Чому setState ніколи не оновлює інтерфейс миттєво: планування оновлень у React
Слідкуйте за оновленням стану React від об’єкта чергових оновлень через маркування шляхів та планування до основного циклу обробки, який ігнорує незмінені піддрощі.
Джерела: The Fiber Architecture In React Part 3: What Actually Happens When You Call setState
Майже кожен розробник React має тихе припущення: виклик функції зміни стану, такої як setCount(count + 1), знову запускає компонент прямо там, ще до виконання наступного рядка коду. Насправді це не відбувається, і різниця між викликом та перерендерингом пояснює концепцію групування операцій, чому некореловані компоненти залишаються недоторканими, та чому отримання значення стану відразу після його зміни повертає старе значення. У цій статті простежується одна операція оновлення через архітектуру Fiber у React — від моменту її запису до моменту, коли нове значення потрапляє у стан компонента, щоб ви могли зрозуміти, що насправді роблять ваші функції зміни стану.
Компонент під мікроскопом
Достатньо простого лічильника, щоб показати весь механізм. Він зберігає одне значення стану та збільшує його при натисканні кнопки.
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
Виклик setCount(count + 1) схожий на звичайну синхронну функцію, тому логічно уявити, що React знову викличе Counter() як пряму наслідку цього рядка. Однак нічого подібного не відбувається. Функція-сеттер повертається, нічого не відображаючи. Натомість вона записує запит та просить виконати роботу пізніше.
Перший крок: сеттер записує оновлення
Коли виконується setCount(1), React виділяє в пам’яті невеликий об’єкт оновлення, який описує цей запит. Концептуально це виглядає так:
{
action: 1,
next: null,
}
Поле action містить те, що ви передали, у цьому випадку значення 1. Поле next існує тому, що одне оновлення рідко є всім. Викличте функцію-встановлювач тричі всередині одного обробника, і React створить три об’єкти оновлення, пов’язані між собою через next, утворюючи невеликий список, приєднаний до компонента. У справжньому коді React оновлення також містить індекс (його пріоритет), а список очікуваних оновлень зберігається у вигляді циклічної структури, але сутність полягає у черзі інтенцій, а не у повторному відрендеруванні.
У спрощеній моделі fiber оновлення приєднується ось так:
{
type: Counter,
memoizedState: { count: 0 }, // still 0, untouched
updateQueue: { action: 1, next: null }, // the note, just added
}
Зверніть увагу на те, що не змінилося. Збережений стан все ще дорівнює 0, на екрані все ще відображається 0, і функція Counter() ще не була викликана. Єдина різниця полягає у тому, що об’єкт тепер чекає у черзі. (Для точності: за допомогою хуків React зберігає чергу кожного хука у записі цього хука в списку хуків фібри, а не безпосередньо у полі під назвою updateQueue; memoizedState у функційному компоненті-фібрі — це саме той список хуків. Наведена вище спрощена модель призначена для навчання та зберігає поведінку, яка є важливою тут.)
Це також пояснює класичний сюрприз. Якщо ви вивести значення count одразу після виклику setCount, ви побачите старе значення, оскільки функція компонента, яка зберігала доступ до count, ще не була виконана знову.
Другий крок: scheduleUpdateOnFiber позначає шлях та запитує виконання роботи
Відразу після того, як оновлення додано до черги, React викликає внутрішню функцію під назвою scheduleUpdateOnFiber. Вона має дві окремі функції, і розділення їх полегшує зрозуміння решти процесу.
Позначення шляху до кореня
Спочатку React просувається вгору від фібри Counter через кожен батьківський посилання, поки не досягне кореня дерева. На кожному предку по шляху він встановлює поле під назвою childLanes, яке фактично означає «у когось нижче є виконання роботи». Сама фібра Counter отримує відповідне поле lanes, що означає «у мене також є виконання роботи».
Ці поля — це не просто булеві значення. Це маски бітів, які кодують пріоритет, що є окремою темою; дивіться як React lanes використовують маски бітів для кодування пріоритету оновлень. Для цього огляду достатньо знати, що вони переходять від порожнього до непорожнього стану, залишаючи слід від оновленого компонента до верхньої частини.
Відкладення фактичного відображення
По-друге, React вимагає, щоб операція відображення виконувалась незабаром, а не всередині обробника подій. Конкретний механізм залежить від пріоритету. Робота, яку обробляє планувальник React, зазвичай додається за допомогою MessageChannel (з setTimeout як альтернативою у середовищах, де його немає), тож вона виконується пізніше під час циклу подій. Для окремих подій високого пріоритету, таких як кліки, сучасні версії React зазвичай виконують цю роботу у вигляді мікрозавдання одразу після завершення обробника, що все одно відбувається після вашого коду, але швидше. Конкретна стратегія змінювалась між версіями, тому вважайте ці деталі специфічними для кожної версії.
Яким би не був канал, наслідок залишається однаковим: відтворення змісту навмисно відкладається до моменту повернення обробника. Саме це дозволяє використовувати пакетну обробку. Три виклики setCount у одному обробнику створюють три очікувані оновлення, а єдиний подальший етап відтворення обробляє їх усі разом замість того, щоб виконувати процес тричі.
Третій крок: відтворення завжди починається з кореня
Коли нарешті виконується запланована робота, вона не починається з Counter. Вона починається з кореневої волокна — спеціального вузла, який знаходиться над усім вашим додатком та був створений під час виклику ReactDOM.createRoot. Це відбувається незалежно від того, на якій глибині дерева походить оновлення.
Початок з кореня може здаватися дорогим, але це не означає необхідності відвідування кожного елемента. Шлях childLanes, встановлений на другому кроці, саме той, яким користується алгоритм обходу. Біля кожного елемента React перевіряє, чи є у піддереві якась незавершена робота. Якщо її немає, React припиняє обробку та пропускає все піддерево, не заходячи в нього. Предки на позначеному шляху обробляються швидко, а досліджуються лише гілки, які ведуть до незавершеної роботи.
Цикл, а не рекурсія
Обхід реалізований у вигляді циклу, а не глибокої рекурсії:
let node = rootFiber;
while (node !== null) {
node = processNode(node); // moves to node.child, node.sibling, or up via node.return
}
Кожен елемент містить три вказівники: child — на свою першу дитину, sibling — на наступний елемент на тому ж рівні, та return — на свого батька. Цих трьох зв’язків достатньо, щоб поступово переходити до дочірніх елементів, до сусідніх елементів та назад, коли завершується певна гілка. Не існує масиву дочірніх елементів для ітерації. Оскільки положення елемента в дереві зберігається у змінній, а не на стеку викликів, React може зупинятися між окремими операціями та продовжувати їх пізніше, що є основою конкурентного відображення.
Чому несуміжні компоненти залишаються недоторканими
Це механізм, який лежить в основі ефекту, який ви, ймовірно, спостерігали. Введення тексту в поле вводу оновлює лише ту частину дерева, яка відповідає цьому стану. Віддалений, непов’язаний компонент не переробляється та навіть не відвідується, оскільки жодне оновлення не позначило його гілку. Пропуск цього процесу — не якась особлива оптимізація, додана пізніше; він є прямим наслідком слідування за шляхом childLanes.
Пам’ятайте про ці обмеження. Компонент на позначеному шляху, у якого є незавершена робота, буде перероблений, і за замовчуванням так само будуть перероблені всі його нащадки, позначені чи ні, якщо тільки вони не зберігаються у пам’яті. Цей шлях вказує React, куди рухатися; він не заважає батьковському компоненту, який переробляється, переробити своїх дочірніх компонентів.
Четвертий крок: досягнення Counter та використання черги
Коли обробка доходить до Counter, починає діяти механізм подвійного буферування. React перевіряє вказівник alternate у поточному фібрі та або повторно використовує існуючий фібр у процесі обробки, або створює новий. Саме в цій копії у процесі обробки обчислюється новий стан.
Тепер черга нарешті прочитана. React обробляє очікувану зміну, застосовує дію до попереднього стану та записує результат у memoizedState волокна, яке знаходиться у процесі обробки. Під час цього кроку фактично викликається Counter(), і useState повертає нове значення. Якщо було заброньовано кілька оновлень, вони застосовуються у порядку; функціональні оновлення, такі як setCount(c => c + 1), кожне отримує результат попереднього, тому вони правильно працюють при пакетній обробці, тоді як setCount(count + 1) тричі призводить лише до одного збільшення.
// work-in-progress fiber, right after processing the queue
{
type: Counter,
memoizedState: { count: 1 }, // finally updated, using the queued action
updateQueue: null, // consumed, nothing left pending
alternate: Fiber A,
}
Зараз черга порожня, а новий стан знаходиться у волокні з поточною обробкою. Зустрілися три окремі механізми: оновлення з черги з першого кроку, відстеження слідів та обробка починаючи з кореня з другого та третього кроків, а також пара волокон з поточною обробкою, яка зберігає версії „до“ та „після“. Щоб дізнатися більше про процес, який продовжується під час узгодження та затвердження DOM, дивіться трекування виклику React setState від черги оновлень до затвердження DOM.
Повна послідовність
Якщо розглядати все по черзі, один клік проходить через наступні етапи:
- Обробник кліку викликає
setCount, і React створює об’єкт оновлення та додає його до черги компонента.
scheduleUpdateOnFiber встановлює значення lanes у об’єкті Counter та childLanes у кожному предку аж до кореня.child, sibling та return, пропускаючи піддрощі, у яких немає очікуваних операцій.Counter React підготовлює фібру з поточними операціями, обробляє чергові оновлення та обчислює новий стан.Жоден з цих кроків не відбувається на тій же лінії коду, де ви викликали функцію-змінник. Усе відбувається пізніше, у структурі, поля якої створені саме для підтримки цього відкладеного, можливого до продовження процесу.
Основні висновки
- Функція встановлення стану додає оновлення у чергу та планує виконання завдань; вона ніколи не відображає контент синхронно всередині вашого обробника.
- Читання стану відразу після його зміни повертає старе значення, оскільки компонент ще не було перезапущено.
- Групування оновлень є природним наслідком відкладення виконання: усі оновлення, додані до черги перед процесом відображення, обробляються одночасно.
lanesтаchildLanesпозначають шлях від оновленої частини до кореня, що дозволяє React пропускати незмінені піддрощі.- Цикл відображення використовує вказівники
child,siblingтаreturnзамість рекурсії, що дозволяє React призупиняти та відновлювати роботу. - Компонент, який перерендерується, за замовчуванням все одно перерендерує свої дочірні елементи; слід шляху обмежує те, де React шукає інформацію, а не те, що відображає батьківський компонент.