Главная / Статьи / Где хранится значение useState: элементы React против Fiber

Где хранится значение useState: элементы React против Fiber

Почему функциональный компонент React забывает всё между вызовами, почему элементы не могут хранить состояние и как поле memoizedState во фрагменте поддерживает актуальность значений useState.

1724 слов

Компонент-функция — это просто функция, и функции забывают свои локальные переменные сразу после возврата. Однако useState возвращает обновленное значение при каждом отрисовывании, словно функция его помнит. В этой статье дается точный ответ на один узкий вопрос: где физически хранится это значение между отрисовываниями? К концу вы сможете различать два объекта, которые React создает для каждого компонента — элемент и фибр, и объяснить, какой из них хранит состояние и почему.

Загадка: функция без памяти

Начнем с самого распространённого компонента — счётчика:

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

Нажмите один раз — появится 1, нажмите снова — появится 2. В этом нет ничего удивительного, пока вы не рассмотрите это как обычный JavaScript. Counter — это функция. Каждый раз, когда вызывается функция, её локальные переменные создаются заново, а когда функция возвращается, они исчезают. При следующем вызове всё начинается с нуля.

Согласно этой логике, при втором вызове React функции Counter() строка useState(0) должна снова вернуть значение 0. Однако она возвращает 1. Значит, что-то вне функции должно сохранять число между вызовами. Это не может быть тело функции, потому что функции просто не ведут себя так. Так что же это?

Исключение элемента

Есть очевидный кандидат, который оказывается неверным, и его исключение делает настоящий ответ более понятным.

Строка <button onClick={...}>{count}</button> является JSX. Браузеры никогда не выполняют JSX; компиляторы вроде Babel или TypeScript-компилятор преобразуют его в обычный вызов функции перед развертыванием кода. Концептуально результат следующий:

React.createElement("button", { onClick: fn }, count)

Благодаря введению автоматической среды выполнения JSX в React 17 компилируемый вызов на самом деле представляет собой jsx() из модуля react/jsx-runtime, а не React.createElement, но оба решения выполняют одну и ту же задачу. Как бы ни называлась эта функция, она возвращает обычный объект:

{
  type: "button",
  props: { onClick: fn, children: 1 },
}

В React это называется элементом. Это описание, а не реальный объект: небольшая запись с указанием того, что здесь должна появиться кнопка с определенным обработчиком клика и отображением этого числа. Настоящие элементы содержат дополнительные поля, такие как key, ref и внутренний маркер $typeof, но ни одно из них не хранит историю изменений.

Теперь ключевое замечание. Каждый вызов Counter создает совершенно новый элемент. При нажатии на кнопку запускается Counter(), возвращается новый объект, а предыдущий становится мусором. Если бы состояние хранилось в элементе, не было бы способа связать процесс отрисовки двух элементов с процессом отрисовки одного, поскольку объект для отрисовки одного элемента уже не существует к моменту начала отрисовки двух.

Такая временность является намеренной. Элементы дешевы именно потому, что они создаются заново при каждой отрисовке и им не нужно синхронизироваться ни с чем. Это также означает, что их нельзя размещать там, где хранится count.

Объект, который сохраняется: файбер

Помимо элементов, React хранит ещё один объект с более длительным сроком жизни для каждого подключенного компонента. Его не создают заново при каждой отрисовке — он формируется при первом подключении компонента, а затем сохраняется и обновляется до тех пор, пока компонент остается на экране. Это и есть файбер.

Волокна часто описываются как что-то таинственное, но на самом базовом уровне волокно — это обычный объект JavaScript. За ним нет никаких специальных конструкций во время выполнения. Если остановиться с помощью отладчика в механизме согласования React и изучить одно волокно, вы увидите обычный объект с набором свойств, которые можно было бы создать самостоятельно с помощью пары фигурных скобок. Их также можно увидеть в браузере: React присваивает внутренние ключи узлам DOM, указывающие на соответствующие волокна, и именно так React DevTools находит их.

Вместо того чтобы перечислять все поля сразу, полезно создавать волокно для компонента Counter по одному свойству за раз. Сразу после монтирования минимальная версия выглядит так:

{
  type: Counter,
}

type: чья это учетная информация

type — это самое простое поле. Оно указывает, какой компонент отслеживается этим волокном. type: Counter означает, что объект создан для управления экземпляром Counter, в котором хранится ссылка на саму функцию.

