Здарова, работяги!

Прошлые два поста были про сдвиг в голове: React всё меньше про «сравни деревья», всё больше про «какую работу и когда делать». Три поста на одной философии — перебор, так что ныряем под капот.

Начну с фундамента: с чем React вообще работает, когда рендерит. Звучит занудно, но пока тут чёрный ящик, советы про memo и colocation остаются заклинаниями: делай так, а почему — непонятно.

JSX — это объект-описание сам по себе ничего не рисует. Это сахар над вызовом функции, которая возвращает обычный объект:

// превращается примерно в: { type: ChildVerySlow, props: { color: "red" }, key: null }

Чертёж: какой компонент и с какими пропсами показать. Эти объекты одноразовые — на каждом рендере создаются заново и выбрасываются. Хранить в них что-либо нельзя, да и негде.

Файбер — рабочая память React

А жить-то где-то надо. На каждый узел дерева — компонент, div, даже кусок текста — React заводит файбер: объект, который переживает рендеры.

В нём лежит всё, что должно пережить вызов функции: пропсы прошлого рендера, ссылки на родителя, ребёнка и соседа, пометки «здесь запланирована работа».

Деревьев из файберов два: текущее, по которому построен экран, и рабочее (work-in-progress из старой лекции), которое React собирает под обновление. В коммите React переключает указатель: достроенное рабочее дерево становится текущим. Старые файберы не выбрасываются — из них, переписав поля, React соберёт следующее рабочее дерево.

Стейт — связный список хуков

Локальный стейт тоже живёт в файбере. Никакой магии: на каждый вызов хука React заводит обычный объект, где лежит значение и ссылка на следующий хук. Вместе они сцеплены в связный список:

// fiber.memoizedState, упрощённо: hook1 = { memoizedState: 'Тёма', next: hook2 } hook2 = { memoizedState: 29, next: null }

Имён у звеньев нет. На новом рендере React идёт по списку заново: первый вызов хука получает первое звено, второй — второе. Всё сопоставление держится на порядке вызовов. Поэтому хукам нельзя жить в условиях:

function Profile({ isEditing }) { const [name, setName] = useState('Тёма'); if (isEditing) { const [draft, setDraft] = useState(name); // хук под условием } const [age, setAge] = useState(29); }

Пока isEditing выключен, в списке те самые два звена. Включился — вызовов стало три. draft молча заберёт второе звено и получит 29 — чужой стейт age. А третий вызов упрётся в конец списка, и React упадёт с той самой «Rendered more hooks than during the previous render».

В лучшем случае рендер падает сразу, в худшем — стейт тихо съезжает в чужие звенья. «Хуки только на верхнем уровне» — не вкусовщина линтера, а прямое следствие того, как стейт лежит в файбере.

Зачем дерево распилено на файберы

До Fiber React рендерил рекурсией: зашёл в корень и спустился до самых листьев. Из середины рекурсии не выйти — стек вызовов на паузу не поставишь.

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

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

Резюме

— JSX-элемент — одноразовое описание { type, props, key }: создаётся на каждом рендере и выбрасывается; — файбер — объект, который переживает рендеры: прошлые пропсы, ссылки по дереву, список хуков; — хуки сопоставляются со звеньями списка только по порядку вызова, поэтому им нельзя жить в условиях; — дерево обходится по одному файберу за шаг, между шагами React может прерваться — на этом стоит управляемая срочность.