Главная / Статьи

Почему setState никогда не обновляет интерфейс сразу: планирование обновлений в React

Следуйте за обновлением состояния React из объекта очереди обновлений через механизмы отметки путей и планирования до основного цикла обработки, который пропускает незатронутые поддеревья.

Издатель: REACTAPP.TOP8 мин чтения1762 слов

Источники: The Fiber Architecture In React Part 3: What Actually Happens When You Call setState

Почти у каждого разработчика на React есть тихое предположение: вызов функции изменения состояния, такой как setCount(count + 1), приводит к немедленной переработке компонента перед выполнением следующей строки кода. На самом деле это не так, и разница во времени между вызовом и перерисовкой объясняет принцип группировки операций, почему не связанные компоненты остаются нетронутыми, и почему считывание состояния сразу после его изменения возвращает старое значение. В этой статье следуется одно обновление через архитектуру Fiber в React, от момента его записи до момента, когда новое значение поступает в состояние компонента, чтобы вы могли понять, что на самом деле делают функции изменения состояния.

Компонент под микроскопом

Для демонстрации всего механизма достаточно минимального счётчика. Он хранит одно значение состояния и увеличивает его при нажатии кнопки.

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

Вызов setCount(count + 1) напоминает обычную синхронную функцию, поэтому кажется логичным, что React снова вызовет Counter() в качестве прямого следствия этой строки. Однако ничего подобного не происходит. Функция-сеттер возвращаетсь, ничего не отрисовывая. Вместо этого она записывает запрос и просит выполнить работу позже.

Первый шаг: сеттер записывает обновление

Когда выполняется setCount(1), React выделяет в памяти небольшой объект обновления, описывающий этот запрос. Концептуально это выглядит так:

{
  action: 1,
  next: null,
}

Поле action хранит то, что было передано, в данном случае значение 1. Поле next существует потому, что одно обновление редко является всем, что нужно сделать. Если вызвать метод-установщик три раза внутри одного обработчика, React создаст три объекта обновления, связанных между собой через next, что приведет к формированию небольшого списка, привязанного к компоненту. В реальном исходном коде React обновление также содержит информацию о приоритете (лейне), а список ожидающих обновлений хранится в виде циклической структуры, но суть заключается в очереди задач, а не в повторном отрисовывании.

В упрощенной модели фибры обновление привязывается следующим образом:

{
  type: Counter,
  memoizedState: { count: 0 },   // still 0, untouched
  updateQueue: { action: 1, next: null },   // the note, just added
}

Обратите внимание на то, что не изменилось. Сохраненное состояние по-прежнему равно 0, на экране по-прежнему отображается 0, и функция Counter() так и не была вызвана. Единственная разница заключается в том, что объект теперь находится в очереди. (Для точности: с использованием хуков React хранит очередь каждого хука в записи этого хука в списке хуков фибры, а не непосредственно в поле с именем updateQueue; memoizedState у функционального компонента-фибры — это и есть такой список хуков. Приведенная выше упрощенная модель предназначена для обучения и сохраняет поведение, важное здесь.)

Это также объясняет классическую неожиданность: если вы выведете значение count сразу после вызова setCount, вы увидите старое значение, потому что функция компонента, которая хранила ссылку на count, еще не была выполнена повторно.

Второй шаг: scheduleUpdateOnFiber отмечает путь и запрашивает выполнение работ

Сразу после того, как обновление помещается в очередь, React вызывает внутреннюю функцию с именем scheduleUpdateOnFiber. У неё есть две отдельные задачи, и разделение их делает остальную часть процесса более понятной.

Отмечание пути к корню

Сначала React проходит вверх от фибры Counter через каждый указатель родителя, пока не достигнет корня дерева. На каждом предке по пути он устанавливает поле с именем childLanes, которое фактически означает «у кого-то ниже меня есть задачи, ожидающие выполнения». Сама фибра Counter получает связанное с этим поле lanes, что означает «у меня тоже есть задачи, ожидающие выполнения».

Эти поля — не простые логические значения. Это маски битов, которые кодируют приоритет, что является отдельной темой; см. как React lanes используют маски битов для кодирования приоритета обновлений. Для данного руководства достаточно знать, что они переходят от пустого состояния к непустому, оставляя след от обновленного компонента до верхней части иерархии.

Отложение фактической отрисовки

Во-вторых, React требует, чтобы работа по отрисовке выполнялась в ближайшее время, а не внутри обработчика событий. Конкретный механизм зависит от приоритета. Работа, обрабатываемая планировщиком React, обычно отправляется с помощью MessageChannel (при отсутствии которого используется setTimeout в качестве альтернативы), поэтому она выполняется на следующем этапе цикла событий. Для отдельных событий высокого приоритета, таких как клики, современные версии React обычно выполняют соответствующую работу в виде микозадачи сразу после завершения обработчика, то есть всё равно позже, чем ваш код, но быстрее. Конкретная стратегия менялась между версиями, поэтому считайте эти детали специфичными для конкретной версии.

Каким бы ни был канал, последствия одинаковы: отрисовка намеренно откладывается до возврата обработчика. Именно это позволяет использовать группировку операций. Три вызова setCount в одном обработчике приводят к созданию трех задач для очереди обновлений, и один последующий этап отрисовки обрабатывает их все сразу, вместо того чтобы выполнять отрисовку три раза.

Третий шаг: отрисовка всегда начинается с корня

Когда запланированная работа наконец выполняется, она не начинается с Counter. Она начинается с корневой волокна — специального узла, находящегося над всим приложением и созданного при вызове ReactDOM.createRoot. Это происходит независимо от того, на какой глубине дерева возникло обновление.