У элементов-хостов тоже бывают волокна. Кнопка, которую отрисовывает Counter, имеет свое собственное волокно, и её type — это строка "button", а не функция. Таким образом, волокно <Counter /> имеет значение { type: Counter }, а волокно <button> — значение { type: "button" }: одно и то же поле, одна и та же цель, но оно указывает либо на ваш компонент, либо на встроенный тег.

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

type ничего не говорит о том, как сохраняется состояние. Для этого требуется следующее поле.

memoizedState: где на самом деле хранится значение счётчика

useState нуждается в месте за пределами функции для хранения текущего значения, поскольку локальные переменные функции сбрасываются при каждом вызове. Это место — поле в фибре с названием memoizedState. Термин «запомненное» просто означает, что значение сохраняется с прошлого раза вместо того, чтобы его пересчитывать заново.

Добавление этого в скетч даёт:

{
  type: Counter,
  memoizedState: { count: 0 },
}

Это упрощённая схема. В реальной реализации поле memoizedState у функционального компонента указывает на первый элемент связанного списка объектов хуков — по одному на каждый вызов хука, причём число 0 находится в собственном поле memoizedState этого хука, а не в объекте { count: 0 }. React не знает, что ваша переменная называется count; он знает только «значение первого хука». Для понимания того, где находится состояние, достаточно упрощённой версии.

Теперь произойдет нажатие. React снова вызывает Counter(). Когда выполнение достигает строки useState(0), хук не возвращает значение 0 в вашем коде. Он ищет сохраненное состояние фибры, находит текущее значение (которым становится 1 после обработки нажатия) и возвращает его. Аргумент для useState представляет собой лишь начальное значение: он используется при первом отрисовывании компонента, а затем игнорируется. С этого момента источником правды является фибра, а не литеральное значение в теле функции.

Это полный ответ на начальную загадку. Счетчик не находится в функции, которая забывает всё между вызовами, и не в элементе, который удаляется после каждого отрисовывания. Он хранится в фибре — отдельном объекте, который React сохраняет и обновляет при каждом отрисовывании Counter.

Две практические последствия

Эта модель объясняет несколько явлений, которые иначе кажутся произвольными:

  • Изменение аргумента функции useState после монтирования не влияет на сохранённое значение, поскольку React считывает его только один раз. Если необходимо сбросить состояние на основе параметров компонента, измените key компонента, чтобы React создал новую структуру обработки.
  • Поскольку структура обработки находится по позиции в дереве компонентов, состояние принадлежит тому месту, где рендерится компонент, а не определению функции. Два элемента <Counter /> рядом друг с другом имеют две отдельные структуры обработки и два независимых счётчика.

Что хранится в структуре обработки помимо этих двух полей

type и memoizedState достаточны для решения проблемы состояния, но настоящий фибер содержит гораздо больше информации. Он хранит ссылку на DOM-узел, который был создан, указатели на родителя, первого потомка и следующего брата/сестру, чтобы React мог пройтись по дереву, а также предыдущие параметры для сравнения с новыми. Именно эти поля определяют, как React выясняет, что изменилось, и как он прослеживает всё дерево компонентов, что является отдельной проблемой от вопроса о местоположении отдельного значения.

Теперь можно четко определить границу между этими двумя объектами, поскольку их смешение является причиной многих недопониманий в отношении отрисовки:

Element                           Fiber
--------                          -----
Created by React.createElement    Created internally by React
New object every render           Same object, updated in place
Discarded right after             Persists for the component's
  React reads it                    entire mounted lifetime
Holds no history                  Holds memoizedState, the real
                                     remembered value across renders
Describes what should exist       Is the thing that actually exists,
                                     with real memory attached to it

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

Один нерешенный момент: фибры появляются парами

Утверждение, что фибра «обновляется на месте», является полезной первой оценкой, но это не вся правда. На самом деле React хранит две версии каждой фибры: одну, соответствующую тому, что сейчас отображается на экране, и другую, готовящуюся к следующему обновлению, и переключается между ними. Именно такая структура позволяет React работать над обновлением без нарушения видимого интерфейса, и это заслуживает отдельного объяснения.

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

  • Локальные переменные функционального компонента создаются заново при каждом вызове, поэтому сама функция не может хранить состояние.
  • JSX компилируется в вызовы, которые генерируют элементы: простые описания, уничтожаемые при каждой отрисовке.
  • Fibers — это обычные объекты, которые существуют до тех пор, пока компонент подключен; атрибут type указывает, какой компонент отслеживается fiber.
  • useState читает и записывает значения через memoizedState fiber, который на самом деле указывает на список объектов hook в порядке их вызова.
  • Начальное значение, переданное в useState, имеет значение только при подключении компонента; после этого источником правды является fiber.

См. также