Все мы слышали о этой системе. Новая система React Hooks 16.7 наделала много шума в коммьюнити. Мы все ее пробовали и тестировали, очень волновались за нее и ее потенциал. При мысли о хуках она кажется волшебной, так как React каким-то образом управляет вашим компонентом, даже не копируя его (без использования ключевого слова this). Итак, как же React это делает?
Сегодня я хотел бы погрузиться в реализацию хуков (перехватчиков) в React, чтобы мы могли лучше понять этот процесс. Проблема с магическими особенностями заключается в том, что теперь сложнее отладить ошибку, если она произойдет, потому что она поддерживается сложной трассировкой стека. Таким образом, имея глубокие знания о новой системе React Hooks, мы сможем исправлять ошибки достаточно быстро, сразу, как только столкнемся с ними, или вообще научимся избегать их появления.
Прежде чем я начну, я просто хотел бы сказать, что я не разработчик и не специалист по обслуживанию React. Значит, мои слова должны восприниматься с долью скептицизма. Я очень глубоко погрузился в реализацию системы хуков в React, но я все равно не могу гарантировать, что именно так работает эта библиотека. Учитывая вышесказанное, я буду подтверждать свои слова доказательствами и ссылками из исходного кода React и попытаюсь сделать мои аргументы максимально весомыми.
Прежде всего, давайте рассмотрим механизм, который гарантирует, что хуки будут вызываться в пределах области React. Вы, вероятно, уже знаете, что хуки не имеют никакого смысла, если не вызывать их в правильном контексте.
Диспетчер
Диспетчер - это общий объект, содержащий функции hook. Он будет динамически распределяться или очищаться на основе фазы рендеринга ReactDOM. Он гарантирует, что пользователь не получит доступ к хукам вне компонента React (см. реализацию).
Хуки могут быть включены/отключены флагом под названием <code rel="JavaScript">enableHooks</code>. Это делается перед тем, как рендерить корневой компонент. Для этого нужно просто переключиться на правый диспетчер; это означает, что технически мы можем включать/отключать хуки во время выполнения. React 16.6.X также имеет экспериментальную функцию, но она фактически отключена (см. реализацию).
Когда мы закончим выполнение рендеринга, мы аннулируем диспетчер и тем самым предотвращаем случайное использование хуков вне цикла рендеринга ReactDOM. Это механизм, который гарантирует, что пользователь не наделает глупых вещей (см. реализацию).
Диспетчер проверяется в каждом вызове хука с помощью функции resolveDispatcher()
. Как я уже говорил ранее, вне цикла рендеринга React это не должно нести смысла, а React должен выдать предупреждающее сообщение: «Hooks can only be called inside the body of a function component (Хуки можно вызывать только внутри тела функционального компонента)» (см. реализацию).
let currentDispatcher
const dispatcherWithoutHooks = { /* ... */ }
const dispatcherWithHooks = { /* ... */ }
function resolveDispatcher() {
if (currentDispatcher) return currentDispatcher
throw Error("Hooks can't be called")
}
function useXXX(...args) {
const dispatcher = resolveDispatcher()
return dispatcher.useXXX(...args)
}
function renderRoot() {
currentDispatcher = enableHooks ? dispatcherWithHooks : dispatcherWithoutHooks
performWork()
currentDispatcher = null
}
Раз уж мы разобрались с этим простым механизмом инкапсуляции, я хотел бы перейти к сути этой статьи - к хукам. Сразу хочу познакомить вас с новой концепцией.
Очередь хуков
«За кулисами» хуки представлены как узлы, которые связаны друг с другом в порядке их вызова. Они представлены так, потому что хуки не просто создаются, а затем оставляются в покое. Существует механизм, который позволяет им быть тем, чем они являются. Хук имеет несколько свойств, которые вам нужно осознать, прежде чем погрузиться в его реализацию:
- Его начальное состояние создается в первоначальном рендере;
- Его состояние может обновляться «на лету»;
- React запомнит состояние хука в будущих рендерах;
- React предоставит вам правильное состояние, основанное на порядке вызова;
- React знает, к какому «волокну» принадлежит этот хук.
Соответственно, нам нужно переосмыслить то, как мы рассматриваем состояние компонента. До сих пор мы думали об этом так, как будто это простой объект:
{
foo: 'foo',
bar: 'bar',
baz: 'baz',
}
Но при работе с хуками их следует рассматривать как очередь, где каждый узел представляет собой единую модель состояния:
{
memoizedState: 'foo',
next: {
memoizedState: 'bar',
next: {
memoizedState: 'bar',
next: null
}
}
}
Схема реализации одного узла хука может быть просмотрена в реализации. Вы увидите, что у хука есть некоторые дополнительные свойства, но ключ для понимания работы хуков лежит в memoizedState
и в next
. Остальные свойства используются, в частности, с помощью обращения к методу useReducer()
для кэширования отправленных действий и базовых состояний. Из-за этого процесс восстановления можно повторить как возврат в различных случаях:
- baseState - объект состояния, которое будет передано восстановителю;
- baseUpdate - самое последнее отправленное действие, созданное в baseState;
- queue - очередь отправленных действий, которые ждут прохода через восстановителя.
К сожалению, у меня не получилось хорошо понять хук восстановителя, потому что мне не удалось воспроизвести практически никаких его крайних случаев. Из-за этого мне было неловко вдаваться в подробности. Я скажу только, что реализация восстановителя настолько непоследовательна, что даже один из комментариев в самой реализации гласит: «Don't persist the state accumlated from the render phase updates to (Не уверен, является ли это желаемой семантикой)». Тогда как я могу быть уверен?!
Итак, вернемся к хукам. Перед каждой функцией вызова компонента будет вызвана функция namedHooks()
, где текущее волокно и его первый узел хука в очереди хуков будут храниться в глобальных переменных. Таким образом, каждый раз, когда мы вызываем функцию hook(useXXX())
, она будет знать, в каком контексте запускаться.
let currentlyRenderingFiber
let workInProgressQueue
let currentHook
// Source: https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/react-reconciler/src/ReactFiberHooks.js:123
function prepareHooks(recentFiber) {
currentlyRenderingFiber = workInProgressFiber
currentHook = recentFiber.memoizedState
}
// Source: https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/react-reconciler/src/ReactFiberHooks.js:148
function finishHooks() {
currentlyRenderingFiber.memoizedState = workInProgressHook
currentlyRenderingFiber = null
workInProgressHook = null
currentHook = null
}
// Source: https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/react-reconciler/src/ReactFiberHooks.js:115
function resolveCurrentlyRenderingFiber() {
if (currentlyRenderingFiber) return currentlyRenderingFiber
throw Error("Hooks can't be called")
}
// Source: https://github.com/facebook/react/tree/5f06576f51ece88d846d01abd2ddd575827c6127/react-reconciler/src/ReactFiberHooks.js:267
function createWorkInProgressHook() {
workInProgressHook = currentHook ? cloneHook(currentHook) : createNewHook()
currentHook = currentHook.next
workInProgressHook
}
function useXXX() {
const fiber = resolveCurrentlyRenderingFiber()
const hook = createWorkInProgressHook()
// ...
}
function updateFunctionComponent(recentFiber, workInProgressFiber, Component, props) {
prepareHooks(recentFiber, workInProgressFiber)
Component(props)
finishHooks()
}
После завершения обновления будет вызвана функция namedHooks()
, где ссылка на первый узел в очереди хуков будет сохранена на отображаемом волокне в свойстве memoizedState
. Это означает, что очередь хуков и их состояние могут быть адресованы извне:
const ChildComponent = () => {
useState('foo')
useState('bar')
useState('baz')
return null
}
const ParentComponent = () => {
const childFiberRef = useRef()
useEffect(() => {
let hookNode = childFiberRef.current.memoizedState
assert(hookNode.memoizedState, 'foo')
hookNode = hooksNode.next
assert(hookNode.memoizedState, 'bar')
hookNode = hooksNode.next
assert(hookNode.memoizedState, 'baz')
})
return (
<ChildComponent ref={childFiberRef} />
)
}
Давайте углубимся в подробности и поговорим об отдельных хуках, начиная с самых распространенных из всех - хуков состояния.
State Хуки
Вы будете удивлены, но за кулисами хук useState
использует useReducer
, и он просто предоставляет ему предопределенный обработчик восстановителя (см. реализацию). Это означает, что результаты, возвращаемые useState
, фактически являются состоянием восстановителя и диспетчером действий. Взгляните на обработчик восстановителя, который использует хук состояния:
function basicStateReducer(state, action) {
return typeof action === 'function' ? action(state) : action;
}
Как и ожидалось, мы можем напрямую предоставить диспетчеру действий новое свойство. Но посмотрите на это?! Мы также можем предоставить диспетчеру функцию действия, которая получит старое состояние и вернет новое. Этого нет в официальной документации React (на момент написания статьи), что печально, ведь это полезно! Это означает, что при отправке задающего состояние метода в компонент tree можно запускать мутации против текущего состояния родительского компонента, не передавая его в качестве другого значения. Например:
const ParentComponent = () => {
const [name, setName] = useState()
return (
<ChildComponent toUpperCase={setName} />
)
}
const ChildComponent = (props) => {
useEffect(() => {
props.toUpperCase((state) => state.toUpperCase())
}, [true])
return null
}
Наконец, хуки эффектов, которые оказали значительное влияние на жизненный цикл компонента и на то, как он работает.
Хуки эффектов
Хуки эффектов ведут себя несколько иначе. Они имеют дополнительный уровень логики, который я хотел бы объяснить. Опять же, есть вещи, которые вы должны понимать о свойствах хуков эффектов, прежде чем погрузиться в реализацию:
- Они создаются во время рендера, но запускаются после отрисовки;
- Если есть такое условие, они будут уничтожены непосредственно перед следующей отрисовки;
- Их вызывают в порядке определения.
Обратите внимание, что я использую термин «отрисовка», а не «рендер». Это две разные вещи. Я видел, как многие ораторы в недавней конференции React использовали неправильные термины! Даже в официальных документах React говорится: «after the render is committed to the screen (после того, как рендеринг передается экрану)», что похоже на «отрисовку». Метод рендеринга только создает узел волокна, но пока ничего не рисует.
Соответственно, должна быть еще одна дополнительная очередь, которая должна содержать эти эффекты. Она должна вызываться после отрисовки. Вообще говоря, волокно имеет очередь, содержащую узлы эффекта. Каждый эффект имеет разный тип. Он должен вызываться подходящей фразой:
Вызовите копии getSnapshotBeforeUpdate()
перед мутацией (см. реализацию);
Выполните все вставки хостов, обновления, удаления и демонтаж ссылок (см. реализацию);
Произведите все жизненные циклы и возвраты ссылок. Жизненные циклы проходят отдельно, так что все размещения, удаления и обновления во всем дереве должны уже быть вызваны. Из-за этого шага также срабатывают все специфические для визуализации эффекты (см. реализацию);
Эффекты, составленные с использованием хука useEffect()
, также называются пассивными. Они основаны на реализации (может, пора бы начать использовать это понятие в коммьюнити React?!).
Хуки эффектов должны храниться в волокне в свойстве updateQueue
. Каждый узел эффекта должен быть построен по такой схеме (см. реализацию):
- tag - двоичное число, которое будет диктовать поведение эффекта (я скоро объясню);
- create - возврат, который должен запускаться после отрисовки;
- destroy - возврат, возвращаемый из create(). Он должен запускаться перед первоначальным рендером;
- inputs - набор значений, который будет определять, нужно ли уничтожить эффект или восстановить его;
- next - ссылка на следующий эффект, которая была определена в функции Component.
Все свойства, кроме tag
, довольно просты и понятны. Если вы хорошо изучили хуки, вы знаете, что React Hooks предоставляет вам пару специальных эффектов: useMutationEffect() и useLayoutEffect()
. Эти два эффекта внутренне используют useEffect()
, который по сути означает, что они создают узел эффекта, но с использованием другого значения tag
.
Tag создается из набора двоичных значений (см. реализацию):
const NoEffect = /* */ 0b00000000;
const UnmountSnapshot = /* */ 0b00000010;
const UnmountMutation = /* */ 0b00000100;
const MountMutation = /* */ 0b00001000;
const UnmountLayout = /* */ 0b00010000;
const MountLayout = /* */ 0b00100000;
const MountPassive = /* */ 0b01000000;
const UnmountPassive = /* */ 0b10000000;
Наиболее распространенными случаями использования этих двоичных значений будут использование прямой линии (|) и добавление битов к одному значению. Затем мы можем проверить, реализует ли тег определенное поведение или нет, используя амперсанд (&). Если результат отличен от нуля, это означает, что тег реализует указанное поведение.
const effectTag = MountPassive | UnmountPassive
assert(effectTag, 0b11000000)
assert(effectTag & MountPassive, 0b10000000)
Вот поддерживаемые типы React hooks вместе с их тегами (см. реализацию):
- Эффект по умолчанию - UnmountPassive | MountPassive;
- Эффект мутации - UnmountSnapshot | MountMutation;
- Эффект способа размещения - UnmountMutation | MountLayout.
Вот так React проверяет, выполняется ли поведение (см. реализацию):
if ((effect.tag & unmountTag) !== NoHookEffect) {
// Unmount
}
if ((effect.tag & mountTag) !== NoHookEffect) {
// Mount
}
Если основываться на том, что мы только что узнали о хуках эффектов, то мы можем включить эффект в определенное волокно извне:
function injectEffect(fiber) {
const lastEffect = fiber.updateQueue.lastEffect
const destroyEffect = () => {
console.log('on destroy')
}
const createEffect = () => {
console.log('on create')
return destroy
}
const injectedEffect = {
tag: 0b11000000,
next: lastEffect.next,
create: createEffect,
destroy: destroyEffect,
inputs: [createEffect],
}
lastEffect.next = injectedEffect
}
const ParentComponent = (
<ChildComponent ref={injectEffect} />
)
Вот и все, что я хотел рассказать в этой статье, надеюсь она вам помогла сделать систему Reacts hooks более памятной.