Відстеження виклику React setState з черги оновлень до фіксації в DOM
Пройдіться крок за кроком з оновленням стану в React через чергу оновлень хуків, планувальник, фазу відображення, узгодження та збереження, щоб зрозуміти, чому стан ніколи не змінюється миттєво.
Майже кожен розробник React з часом пише функцію для зміни стану, а потім використовує console.log, і дивується, коли бачить виведене старе значення. Назва setState натякає на миттєве присвоєння, але React так не працює: виклик функції-змінника лише записує запит на майбутній стан, і перед тим, як щось з’явиться на екрані, виконується ціла низка операцій. У цій статті простежується одне оновлення через усю цю послідовність — від черги оновлень хука, через планування, відображення, узгодження до фази збереження змін. Як тільки ви зможете уявити кожен етап, кілька явищ, які спочатку здаються дивними, стають передбачуваними: чому стан виглядає асинхронним, чому кілька оновлень можуть об’єднатися в одне відображення, чому React іноді пропускає певні операції та чому існують функціональні оновлення.
Фрагмент коду, який дивує всіх
Уявіть собі перегляд коду, де цей підхід здається абсолютно логічним:
setCount(count + 1);
console.log(count);
Очікується, що консоль покаже збільшену значення. Вона відображає попереднє. Ось така сама ситуація всередині повного компонента:
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log(count);
};
return (
<button onClick={handleClick}>
{count}
</button>
);
}
Після першого кліку багато розробників очікують побачити:
1
Те, що насправді з’являється у консолі, це:
0
Причина в тому, що React ще не відредагував інтерфейс. У момент виконання console.log React отримав лише запит. Ще нічого з черги не було застосовано, функція компонента ще не була викликана, а DOM не змінився. Код, який ви виконуєте, належить до поточного рендерингу, і в межах цього рендерингу count є звичайною константою, яка була встановлена під час виконання функції. Ніщо не може її перезначити.
Це перша зміна у ментальній моделі: функція-встановлювач не змінює змінну стану, яку ви читаєте. Вона лише планує виконання завдань для React пізніше.
Що зберігає React, коли ви викликаєте функцію-встановлювач
Коли ви пишете:
setCount(count + 1);
Хочеться уявити, що React внутрішньо робить щось на кшталт цього:
count = count + 1
Але це не так. React створює об’єкт оновлення та додає його до черги, яка належить саме цьому Hookу. Концептуально ситуація після натискання кнопки виглядає так:
Current State
|
▼
count = 0
|
▼
User Clicks
|
▼
setCount(1)
|
▼
Update Queue
[ Update: 1 ]
Сам стан залишається недоторканим. Усе, що зробив React, — це залишити собі нотатку: наступного разу, коли будуть оброблятися оновлення для цього Hookу, count має стати 1.
Чому оновлення проходять через чергу
Черга стає доцільною, коли кілька оновлень надходять одне за одним. Розгляньмо обробник, який тричі викликає функцію-встановлювач:
const handleClick = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
Хтось, хто новий у React, може очікувати, що один клік призведе до:
count = 3
Фактичний результат є таким:
count = 1
Усі три виклики були здійснені під час одного і того ж рендерування, і під час цього рендерування було спостережено:
count = 0
Отже, кожен вираз count + 1 дає однакове значення, і React отримує три ідентичні запити:
setCount(1)
setCount(1)
setCount(1)
Тому черга містить три записи, у всіх з яких написано "встановити на 1":
[1]
[1]
[1]
Обробка їх у порядку все одно закінчується так:
1
а не:
3
Та сама логіка застосовується, коли значення відрізняються. Припустимо, що під час рендерування виконуються ці три виклики:
setCount(1)
setCount(6)
setCount(4)
Тоді черга містить:
[1]
[6]
[4]
Кожен елемент повністю замінює попередній стан, тому після обробки має значення лише останній, і результат дорівнює 4. Прості значення є заміною, а не інструкціями для розробки на основі попереднього стану. Саме через цю обмеженість існують функціональні оновлення.
Функціональні оновлення обчислюються на основі найновішого стану
Тепер змініть обробник так, щоб кожен виклик передавав функцію:
const handleClick = () => {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
};
Цього разу черга містить щось більше схоже на три невеликі програми, а не три значення:
prev => prev + 1
prev => prev + 1
prev => prev + 1
Коли React обробляє чергу, він передає результат кожної функції наступній:
0 → 1
1 → 2
2 → 3
а кінцевий стан є таким:
count = 3
Різниця полягає у тому, що функція оновлення не захоплює значення з поточного відображення. Вона описує, як отримати наступний стан з того стану, який має React у момент обробки цього елемента. Використовуйте цю форму завжди, коли новий стан залежить від попереднього, особливо коли кілька оновлень можуть бути виконані одночасно або коли оновлення відбувається всередині функції-відповіді, створеної під час попереднього відображення.
Повний життєвий цикл одного оновлення
Враховуючи чергу, простежте за одним кліком, який викликає:
setCount(count + 1);
Спрощений огляд усього, що відбувається далі:
User Click
|
▼
Create Update
|
▼
Place Update Into Queue
|
▼
Notify React Scheduler
|
▼
Schedule Render
|
▼
Render Phase
|
▼
Reconciliation
|
▼
Commit Phase
|
▼
DOM Updated
У наступних розділах розглядається кожен етап.
Крок 1: створюється запис оновлення
Виклик setCount(1) ніколи не викликає прямого повторного відображення чого-небудь:
setCount(1);
React створює запис оновлення, який можна уявити собі як примітку з текстом:
Apply this update later.
Цей запис прив’язаний до внутрішнього стану Hook. Дані Hook знаходяться разом із Fiber компонента – внутрішнім елементом, який React зберігає для кожної інстанції компонента, – а також черга оновлень також знаходиться там:
Fiber
|
└── useState
|
├── Current State
└── Update Queue
Саме тому Hookи мають викликатися у тому самому порядку під час кожного рендерування: React знаходить стан та чергу кожного Hook за його позицією в цьому списку.
Крок 2: React планує виконання завдань
Маючи оновлення, React мусить вирішити, коли його обробити. Це завдання планувальника. React не обов’язково рендерує в момент виклику функції-встановлювача; він знаходить баланс між швидкістю реакції та уникненням зайвої роботи.
A
AB
ABC
ABCD
ABCDE
Синхронне відображення ресурсомістких елементів інтерфейсу після кожного натискання клавіші, без можливості визначення пріоритету, призводить до повільної роботи складних інтерфейсів. Завдяки плануванню React може об’єднувати оновлення, які надходять одночасно, і за допомогою функцій конкурентного виконання, таких як переходи, ставитися до термінових оновлень, наприклад до самого введення даних, інакше, ніж до менш термінових, наприклад до списку фільтрованих результатів. Саме ця здатність є важливою причиною того, чому архітектура React перейшла від миттєвих оновлень до планованих.
Крок 3: на етапі відображення компонент запускається знову
Коли React вирішує, що настав час обробити очікувані оновлення, він починає нове відображення. Відображення означає просто знову викликати функцію компонента:
function Counter() {
const [count, setCount] = useState(0);
return <h1>{count}</h1>;
}
Важливою деталлю є те, що робить useState під час цього виклику. Перш ніж повернути значення, він обробляє чергові оновлення у порівнянні з попереднім станом. Починаючи з:
Previous State = 0
React пройшовся по черзі:
Queue:
[ +1 ]Process QueueResult:
1
а значення, яке тепер повертає useState, є наступним:
count = 1
яке компонент використовує для цього оновлення. Саме тому нове значення стає видимим лише під час наступного оновлення: воно обчислюється там, а не у момент виклику функції-змінника.
Крок 4: створюється нова деревоподібна структура елементів
Виконання компонента повертає нову деревоподібну структуру елементів React. Поруч із попереднім оновленням вона виглядає так:
Previous Render
<h1>0</h1>
New Render
<h1>1</h1>
DOM браузера ще не змінювався. На цьому етапі React має лише оновлений план бажаного інтерфейсу користувача.
Крок 5: процес узгодження знаходить відмінності
Далі React порівнює попередній результат:
Old Tree
з новим:
New Tree
щоб з’ясувати, що саме змінилося. У цьому прикладі, до:
Before:
<h1>0</h1>
та після:
After:
<h1>1</h1>
Лише текст всередині заголовка відрізняється, тож це єдина зміна, яку фіксує React. Саме це порівняння і означає поняття «реконсиліація». Щоб детальніше дізнатися, як React вирішує, що зберегти, а що замінити, перегляньте створення ментальної моделі для реконсиліації, стану та хуків у React.
Крок 6: фаза комітування впливає на DOM
Після того, як відомий список змін, React переходить до фази комітування та застосовує їх до реального DOM:
DOM Before
<h1>0</h1>
DOM After
<h1>1</h1>
Лише зараз користувач бачить нове число. Саме цю ситуацію більшість людей уявляє під час виклику функції-змінника, проте це останній з кількох етапів.
Чому React не застосовує оновлення негайно
Розгляньмо обробник, який одночасно оновлює кілька елементів стану:
setCount(c => c + 1);
setLoading(false);
setUser(data);
Якби кожен виклик запускав власне оновлення інтерфейсу, ви б отримали:
Render 1
Render 2
Render 3
Це означає три оновлення інтерфейсу, два з яких є марними, а також можливі проміжні екрани з несумісними комбінаціями стану. Натомість React групує оновлення. Виклики потрапляють у чергу:
Update
Update
Update
і потім обробляються разом:
|
▼Single Render
Блокування операцій дозволяє уникнути зайвого відрендеровування та гарантує, що користувач завжди бачитиме однаковий кінцевий стан. Це основна причина, чому React віддає перевагу плануванню замість миттєвого виконання кожної зміни. React також може повністю пропустити виконання певних операцій: якщо зміна створює значення, ідентичне поточному, за результатами перевірки за допомогою Object.is, React може не відрендеровувати дочірні елементи компонента.
Як це виглядає на практиці
Візьмемо поле пошуку, де кожна натиснута клавіша оновлює три елементи стану:
query
results
loading
Природно припускати, що кожен з цих функцій-змінників спричиняє окреме відрендеровування. Однак профілювання такого компонента зазвичай показує інше: оновлення, виконані разом у межах однієї події, об’єднуються в одну групу, тож компонент відрендеровується рідше, ніж можна було б при наївному аналізі коду. Завдання React полягає не лише у застосуванні змін, а й у їх ефективному застосуванні.
Коли у кількох компонентах є незавершені оновлення
Оновлення не обмежуються лише одним компонентом. Розгляньмо таку структуру:
App
├── Header
├── Sidebar
└── Dashboard
Якщо кілька оновлень потрапляють у різні частини цієї структури, React не повинен сліпо перебудовувати все. Структура Fiber дозволяє React відстежувати:
- які компоненти мають незавершену роботу
- де в структурі походить кожне оновлення
- які підструктури потрібно обробити, а які можна пропустити
Саме це дозволяє React бути набагато вибірковішим, ніж підхід „перерендерувати всю програму при кожній зміні“. Зауважте, що компонент, який перерендерується, за замовчуванням все одно перерендерує свої дочірні елементи; мемоізація дозволяє пропускати незмінені підструктури.
Повторний аналіз застарілого console.log
Повернемося до фрагмента з початку:
setCount(count + 1);
console.log(count);
Консоль виводить:
0
Оскільки код все ще виконується під час поточного відображення. Колона обробки ще не була оброблена, наступне відображення ще не відбулося, і оновлення чекає своєї черги. Точніше це можна описати так:
Current Render
count = 0
Request Update
Future Render
count = 1
Якщо вам потрібне нове значення негайно, обчисліть його у локальній змінній та використовуйте її, або отримайте його під час наступного відображення чи у ефекті, який від нього залежить. Якщо дивитися на це так, поведінка зовсім не є дивною.
Як пов’язані елементи
Усі ці етапи узгоджуються в один ланцюг:
- Стан зберігається разом із даними Hook компонента.
- Дані Hook приєднуються до Fiber компонента.
- Виклик функції-встановлювача створює оновлення.
- Оновлення додаються до колони обробки Hook.
- Скейлер вибирає момент для їх обробки.
- На етапі відображення компонент запускається знову, і колона обробки застосовується.
Концепції, які часто викладають окремо, насправді є послідовними кроками в одному процесі. Коротко: виклик функції-встановлювача залишає стан без змін та замість цього запитує про заплановане оновлення, яке React застосовує під час наступного відображення, порівнює його з попереднім результатом та записує зміни до DOM лише там, де є різниця.
Основні висновки
- Функція-встановлювач ніколи не змінює стан на місці; вона додає оновлення до черги, щоб React обробив його пізніше.
- Оновлення чергуються за кожним Hook, тому кілька з них можуть бути оброблені під час одного відображення.
- Прості значення замінюють стан; функції-оновлювачі отримують наступний стан з останнього, що дозволяє уникнути застарілих значень.
- React планує виконання завдань, а не відображає контент при кожному виклику, що уможливлює групування операцій та їх пріоритизацію.
- Відображення та оновлення DOM — це окремі етапи: на етапі відображення створюється опис, а на етапі затвердження відбуваються зміни в браузері.
- Очікувальна черга оновлень зберігається разом із станом Hook у Fiber компонента.
Розглядати setState як запит, а не як команду — це незначна зміна формулювання, яка має великі наслідки. Саме це дозволяє здійснювати групування операцій, планування їх виконання, узгодження та одночасне відображення контенту. Наступним логічним запитанням є те, як React вирішує, чи очікувані оновлення призведуть до одного відображення чи до кількох, і саме це вирішує автоматичне групування, запроваджене в React 18.