В этой статье мы узнаем, в какой момент React вызывает useEffect, useRef и пользовательские хуки. Для демонстрации будет использован хук usePrevious.
Вопрос, который я люблю задавать разработчикам: «Понимаете ли вы жизненный цикл React»? Очень часто ответ бывает уверенным «да».
Тогда я показываю им код хука usePrevious и позволяю им объяснить, почему он работает. Если вы не знаете, что такое хук usePrevious, вы можете увидеть его ниже. Он используется для получения предыдущего значения параметра или состояния в компоненте, см. документацию React.
const usePrevious = (value, defaultValue) => {
const ref = useRef(defaultValue);
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
};
Обычно я получаю разрозненный ответ, в котором упоминается что-то о том, что useRef обновляется мгновенно, независимо от жизненного цикла, или что useRef не вызывает повторного рендеринга. Это верно.
Тогда я спрашиваю: «Если useEffect обновляет значение ссылки, как только обновляется переданное значение prop, разве хук не вернет обновленное значение ссылки?». Чаще всего ответом является замешательство. Даже если мое утверждение в корне неверно, они не знают жизненный цикл React достаточно хорошо, чтобы объяснить, что не так в моем вопросе. На самом деле, чаще всего они верят, что то, что я говорю, правда, и не понимают, почему этот крючок работает.
Поэтому давайте рассмотрим, как работает хук usePrevious. Это идеальный случай для объяснения того, как React обрабатывает useEffect и useRef.
Что вы говорили раньше?
Логирование работы usePrevious
Здесь у нас есть простой компонент React, использующий хук usePrevious. Его задача — увеличивать счетчик при нажатии на кнопку. Это слишком сложный способ сделать такую вещь, на самом деле нам не нужен хук usePrevious в этом случае, но поскольку обсуждаемая тема — хук usePrevious, статья будет довольно скучной, если мы оставим его без внимания.
// ### App.js
// When the button is clicked, the value is incremented.
// That will in turn increment the count.
// import React, { useEffect, useState } from "react";
// import usePrevious from "./usePrevious";
export default function App() {
const [value, setValue] = useState(0);
const [count, setCount] = useState(0);
const previouseValue = usePrevious(value, 0);
useEffect(() => {
if (previouseValue !== value) {
setCount(count + 1);
}
}, [previouseValue, value, count]);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setValue(value + 1)}>Increment</button>
</div>
);
}
Чтобы лучше понять, что делает React при выполнении кода, ниже я привел тот же код, но с большим количеством консольных логов. Я внимательно просмотрю их все. Вы можете найти пример кода на CodeSandbox, если захотите разобраться самостоятельно.
// ### App.js (with logs)
// When the button is clicked, the value is incremented.
// That will in turn increment the count.
// import React, { useEffect, useState } from "react";
// import usePrevious from "./usePrevious";
export default function App() {
const [value, setValue] = useState(0);
const [count, setCount] = useState(0);
console.log("[App] rendering App");
console.log("[App] count (before render):", count);
console.log("[App] value:", value);
const previouseValue = usePrevious(value, 0);
console.log("[App] previousValue:", previouseValue);
useEffect(() => {
console.log("[App useEffect] value:", value);
console.log("[App useEffect] previouseValue:", previouseValue);
if (previouseValue !== value) {
console.log("[App useEffect] set count to value:", value, "nn");
setCount(count + 1);
} else {
console.log("[App useEffect] not increasing count");
}
}, [previouseValue, value, count]);
console.log("[App] count (after render):", count);
console.log("[App] done rendering Appnn");
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setValue(value + 1)}>Increment</button>
</div>
);
}
// ### usePrevious.js (with logs)
// import { useRef, useEffect } from "react";
const usePrevious = (value, defaultValue) => {
console.log("[usePrevious] value:", value);
const ref = useRef(defaultValue);
useEffect(() => {
console.log("[usePrevious useEffect] value:", value);
console.log("[usePrevious useEffect] increment ref.current:", ref.current);
ref.current = value;
}, [value]);
console.log("[usePrevious] ref.current:", ref.current);
return ref.current;
};
export default usePrevious;
Думаю, теперь достаточно кода. Давайте посмотрим, что произойдет, когда мы нажмем кнопку Increment. Вот что мы увидим в консоли вывода. Я настоятельно рекомендую открыть второе окно браузера, чтобы код был виден, пока вы читаете остальную часть этой статьи.
# App component renders (1)
[App] rendering App
[App] count (before render): 0
[App] value: 1
[usePrevious] value: 1
[usePrevious] ref.current: 0
[App] previousValue: 0
[App] count (after render): 0
[App] done rendering App
# useEffects run (2)
[usePrevious useEffect] value: 1
[usePrevious useEffect] increment ref.current: 0
[App useEffect] value: 1
[App useEffect] previouseValue: 0
[App useEffect] set count to value: 1
# App component rerenders again (3)
[App] rendering App
[App] count (before render): 1
[App] value: 1
[usePrevious] value: 1
[usePrevious] ref.current: 1
[App] previousValue: 1
[App] count (after render): 1
[App] done rendering App
# useEffects run again (4)
[App useEffect] value: 1
[App useEffect] previouseValue: 1
[App useEffect] not increasing count
# (5)
Примечание: Следующее описание следует рассматривать как интерпретацию кода и вывода, приведенного выше. Это не точный алгоритм, который использует React. Подробнее об этом позже.
(1) Итак, вот что происходит. Когда мы нажимаем на кнопку увеличения, она обновляет состояние значения до 1, что вызывает перерисовку компонента App. Хук usePrevious — это первый код, который будет достигнут в рендере, поэтому он вызывается напрямую. В этом хуке мы получаем обновленное значение prop, равное 1, в то время как ref.current по-прежнему имеет значение по умолчанию 0. React отмечает, что зависимость от useEffect изменилась, но пока не запускает useEffect. Вместо этого он возвращает значение ref.current, равное 0, из хука и сохраняет его в переменной previousValue.
Рендеринг компонента App продолжается, и он достигает useEffect. В это время значение было обновлено с 0 до 1, поэтому эффект useEffect должен быть запущен, но еще нет. Вместо того чтобы сработать, React завершает рендеринг со значением count по умолчанию 0.
React отмечает, что зависимость обновилась, но не запускает эффект немедленно
(2) Теперь, после завершения рендеринга компонента App, пришло время запустить useEffects. React отметил, что необходимо запустить как useEffect в хуке usePrevious, так и в компоненте App. Он начинает вызывать useEffect в usePrevious hook, это тот useEffect, который был достигнут первым во время рендеринга.
Когда он запускает код useEffect, он обновляет ref.current до 1 и все. React продолжает работу со следующим по очереди useEffect, который находится в компоненте App. В тот момент, когда компонент App был перерендерирован и React впервые заметил, что значение в списке зависимостей обновилось, переменная previousValue все еще была установлена в 0. Причина, по которой мы запустили useEffect, заключается в том, что значение увеличилось с 0 до 1. Таким образом, if-запрос, сравнивающий значение с previousValue, будет истинным, и мы обновим счетчик с 0 до 1.
(3) Теперь мы опустошили очередь эффектов useEffects. Больше нет эффектов для запуска. Теперь React может проверить, требуется ли перерисовка, и он заметит, что требуется. setCount был вызван, поэтому переменная count обновилась до 1 с 0, и React решает перерисовать компонент еще раз.
Значение переменной state по-прежнему равно 1, мы не увеличивали это значение. На этот раз хук usePrevious вызывается с тем же значением, что и при предыдущем рендеринге, поэтому нет необходимости вызывать useEffect в хуке usePrevious. ref.current по-прежнему имеет значение 1, поэтому переменной previousValue будет присвоено значение 1. Когда мы достигаем useEffect в компоненте App, React отмечает, что previousValue обновилась, но ничего не делает. Он продолжает рендеринг компонента App и изящно завершает его со счетом 1.
(4) Рендеринг завершен, но у нас есть useEffect в очереди на выполнение. Как уже говорилось, у useEffect в usePrevious не было причин для срабатывания, поэтому React продолжает непосредственно с эффектом в компоненте App. previousValue теперь равно 1, поэтому мы запустили useEffect. value не изменилось и по-прежнему установлено в 1, поэтому мы не вызываем функцию setCount.
(5) Теперь мы закончили выполнение useEffects, поэтому React пора проверить, не требуется ли повторный рендеринг. Это не так, поскольку ни value, ни count не обновились, когда мы запускали эффекты. Поэтому React успокаивается и ждет дальнейших действий пользователя.
Как выглядит жизненный цикл?
То, что я описал выше, не является техническим описанием жизненного цикла React, скорее это интерпретация того, что происходит во время работы кода. Здесь нет времени на подробное объяснение того, как на самом деле выглядит код React. Очевидно, что он немного сложнее, чем я описываю в этой статье. Нам понадобится более сложный пример, включающий дочерние компоненты и т.д., и нам нужно будет поговорить о рендере и фазе фиксации. Для тех, кому интересно, краткое объяснение этого можно найти здесь.
Как бы то ни было, чтобы помочь вам понять порядок выполнения, который я описал в пяти шагах выше, я подытожу его с помощью псевдокода.
const rerender = () => {
// run code in component
// if we reach a useEffect
if (useEffectDependenciesHasUpdated) {
useEffectQueue.push(useEffectCode)
}
// continue running code in component
}
const reactLifeCycle = () => (
while (true) {
if (stateHasChanged) {
rerender()
runEffectsInQueue()
}
}
)
Как видите, приведенного выше псевдокода достаточно, чтобы объяснить, почему работает хук usePrevious. На базовом уровне жизненный цикл можно объяснить следующим образом. React рендерит компонент и запускает код внутри него. Всякий раз, когда достигается эффект useEffect, react просматривает список зависимостей. Если переменная в списке зависимостей изменилась, React добавляет функцию обратного вызова в useEffect в очередь.
Когда рендеринг завершен, react начинает извлекать обратные вызовы эффектов из этой очереди и вызывать их. Когда очередь пустеет, React начинает проверять, нужно ли снова рендерить какие-либо компоненты.
Почему мой вопрос был ошибочным
В начале статьи я объяснил, как я задал людям вопрос о хуке usePrevious. Можете ли вы теперь объяснить, что в вопросе не так?
Если useEffect обновляет значение ссылки, как только переданное значение prop обновляется, разве хук не вернет обновленное значение ссылки?
На самом деле, ответ на вопрос таков: да. Если бы useEffect обновлял значение ref сразу после обновления переданного значения, то да, в этом случае мы бы вернули обновленное значение ref. Но React работает не так. UseEffect не вызывается мгновенно. Он вызывается после того, как React завершил фазу рендеринга и родительский компонент уже прочитал старое значение ссылки.
Заключение
Можно многое сказать об обработке жизненного цикла React. В этой статье мы рассмотрели только useEffect, useRef и пользовательский хук usePrevious, чтобы увидеть, в каком порядке React выполняет код.
Что мы можем обнаружить, используя пользовательский хук usePrevious, так это то, что React вызывает пользовательский хук, как только достигает его на этапе рендеринга. Хук — это просто кусок кода, извлеченный из компонента.
Однако, когда мы достигаем хука useEffect, React, похоже, вообще ничего не делает, а ждет окончания рендеринга компонента, а затем, после его окончания, вызывается обратный вызов в useEffect.
Я сказал «вроде бы ничего», потому что так оно и есть на самом деле. Внутри React обрабатывает множество вещей под капотом. Список зависимостей должен быть проверен, чтобы понять, нужно ли вообще запускать обратный вызов или нет. React также должен отслеживать старые зависимости, чтобы иметь возможность сравнить их. Но это тема для другого дня. Сегодня вам нужно знать, что обратные вызовы useEffect вызываются после завершения рендеринга компонента и выполняются в том же порядке, в котором код до них доходит.
После выполнения useEffect компонент может повторить рендеринг во второй раз, если его состояние обновилось, например, если была вызвана функция set, возвращаемая useState. Если эффект useEffect обновляет только значение useRef, то React не перерисовывает компонент. Это значение обновляется немедленно.
Спасибо за прочтение,
Деннис