Здарова, работяги!
Прошлые два поста были про сдвиг в голове: 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 может прерваться — на этом стоит управляемая срочность.