Отслеживание вызова React setState из очереди обновлений до фиксации в DOM
Пройдитесь по шагам обновления состояния в React по очереди хуков, планировщику, фазе отрисовки, процессу сопоставления и сохранению изменений, чтобы понять, почему состояние никогда не меняется мгновенно.
Почти каждый разработчик React рано или поздно пишет функцию для изменения состояния, за которой следует вызов console.log, и удивляется, когда видит отображение старого значения. Название setState наводит на мысль о мгновенном присвоении, но React поступает иначе: вызов такой функции просто фиксирует запрос на изменение состояния в будущем, и до того, как что-либо появится на экране, проходит целый набор операций. В этой статье рассматривается процесс одного обновления через весь этот цикл — от очереди обновлений хука, через планирование, отрисовку, сопоставление данных и фазу сохранения изменений. Как только вы сможете представить каждый этап, многие на первый взгляд странные явления станут предсказуемыми: почему изменение состояния кажется асинхронным, почему несколько обновлений могут объединиться в одну отрисовку, почему React иногда пропускает определенные операции и зачем существуют функциональные обновления.
Фрагмент кода, который удивляет всех
Представьте себе ревью кода, где такой подход кажется совершенно разумным:
setCount(count + 1);
console.log(count);
Ожидается, что в консоли отобразится увеличенное значение. Однако там показывается прежнее значение. Вот такая же ситуация внутри полноценного компонента:
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log(count);
};
return (
<button onClick={handleClick}>
{count}
</button>
);
}
При первом нажатии многие разработчики ожидают увидеть:
1
На самом деле в консоли отображается:
0
Причина в том, что React ещё не выполнил повторную отрисовку. В момент выполнения console.log React получил лишь запрос. Ничего из очереди ещё не было применено, функция компонента снова не вызывалась, и DOM не изменился. Код, который вы выполняете, относится к текущей отрисовке, и в рамках этой отрисовки count — это обычная константа, значение которой было задано при первом запуске функции. Ничто не может изменить её значение.
Это первое изменение в ментальной модели: функция-установщик не меняет переменную состояния, которую вы считываете. Она просто планирует выполнение действий для React позже.
Что хранит React при вызове функции-установщика
Когда вы пишете:
setCount(count + 1);
возникает соблазн представить, что React внутри делает нечто подобное:
count = count + 1
Но это не так. React создает объект обновления и добавляет его в очередь, принадлежащую конкретному Hook. Концептуально ситуация после нажатия выглядит так:
Current State
|
▼
count = 0
|
▼
User Clicks
|
▼
setCount(1)
|
▼
Update Queue
[ Update: 1 ]
Само состояние остается нетронутым. Всё, что сделал React, — это записал для себя примечание: в следующий раз при обработке обновлений для этого Hook значение count должно стать 1.
Почему обновления проходят через очередь
Очередь становится оправданной, когда несколько обновлений поступают одно за другим. Рассмотрим обработчик, который вызывает функцию-установщик три раза:
const handleClick = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
Человек, только начинающий работать с React, может ожидать, что один клик приведет к следующему:
count = 3
Но на самом деле происходит следующее:
count = 1
Все три вызова были сделаны во время одной и той же обработки, и в ходе этой обработки произошло следующее:
count = 0
Поэтому каждое выражение count + 1 возвращает одинаковое значение, и React получает три идентичных запроса:
setCount(1)
setCount(1)
setCount(1)
В результате в очереди оказываются три записи с указанием "установить на 1":
[1]
[1]
[1]
Даже если обрабатывать их по порядку, результат будет таким же:
1
а не иным:
3
Та же логика справедлива и в случае, когда значения различаются. Предположим, что в ходе обработки совершаются эти три вызова:
setCount(1)
setCount(6)
setCount(4)
Тогда в очереди окажутся следующие элементы:
[1]
[6]
[4]
Каждая запись полностью заменяет предыдущее состояние, поэтому после обработки важна только последняя запись, и результат равен 4. Простые значения являются заменами, а не инструкциями для дальнейшей обработки предыдущего состояния. Именно из-за этого ограничения существуют функциональные обновления.
Функциональные обновления вычисляются на основе последнего состояния
Теперь измените обработчик так, чтобы каждый вызов передавал функцию:
const handleClick = () => {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
};
На этот раз в очереди находятся не три значения, а что-то вроде трех небольших программ:
prev => prev + 1
prev => prev + 1
prev => prev + 1
Когда React обрабатывает очередь, он передаёт результат каждой функции следующей:
0 → 1
1 → 2
2 → 3
И окончательное состояние будет следующим:
count = 3
Разница заключается в том, что функция обновления не захватывает значение из текущей отрисовки. Она описывает, как получить следующее состояние на основе того состояния, которое у React в момент обработки данного элемента. Используйте эту форму, когда новое состояние зависит от предыдущего, особенно когда несколько обновлений могут быть запущены одновременно или когда обновление происходит внутри функции-возврата, созданной во время более ранней отрисовки.
Полный жизненный цикл одного обновления
Учитывая очередь, проследите за одним кликом, который вызывает:
setCount(count + 1);
Упрощенное представление всего, что происходит далее:
User Click
|
▼
Create Update
|
▼
Place Update Into Queue
|
▼
Notify React Scheduler
|
▼
Schedule Render
|
▼
Render Phase
|
▼
Reconciliation
|
▼
Commit Phase
|
▼
DOM Updated
В следующих разделах рассматривается каждый этап.
Шаг 1: создается запись об обновлении
Вызов setCount(1) никогда не приводит к прямой повторной отрисовке чего-либо:
setCount(1);
React создает запись об обновлении, которую можно представить как примечание с текстом:
Apply this update later.
Эта запись привязана к внутреннему состоянию хука. Данные хука находятся рядом с Fiber компонента — внутренним узлом, который React сохраняет для каждой инстанции компонента, — а очередь обновлений также находится там:
Fiber
|
└── useState
|
├── Current State
└── Update Queue
Именно поэтому хуки должны вызываться в одинаковом порядке при каждой отрисовке: React находит состояние и очередь каждого хука по его позиции в этом списке.
Шаг 2: React планирует выполнение задач
Имея в своем распоряжении данные об обновлении, React должен решить, когда его обработать. Это задача планировщика. React не обязательно отрисовывает интерфейс сразу после вызова функции-установщика; он балансирует между быстротой реакции и избеганием ненужной работы.
Представьте, что пользователь быстро вводит текст в поле:
A
AB
ABC
ABCD
ABCDE
Синхронная отрисовка ресурсоемких элементов интерфейса после каждого нажатия клавиши без возможности установления приоритетов приводит к замедлению работы сложных интерфейсов. Механизм планирования позволяет React объединять обновления, поступающие одновременно, и с помощью таких функций, как переходы, относиться к срочным обновлениям, таким как ввод данных, иначе, чем к менее срочным, например, к списку отфильтрованных результатов. Именно эта возможность стала одной из причин того, почему архитектура React перешла к планированным обновлениям вместо мгновенных.
Шаг 3: на этапе отрисовки компонент запускается снова
Когда React решает, что пришло время обработать задержанные обновления, он запускает новую отрисовку. Отрисовка означает просто повторный вызов функции компонента:
function Counter() {
const [count, setCount] = useState(0);
return <h1>{count}</h1>;
}
Важным моментом является то, что делает useState во время этого вызова. Прежде чем вернуть значение, он обрабатывает очередные обновления по отношению к предыдущему состоянию. Начиная с:
Previous State = 0
React проходит по очереди:
Queue:
[ +1 ]Process QueueResult:
1
а значение, которое возвращает useState, теперь выглядит так:
count = 1
Это значение компонент использует для текущей отрисовки. Именно поэтому новое значение становится видимым только при следующей отрисовке: оно вычисляется там, а не в момент вызова функции-установщика.
Шаг 4: создается новая дерево элементов
Запуск компонента возвращает обновленное дерево элементов React. По сравнению с предыдущей отрисовкой оно выглядит так:
Previous Render
<h1>0</h1>
New Render
<h1>1</h1>
DOM браузера пока не был изменен. В этот момент у React есть только обновленная схема предполагаемого интерфейса.
Шаг 5: сопоставление находит различия
Далее React сравнивает предыдущий результат:
Old Tree
с новым:
New Tree
определяя, что именно изменилось. В этом примере раньше:
Before:
<h1>0</h1>
а после:
After:
<h1>1</h1>
Только текст внутри заголовка отличается, поэтому именно это изменение фиксирует React. Именно такое сравнение и означает процесс согласования. Чтобы узнать подробнее, как React решает, что сохранить, а что заменить, ознакомьтесь с созданием умственной модели для согласования, состояния и хуков в React.
Шаг 6: фаза сохранения изменений влияет на DOM
После того как список изменений известен, React переходит к фазе сохранения и применяет их к реальному DOM:
DOM Before
<h1>0</h1>
DOM After
<h1>1</h1>
Только сейчас пользователь видит новое значение. Именно это обычно представляют себе люди, когда вызывают метод-установщик, но на самом деле это последний из нескольких этапов.
Почему React не применяет обновления сразу
Рассмотрим обработчик, который одновременно обновляет несколько элементов состояния:
setCount(c => c + 1);
setLoading(false);
setUser(data);
Если бы каждый вызов запускал свою отрисовку, получилось бы следующее:
Render 1
Render 2
Render 3
Это три отрисовки, две из которых бесполезны, а также возможное появление промежуточных экранов с несогласованными комбинациями состояния. Вместо этого React группирует обновления. Вызовы помещаются в очередь:
Update
Update
Update
и затем обрабатываются вместе:
|
▼Single Render
Группировка операций позволяет избежать избыточной перерисовки и гарантирует, что пользователь всегда видит одинаковое окончательное состояние. Именно по этой причине React предпочитает планирование операций вместо немедленной их выполнения при каждом обновлении. React также может полностью пропустить выполнение некоторых операций: если обновление приводит к значению, идентичному текущему согласно проверке с помощью Object.is, React может прекратить работу без перерисовки дочерних элементов компонента.
Как это выглядит на практике
Возьмем поле поиска, где каждая нажатие клавиши обновляет три значения состояния:
query
results
loading
Естественно предположить, что каждый из этих методов обновления вызывает отдельную перерисовку. Однако анализ производительности такого компонента обычно показывает иное: операции, выполняемые одновременно в рамках одного события, группируются, поэтому компонент перерисовывается реже, чем можно было бы предположить на основе простого анализа кода. Задача React заключается не только в применении обновлений, но и в их эффективном применении.
Когда у нескольких компонентов есть незавершённые обновления
Обновления не ограничиваются одним компонентом. Рассмотрим такое дерево:
App
├── Header
├── Sidebar
└── Dashboard
Если несколько обновлений поступают в разные части этого дерева, React не обязан слепо пересоздавать всё. Дерево Fiber позволяет React отслеживать:
- какие компоненты имеют незавершённые операции
- где в дереве возникло каждое обновление
- какие поддеревья необходимо обработать, а какие можно пропустить
Именно это позволяет React действовать гораздо более избирательно, чем при подходе «перерисовывать всё при каждой изменении». Обратите внимание, что компонент, который перерисовывается, по умолчанию всё равно перерисовывает свои дочерние элементы; мемоизация позволяет пропускать неизменённые поддеревья.
Возвращение к устаревшему примеру console.log
Вернёмся к фрагменту кода из начала:
setCount(count + 1);
console.log(count);
В консоли выводится:
0
Потому что код всё ещё выполняется в рамках текущей обработки. Очередь ещё не обработана, следующая обработка ещё не произошла, и обновление ждёт своей очереди. Более точное описание:
Current Render
count = 0
Request Update
Future Render
count = 1
Если вам нужно получить новое значение сразу, вычислите его в локальной переменной и используйте её, либо считайте его в следующей обработке или в эффекте, который от него зависит. С такой точки зрения поведение совсем не странное.
Как связаны все элементы
В совокупности эти этапы образуют единую цепочку:
- Состояние хранится вместе с данными Hook компонента.
- Данные Hook привязываются к Fiber компонента.
- Вызов метода-установщика создаёт обновление.
- Обновления добавляются в очередь Hook.
- Распланировщик выбирает момент для их обработки.
- На этапе обработки компонент вызывается снова, и очередь применяется.
Концепции, которые часто изучаются отдельно, на самом деле являются последовательными шагами в одной обработке. Коротко: вызов функции-установщика оставляет состояние без изменений и вместо этого запрашивает запланированное обновление, которое React применяет при последующем отрисовывании, сравнивает его с предыдущим результатом и коммитит в DOM только там, где произошли изменения.
Основные выводы
- Функция-установщик никогда не изменяет состояние на месте; она записывает обновление в очередь для дальнейшей обработки React.
- Обновления задаются в очередь по отдельным хукам, так что несколько из них могут быть обработаны в одном отрисовывании.
- Простые значения заменяют состояние; функции-обновители формируют новое состояние на основе последнего, что позволяет избежать устаревших значений.
- React планирует выполнение операций вместо того, чтобы выполнять отрисовку при каждом вызове, что делает возможным группировку операций и их приоритизацию.
- Отрисовка и обновление DOM — это разные этапы: на этапе отрисовки формируется описание состояния, а на этапе выполнения команды происходит изменение в браузере.
- Очередь обновлений хранится вместе с состоянием Hook в структуре Fiber компонента.
Рассматривание функции setState как запроса, а не как прямой приказ, — это небольшая изменение формулировки с серьезными последствиями. Именно поэтому вообще возможна группировка операций, их планирование, синхронизация и одновременная отрисовка. Естественным следующим вопросом является то, как React определяет, приведут ли обновления из очереди к одной отрисовке или к нескольким, и именно этой проблеме посвящена функция автоматической группировки обновлений, введенная в React 18.