Начало обхода с корня может показаться дорогостоящим, но это не означает необходимости посещения каждой ветви. Путь childLanes, заданный на втором шаге, именно тот, который используется при обходе. У каждой ветви React проверяет, есть ли в поддереве незавершённые операции. Если их нет, React прекращает обработку и пропускает всё поддерево, не спускаясь в него. Преемники по отмеченному пути обрабатываются быстро, а исследуются только те ветви, которые ведут к незавершённым операциям.

Цикл, а не рекурсия

Обход реализуется с помощью цикла, а не глубокой рекурсии:

let node = rootFiber;
while (node !== null) {
  node = processNode(node);   // moves to node.child, node.sibling, or up via node.return
}

Каждая волокно содержит три указателя: child — на своего первого ребенка, sibling — на следующее волокно на том же уровне и return — на своего родителя. Этих трех ссылок достаточно для постепенного перемещения к детям, между собратьями и обратно наверх по окончании обработки ветви. Не требуется массив детей для итерации. Поскольку положение в дереве хранится в переменной, а не на стеке вызовов, React может приостанавливать работу между операциями и возобновлять ее позже, что является основой параллельной отрисовки.

Почему не связанные компоненты остаются нетронутыми

Это механизм, стоящий за эффектом, который вы, вероятно, уже наблюдали. Ввод данных в поле ввода обновляет только ту часть дерева, которая отвечает за данный состояние. Далекий, не связанный с ним компонент не перерисовывается и даже не обрабатывается, поскольку никакое обновление не затронуло его ветвь. Такой пропуск — это не какая-то специальная оптимизация, добавленная позже; он является прямым следствием использования механизма childLanes.

Имейте в виду ограничения этого подхода. Компонент на отмеченном пути, у которого есть незавершённые операции, будет перерисован, и по умолчанию будут перерисованы все его потомки, отмеченные или нет, если только они не сохранены в кэше. Механизм childLanes указывает React, куда двигаться; он не мешает родителю, перерисовывающемуся, перерисовать своих детей.

Четвёртый шаг: работа с Counter и обработка очереди

Когда обработка достигает Counter, вступает в действие механизм двойного буферинга. React проверяет указатель alternate в текущей волокне и либо повторно использует существующее волокно с незавершенной обработкой, либо создаёт новое. Именно в этой копии с незавершенной обработкой вычисляется новое состояние.

Теперь очередь наконец считывается. React обрабатывает ожидающееся обновление, применяет действие к предыдущему состоянию и записывает результат в memoizedState фибры с текущей обработкой. На этом этапе фактически вызывается Counter(), и useState возвращает новое значение. Если было ожидание нескольких обновлений, они применяются в порядке; функциональные обновления, такие как setCount(c => c + 1), каждое получает результат предыдущего, поэтому они корректно работают при групповом применении, в то время как setCount(count + 1) три раза приводит лишь к одному увеличению.

// work-in-progress fiber, right after processing the queue
{
  type: Counter,
  memoizedState: { count: 1 },   // finally updated, using the queued action
  updateQueue: null,             // consumed, nothing left pending
  alternate: Fiber A,
}

Теперь очередь пуста, а новое состояние находится в волокне с текущей обработкой. Состоялись три отдельных механизма взаимодействия: обновление из очереди, указанное на предыдущих шагах, поиск по отмеченному следу и обход в порядке от корня к дочерним элементам на следующих шагах, а также пара волокон текущей обработки, хранящая версии до и после изменений. Чтобы узнать больше о процессе, включающем согласование изменений и фиксацию в DOM, ознакомьтесь с описанием отслеживания вызова React setState от очереди обновлений до фиксации в DOM.

Полная последовательность

Если рассматривать всё по порядку, один клик проходит через следующие этапы:

  • Обработчик клика вызывает setCount, и React создаёт объект обновления, добавляя его в очередь компонента.
  • scheduleUpdateOnFiber устанавливает значение lanes в объекте Counter и childLanes во всех предках до корня.
  • Эта же функция запрашивает выполнение этапа отрисовки после возврата обработчика.
  • Этап отрисовки начинается с корневого элемента и проходит через элементы типа child, sibling и return, пропуская поддеревья, в которых нет незавершенных операций.
  • В объекте Counter React готовит элемент, на котором ведутся работы, обрабатывает очередные обновления и вычисляет новое состояние.
  • На этапе сохранения применяются изменения к DOM, и элемент, на котором ведутся работы, становится новым текущим элементом.
  • Ничего из этого не происходит на той строке, где вы вызвали функцию-установщик. Всё выполняется позже, в структуре, поля которой созданы специально для поддержки этого отложенного, возобновляемого процесса.

    Основные выводы

    • Установщик состояния добавляет обновление в очередь и планирует его выполнение; он никогда не выполняется синхронно внутри обработчика.
    • Чтение состояния сразу после его изменения возвращает старое значение, поскольку компонент ещё не перезапущен.
    • Группировка обновлений — естественный результат отложенной обработки: все обновления, добавленные в очередь до этапа отрисовки, обрабатываются одновременно.
    • lanes и childLanes обозначают путь от обновлённой ветви до корня, что позволяет React пропускать незатронутые поддеревья.
    • Цикл отрисовки использует указатели child, sibling и return вместо рекурсии, что позволяет React приостанавливать и возобновлять работу.
    • При повторной отрисовке компонент по умолчанию всё равно перерисовывает свои дочерние элементы; трек ветвей ограничивает область поиска React, а не то, что отрисовывает родительский компонент.