Чому існують правила гачків: волокна, списки гачків та диспетчери
Екскурсія по внутрішньому функціонуванню React: вузли Fiber, подвійне буферування, канали, список посилань hookів та те, як кожна вбудована родина hookів зберігає свій стан та планує виконання завдань.
Більшість розробників 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 не змінює його під час обчислення оновлень.
- Робоче дерево (WIP) створюється на фоні, коли щось змінюється. 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 вирізняється тим, що ніколи не займає місце у списку хуків.
- Читання. Він зчитує значення від найближчого відповідного Provider, розташованого над компонентом, та записує цей контекст у списку залежностей фібри.
- Розповсюдження. Коли змінюється значення Provider, 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 справді вигідне.