Почему существуют правила хуков: волокна, списки хуков и диспетчеры
Экскурсия во внутренности React: узлы Fiber, двойное буферирование, каналы обработки, списки связей хуков и то, как каждая встроенная семья хуков сохраняет свое состояние и планирует выполнение операций.
Большинство разработчиков React могут изложить правила использования хуков, но гораздо меньше из них способны объяснить, почему их нарушение ведет к повреждению состояния, а не просто к появлению полезной ошибки. Ответ кроется во внутреннем устройстве React: каждый вызов хука становится узлом в связном списке, сохраняемом в Fiber, и React находит каждый узел исключительно по порядку вызова хуков. В этом руководстве рассматриваются Fiber, объект хука, диспетчеры, между которыми переключается React во время отрисовки, а также внутренние механизмы каждой группы хуков, благодаря чему правила перестают казаться произвольными и становятся очевидным следствием архитектуры.
Эти знания не понадобятся вам для написания формы или получения данных. Они оказываются полезными при отладке устаревших значений, выборе между useEffect и useLayoutEffect или при попытке понять, почему мемизированный потомок всё равно перерисовывается.
Краткий обзор: что заменили хуки и какие правила с ними пришлись
Хуки появились в React 16.8, и количество встроенных хуков выросло примерно до семнадцати. Они решили три давних проблемы компонентов классов: логику с состоянием было сложно повторно использовать в разных компонентах, связанная логика была рассеяна по методам жизненного цикла, из-за чего компоненты становились слишком объемными, а сами JavaScript-классы (связывание this, понимание жизненных циклов) вызывали замешательство у многих разработчиков.
Вместе с хуками появился набор правил:
- Вызывайте хуки на верхнем уровне тела функционального компонента.
- Вызывайте хуки на верхнем уровне тела пользовательского хука.
- Никогда не вызывайте хуки внутри условий или циклов.
- Никогда не вызывайте хуки после условного раннего
return. - Никогда не вызывайте хуки внутри обработчиков событий.
- Никогда не вызывайте хуки в компонентах классов.
useEffect, useMemo или useReducer.try, catch или finally.Нарушение этих правил приводит к предупреждениям, ошибкам или, что ещё хуже, к скрытым багам. Краткое объяснение заключается в том, что хуки компонента образуют односвязный список, привязанный к узлу Fiber — состоянийному объекту JavaScript, который React хранит для каждого компонента. Чтобы понять, почему это важно, начните с самого Fiber. Если вы уже хорошо его знаете, перейдите сразу к разделу о объекте хуков. Для более простого введения в концепции согласования и состояния ознакомьтесь с страницей о создании умственной модели согласования, состояния и хуков в React.
React Fiber: движок, в котором работают хуки
Fiber — это движок согласования в React, представленный в React 16 как полная переработка способа, которым React вычисляет и применяет обновления интерфейса.
Проблемы старого согласователя
До появления Fiber React использовал так называемый согласователь на основе стека. При каждом обновлении он рекурсивно просматривал дерево компонентов, и рекурсивный проход по стеку вызовов JavaScript нельзя было остановить на полпути: как только он начинался, он продолжался до обработки всего дерева. При большом размере дерева это приводило к занятию всей основной потоковой цепочки, из-за чего анимации замирали, нажатия клавиш откладывались, а интерфейс «трясся». Что ещё хуже, не существовало способа позволить срочному обновлению, такому как нажатие клавиши, опередить длительную, менее важную процедуру отрисовки, уже запущенную.
Что позволяет Fiber
Файбер — это обычный объект JavaScript, представляющий одну единицу работы, связанную с экземпляром компонента или узлом DOM. Поскольку React сам отслеживает эти единицы, не полагаясь на стек вызовов, у него появляются три преимущества:
- Пауза и возобновление. React может остановиться на середине обработки, позволив браузеру заняться более срочными задачами, такими как ввод данных пользователя, а затем продолжить с того же места.
- Приоритизация. Срочные обновления могут отодвинуть на второй план менее важные.
- Повторное использование или утилизация. Если пользователь покидает страницу во время обработки, незавершенная работа может просто быть проигнорирована.
Структура узла Fiber
Каждому элементу React — будь то компонент, узел DOM или текстовый узел — соответствует свой Файбер. Это крупный объект, содержащий параметры компонента, его состояние и ссылку на его представление в DOM.
Вместо хранения дочерних элементов в массивах волокна формируют дерево с помощью трех указателей:
childведет к первому дочернему элементу волокна.siblingведет к следующему волокну на том же уровне.returnвозвращает к родителю.
Обработка обновлений подразумевает следование этим ссылкам: спускаться вниз по указателю child насколько это возможно, перемещаться вбок по указателю sibling, а при завершении обработки ветви — возвращаться наверх по указателю return. Поскольку это обычный цикл по указателям, а не рекурсия, React может остановиться между любыми двумя волокнами.
Двойное буферирование с двумя деревьями
Концепция двойного буферирования заимствована из графического программирования. В любой момент React хранит в памяти два дерева волокон:
- Текущее дерево точно отражает содержимое экрана. React не изменяет его во время вычисления обновлений.
- Рабочее дерево формируется на фоне при любых изменениях. React копирует текущие элементы, которые нуждаются в обновлении, и создаёт новую версию рядом со старой.
Когда рабочее дерево готово, React меняет указатель корня. Рабочее дерево становится текущим, и экран отражает новое состояние. Каждый элемент хранит указатель alternate на свой аналог в другом дереве, благодаря чему состояние хуков передаётся от одной рендер-выполнения к другой.
Этап рендеринга и этап фиксации
Эта архитектура делит каждое обновление на два этапа.
Этап отрисовки может быть прерван. React проходит по дереву, вызывает функции компонента, выполняет хуки и сравнивает результат с текущим деревом, одновременно создавая в памяти временное дерево. Поскольку React контролирует цикл просмотра дерева, его планировщик может передавать управление браузеру каждые несколько миллисекунд. Если пользователь вводит данные во время выполнения отрисовки низкого приоритета, React может приостановиться, обработать ввод и возобновить работу. Он даже может удалять всё временное дерево, когда появляется более новое и срочное обновление, делающее его устаревшим. Поскольку отрисовка может выполняться несколько раз или вообще не завершаться, функции компонентов должны быть чистыми: во время отрисовки не допускается наличие побочных эффектов.
Этап коммита является синхронным. Как только дерево WIP готово, React одновременно применяет рассчитанные изменения к реальному DOM. Этот шаг не может быть приостановлен, поскольку прерывание на полпути мутаций DOM приведет к тому, что пользователь увидит частично обновленный, несогласованный интерфейс. Здесь выполняются эффекты макета, отсюда планируются пассивные эффекты, и здесь привязываются ссылки.
Каналы: как React определяет, что прервать
Чтобы понимать, что может прервать что, React помечает каждое обновление каналом. Каналы представлены в виде битов в маске битов, что позволяет быстро комбинировать и сравнивать приоритеты. В общих чертах:
- Синхронный канал для дискретных, срочных взаимодействий, таких как клики и нажатия клавиш (непрерывные события, такие как наведение курсора или прокрутка, имеют свой собственный канал с высоким приоритетом).
- Каналы перехода для работ, которые можно прервать: фоновые обновления, перерисовка на основе данных, смена вкладок.
Вы никогда не работаете напрямую с Fiber, однако именно он лежит в основе ключевых функций React 18 и 19. Одновременная отрисовка, Suspense, useTransition и useDeferredValue — всё это зависит от возможности прерывания процесса отрисовки.
Объект хуков и почему порядок вызова имеет решающее значение
У функциональных компонентов нет экземпляра для хранения состояния, поэтому React сохраняет его в Fiber в поле под названием memoizedState. Для функциональных компонентов это поле указывает на первый хук в односвязном списке.
Каждый вызов хука во время отрисовки соответствует объекту, имеющему примерно такую структуру:
{
memoizedState: any, // The internal state of the hook
baseState: any, // The state before any unprocessed updates
baseQueue: Update | null, // Updates that were skipped due to priority
queue: UpdateQueue | null, // Circular linked list of pending state updates
next: Hook | null // Pointer to the next hook in the component
}
memoizedState гука хранит его значение (состояние для useState, запись эффекта для useEffect, закэшированная пара для useMemo), queue хранит ожидающие обновления, baseState и baseQueue отслеживают обновления, которые были пропущены из-за того, что их обработка не происходила, а next указывает на следующий гук.
Обратите внимание на отсутствующее: здесь нет ключа или имени. При повторной отрисовке React просто проходит по списку с начала, соединяя первый вызов хука с первым узлом, второй — со вторым и так далее. Если хук вызывается внутри if, и условие меняется, каждый последующий вызов будет соединяться с неверным узлом, в результате чего состояние одного хука «протекает» в другой. Именно поэтому существуют Правила хуков: они гарантируют, что одинаковые хуки выполняются в том же порядке при каждой отрисовке. Правила, касающиеся циклов, досрочного возврата, блоков try и обратных вызовов, — это всего лишь вариации одного и того же требования.
Одно современное исключение подтверждает этот принцип: API use в React 19 можно вызывать условно, именно потому что оно не зависит от позиции в списке таким же образом.
Диспетчеры: одинаковое имя хука, разные реализации
useState, который вы импортируете, представляет собой упрощённый обёрточный объект. Во время выполнения он перенаправляет запросы к тому диспетчеру, который React установил для текущей фазы. В более старых версиях React это реализуется через ReactCurrentDispatcher; в новых версиях диспетчер находится во внутреннем общем объекте React, но принцип остаётся тем же.
- HooksDispatcherOnMount активен во время первой отрисовки.
useStateсоотносится сmountState, который создаёт новый объект хука, устанавливает его начальное состояние и добавляет его в конец списка. - HooksDispatcherOnUpdate активен при повторной отрисовке.
useStateсоотносится сupdateState, который проходит по существующему списку (фактически выполняется операцияworkInProgressHook = workInProgressHook.next), обрабатывает очередь задач и возвращает новое состояние.
Такая архитектура также объясняет, почему вызов хуков внутри обработчиков событий не работает: к моменту выполнения обработчика отрисовка уже завершена, и диспетчер с возможностью выбрасывания ошибок уже активен.
Как внутренне работают различные семейства хуков
Все хуки основаны на структуре связного списка, но сильно отличаются тем, что они хранят и в какой момент выполняют свои действия.
Хуки состояния: useState и useReducer
Внутренне useState представляет собой useReducer с встроенным редюсером, который либо возвращает новое значение, либо вызывает вашу функцию обновления с предыдущим значением. Оба используют одну и ту же модель выполнения:
- Хранение данных. Механизм хранения сохраняет базовое состояние (последнее зафиксированное значение) и очередь обновлений, представляющую собой циклический связанный список ожидающих изменений.
- Обработка запросов. При вызове метода-установщика, например
setCount(c => c + 1), создается объект обновления, содержащий эту операцию; он добавляется в очередь, а фибра отмечается как требующая обработки путем присвоения соответствующего канала (в более старых версиях для той же цели использовались времена истечения). - Реализация обновлений. Во время следующей отрисовки React проходит по очереди и применяет каждую операцию в порядке, чтобы сформировать новое
memoizedState. Обновления, каналы которых не включены в текущую отрисовку, сохраняются вbaseQueueи обрабатываются позже, что обеспечивает соблюдение порядка выполнения даже при разных приоритетах.
Именно поэтому функции обновления являются безопасным выбором, когда следующее состояние зависит от предыдущего: они применяются по очереди к тому состоянию, которое было вычислено очередью до этого момента.
Хуки эффектов: useInsertionEffect, useLayoutEffect и useEffect
Каждый хук эффекта сохраняет запись эффекта в своем состоянии хука, содержащую функцию настройки, функцию очистки и массив зависимостей. Эти записи также добавляются в отдельный список в updateQueue фибры, причем эффекты, зависимости которых изменились, отмечаются, чтобы на этапе выполнения было ясно, какие из них нужно запустить. Эти три хука отличаются по времени выполнения:
- useInsertionEffect выполняется до эффектов макетирования, раньше любого кода, который может считывать структуру макета. Он предназначен для библиотек CSS-in-JS, которым необходимо заранее вставить правила
<style>, чтобы стили уже были применены на момент измерения макета, тем самым избегая повторного расчёта стилей. - useLayoutEffect выполняется синхронно после того, как React изменит DOM, но до того, как браузер сможет отрисовать содержимое. Основной поток блокируется до завершения выполнения этого эффекта и его очистки, поэтому он подходит для измерения и настройки узлов DOM до того, как пользователь что-либо увидит, но не для более сложных операций.
MessageChannel, а при необходимости — setTimeout), поэтому он не задерживает визуальное обновление. Следует учитывать, что при обновлении, исходящем от конкретного действия пользователя, React может выполнить пассивные эффекты до отрисовки.Хуки для улучшения производительности: useMemo и useCallback
Это кэши, которые позволяют избежать дорогостоящих пересчётов или сохранять стабильность ссылок между отрисовками.
- Хранение данных. Хук хранит пару: закэшированное значение и массив зависимостей, на основе которых оно было вычислено.
- Выполнение. При повторном отрисовывании React сравнивает каждую новую зависимость с сохраненной с помощью
Object.is. Если все совпадают, функция-фабрика не вызывается, и возвращается значение из кэша. Если хотя бы одна зависимость отличается, React вызывает функцию-фабрику, сохраняет новое значение и зависимости, а затем возвращает результат. - useCallback эквивалентен
useMemo(() => fn, deps): он сохраняет объект функции, который вы передали, а не значение, полученное при её вызове. В исходном коде React он реализуется отдельно, но поведение остается прежним.
Поскольку сравнение происходит поверхностное, зависимость в виде только что созданного объекта или массива при каждом отрисовывании полностью сводит на нет эффект кэширования.
Гуки с изменяемыми значениями: useRef и useImperativeHandle
Refs хранят информацию, которая не используется при отрисовке, такую как узел DOM или идентификатор таймера.
- useRef — самый простой хук в кодбейсе. При монтировании он создаёт объект
{ current: initialValue }и сохраняет его в качестве состояния хука; при каждом последующем отрисовывании возвращается тот же самый объект. Изменения в полюcurrentникогда не влияют на очередь обновлений или соответствующие механизмы, поэтому они не запускают отрисовку. - useImperativeHandle позволяет настроить то, что видит родительский компонент через ref, путём привязки к нему собственных методов. Внутренне он ведёт себя как
useLayoutEffect: он выполняется синхронно во время фиксации изменений, поэтому хендл готов к моменту выполнения эффектов родительского компонента. В React 19 функциональные компоненты могут получатьrefв качестве обычного свойства, поэтому для его использования больше не требуетсяforwardRef.
Хук контекста: useContext
useContext выделяется тем, что никогда не занимает место в списке хуков.
- Чтение. Система считывает значение из ближайшего соответствующего провайдера, расположенного выше компонента, и записывает этот контекст в список зависимостей фибры.
- Распространение. Когда значение провайдера меняется, React ищет ниже него фибры, зависимости которых включают этот контекст, и запланирует их перерисовку. Это происходит даже если промежуточный компонент использует
React.memoилиshouldComponentUpdate, поэтому мемоизация родителя не защищает потребители контекста.
Гуксы для параллельной обработки: useTransition и useDeferredValue
Эта пара позволяет управлять системой потоков и прерывать длительную процедуру рендеринга.
- useTransition возвращает
[isPending, startTransition]. Обновления, выполненные внутриstartTransition(() => setQuery(text)), получают статус перехода вместо статуса срочного обновления. Если во время отображения перехода происходит клик или нажатие клавиши, React отказывается от текущей виртуальной структуры дерева, обрабатывает срочное обновление, а затем запускает переход заново. - useDeferredValue оборачивает значение, а не функцию-установщик. React фактически сохраняет две версии значения: сначала отображается предыдущее значение, чтобы экран оставался отзывчивым, затем планируется фоновая обработка с новым значением с низким приоритетом.
Специализированные хуки: useId и useSyncExternalStore
Некоторые хуки редко встречаются в коде приложений, но являются необходимыми для авторов библиотек.
- useId предотвращает несоответствия при гидратации в рендеринге на стороне сервера. Он формирует идентификатор на основе положения компонента в дереве. Поскольку структура дерева одинакова на сервере и у клиента во время первоначальной гидратации, идентификаторы совпадают без необходимости использования глобального счётчика.
- useSyncExternalStore заменяет ручные подписки
useEffectна внешние хранилища, такие как Redux или Zustand. Вы передаёте ему две функции: одну для регистрации слушателя изменений в хранилище и функциюgetSnapshot, которая возвращает текущее значение хранилища. React считывает этот снимок во время рендеринга, и если хранилище изменяется в процессе рендеринга, он выполняет синхронный повторный рендеринг, чтобы ни одна часть интерфейса не отображала другую версию данных из хранилища. Это предотвращает проблемы, которые могут возникнуть при одновременном рендеринге.
Основные выводы
- Состояние хуков представляет собой связанный список внутри фибры, определяемый исключительно порядком вызовов; каждый правило хуков существует для того, чтобы сохранять этот порядок неизменным при каждой отрисовке.
- Фибра превращает процесс отрисовки в цикл, который можно прервать, а двойное буферирование позволяет React готовить новую структуру дерева, не влияя на то, что отображается на экране.
- Этап отрисовки может выполняться многократно и должен оставаться «чистым»; этап сохранения изменений выполняется один раз, синхронно, и именно здесь запускаются эффекты.
- Диспетчеры объясняют как разделение процессов монтирования и обновления, так и ошибки, возникающие при вызове хука вне процесса отрисовки.
- Знание того, где каждый хук хранит свои данные и когда выполняется его работа, позволяет легче выбирать подходящее время для запуска эффектов, обеспечивать эффективность мемоизации и правильно понимать контекст и одновременные обновления.
Связанная литература
- Где useState сохраняет своё значение: React Elements против Fibers — почему функциональный компонент React забывает всё между вызовами, почему элементы не могут хранить состояние и как поле memoizedState в Fiber сохраняет значения useState живыми.
- Типизация React Hooks: useState, useEffect, useReducer и пользовательские хуки — узнайте, как правильно типизировать useState, useEffect, useReducer и пользовательские хуки в TypeScript, а также когда использование TypeScript вместо обычного JavaScript действительно оправдано.