Де зберігається значення useState: елементи React проти волокон.
Чому функційний компонент React забуває все між викликами, чому елементи не можуть зберігати стан, та як поле memoizedState волокна зберігає значення useState живими.
Компонент-функція — це просто функція, і функції забувають свої локальні змінні в момент повернення. Проте useState повертає оновлене значення під час кожного відображення, ніби функція його пам’ятає. Цей матеріал точно відповідає на одне конкретне запитання: де фізично знаходиться це значення між відображеннями? До кінця ви зможете розрізняти два об’єкти, які React створює для кожного компонента — елемент та фібер, і пояснити, який з них містить стан та чому.
Загадка: функція без пам’яті
Почнемо з найзнайомішого компонента — лічильника:
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
Якщо натиснути один раз, відобразиться 1, якщо натиснути знову — 2. У цьому немає нічого дивного, поки ви не подивитеся на це як на звичайний JavaScript. Counter — це функція. Кожного разу, коли функція викликається, її локальні змінні створюються з нуля, а коли вона повертається, вони зникають. Наступний виклик починається заново.
Згідно з цією логікою, коли React вдруге викликає Counter(), рядок useState(0) мав би знову повернути 0. Однак він повертає 1. Щось поза межами функції мусить зберігати це число від одного виклику до іншого. Це не може бути тіло функції, адже саме так не поводяться функції. То що ж?
Виключення елемента
Є очевидний кандидат, який виявляється неправильним, і його виключення робить справжню відповідь зрозумілішою.
Рядок <button onClick={...}>{count}</button> є JSX. Браузери ніколи не виконують JSX; компілятор, такий як Babel або компілятор TypeScript, переписує його на звичайний виклик функції перед розгортанням коду. Концептуально результат є наступним:
React.createElement("button", { onClick: fn }, count)
Завдяки автоматичному рунтайму JSX, введеному у React 17, компільований виклик насправді є jsx() з модуля react/jsx-runtime, а не React.createElement, проте обидва виконують однакову функцію. Якою б не була назва функції, вона повертає звичайний об’єкт:
{
type: "button",
props: { onClick: fn, children: 1 },
}
У React це називається елементом. Це опис, а не жива структура: невеликий запис про те, що тут має з’явитися кнопка з певним обробником кліку та відображати це число. Справжні елементи містять ще кілька полів, таких як key, ref та внутрішній маркер $typeof, але жодне з них не зберігає історію.
Тепер ключове спостереження. Кожен виклик Counter створює абсолютно новий елемент. Якщо натиснути кнопку, виконується Counter(), повертається новий об’єкт, а попередній стає непотрібним. Якби стан зберігався в елементі, не було б способу перейти від відображення двох елементів до відображення одного, оскільки об’єкт для відображення одного більше не існує, коли починається відображення двох.
Ця тимчасовість є навмисною. Елементи є дешевими саме тому, що вони створюються з нуля при кожному оновленні та їм ніколи не потрібно підтримувати синхронність з чимось іншим. Це також означає, що вони не можуть знаходитися там, де зберігається count.
Окрім елементів, React зберігає ще один об’єкт довшого терміну існування для кожного компонента, який було розміщено на екрані. Його не створюють заново при кожному оновленні. Він створюється, коли компонент вперше розміщується, а потім зберігається та оновлюється протягом усього часу, поки компонент залишається на екрані. Це і є fiber.
Волокна часто описують як щось таємниче, але на найнижчому рівні волокно — це звичайний об’єкт JavaScript. За ним не стоїть жодна спеціальна конструкція під час виконання. Якщо зупинитися у дебагері всередині механізму узгодження React та переглянути одне волокно, ви побачите звичайний об’єкт із набором властивостей, який можна створити самостійно за допомогою пари фігурних дужок. Їх також можна переглянути у браузері: React прив’язує внутрішні ключі до елементів DOM, які вказують на їхні волокна, і саме так React DevTools їх знаходить.
Замість того, щоб перераховувати всі поля одночасно, корисніше створювати волокно для Counter по одній властивості за раз. Відразу після монтування мінімальна версія виглядає так:
{
type: Counter,
}
type: чия це облікова система
type — це найпростіше поле. Воно визначає, який компонент відстежує ця волокно. type: Counter означає, що об’єкт створений для керування інстанцією Counter, яка містить посилання на саму функцію.
Елементи-хости також мають волокна. Кнопка, яку відображає Counter, має власне волокно, а її type — це рядок "button", а не функція. Тож волокно <Counter /> має значення { type: Counter }, а волокно <button> — { type: "button" }: одне й те саме поле, одна й та сама мета, але воно вказує або на ваш компонент, або на вбудовану тег-структуру.
type також відіграє роль у примиренні. Коли React порівнює новий елемент із існуючим фібром у тому самому положенні, збіг type дозволяє йому повторно використати цей фібр та його стан, тоді як різний type змушує викинути старий фібр та створити новий. Саме тому зміна між двома різними компонентами в одному місці скидає їхній стан.
type нічого не говорить про те, як зберігається стан. Для цього потрібне наступне поле.
memoizedState: де насправді знаходиться підрахунок
useState потребує місця поза функцією для зберігання поточного значення, оскільки локальні змінні функції скидаються при кожному виклику. Цим місцем є поле у фібрі під назвою memoizedState. „Memoized“ просто означає запам’ятоване: збережене з попереднього разу замість перерахунку.
Додавання цього до скетчу дає:
{
type: Counter,
memoizedState: { count: 0 },
}
Це спрощена ілюстрація. У реальній реалізації memoizedState у фібрі функційного компонента вказує на перший елемент ланцюгового списку об’єктів hook — по одному на кожен виклик hook, а число 0 знаходиться у власному полі memoizedState цього hook, а не в об’єкті { count: 0 }. React не знає, що ваша змінна називається count; він знає лише „значення першого hook“. Для розуміння того, де знаходиться стан, достатньо спрощеної версії.
Тепер слідкуйте за кліком. React знову викликає Counter(). Коли виконання доходить до useState(0), хук не повертає значення 0 у вашому коді. Він шукає збережений стан фібри, знаходить поточне значення (яке дорівнює 1 після обробки кліку) та повертає його. Аргумент для useState — це лише початкове значення: його використовують під час першої відрендеризації, а потім ігнорують. Відтоді джерелом істини є фібра, а не літеральне значення у тілі функції.
Це повна відповідь на початкову загадку. Кількість не знаходиться у функції, яка забуває все між викликами, і не у елементі, який видаляється після кожної відрендеризації. Вона зберігається у фібрі — окремому об’єкті, який React зберігає та оновлює під час кожної відрендеризації Counter.
Дві практичні наслідки
Ця модель пояснює кілька явищ, які інакше здаються довільними:
- Зміна аргументу
useStateпісля монтування не впливає на збережене значення, оскільки React читає його лише вперше. Якщо потрібно скинути стан з пропсів, змінітьkeyкомпонента, щоб React створив нову фібру. - Оскільки фібра знаходиться за позицією в дереві, стан належить до того місця, де рендерується компонент, а не до визначення функції. Два елементи
<Counter />поруч отримують дві окремі фібри та два незалежні лічильники.
Що ще містить фібра окрім цих двох полів
type та memoizedState достатні для вирішення проблеми стану, але справжній фібер містить набагато більше інформації. Він зберігає посилання на вузол DOM, який був створений, вказівники на його батька, першу дитину та наступного брата-вузол, щоб React міг пройтися по дереву, а також попередні значення props для порівняння з новими. Саме ці поля визначають, як React визначає зміни та як він просувається по всьому дереву компонентів, що є окремою проблемою від того, де знаходиться окреме значення.
Зараз можна чітко визначити межу між цими двома об’єктами, адже їх змішування є причиною багатьох плутанин щодо відображення:
Element Fiber
-------- -----
Created by React.createElement Created internally by React
New object every render Same object, updated in place
Discarded right after Persists for the component's
React reads it entire mounted lifetime
Holds no history Holds memoizedState, the real
remembered value across renders
Describes what should exist Is the thing that actually exists,
with real memory attached to it
Компактний спосіб запам’ятати це: елемент — це запит, який ви надсилаєте до React, тоді як фібра — це власний запис React про те, що наразі існує. Кожне відображення створює нову партію дешевих, безпам’ятних елементів та передає їх дереву фібр, яке вже існувало з попереднього відображення та зберігає все, що має залишитися. Одна сторона описує, інша — пам’ятає.
Одна незрозуміла деталь: фібри існують парами
Твердження, що фібра „оновлюється на місці“, є корисною первинною оцінкою, але це не вся правда. Насправді React зберігає дві версії кожної фібри — одну, яка відповідає тому, що зараз відображається на екрані, та іншу, яка готується до наступного оновлення, і змінює їх місцями. Саме така схема дозволяє React працювати над оновленням, не порушуючи видимий інтерфейс користувача, і це потребує окремого пояснення.
Основні висновки
- Локальні змінні функційного компонента створюються заново при кожному виклику, тому сама функція не може зберігати стан.
- JSX компілюється у виклики, які створюють елементи: прості, одноразові описи, які перебудовуються при кожному відображенні.
- Fibers — це звичайні об’єкти, які існують під час монтування компонента;
typeвказує, який компонент відстежує fiber. useStateчитає та записує значення черезmemoizedStatefiber, який насправді вказує на список об’єктів hook у порядку їх виклику.- Початкове значення, передане до
useState, має значення лише під час монтування; після цього джерелом істини є fiber.
Пов’язана література
- Переосмислення стану React: де насправді мають знаходитися ваші дані — У цій статті пояснюється, як зменшити кількість помилок у React шляхом розміщення стану в URL, DOM чи похідних значень замість надмірного використання useState.
- Як браузер малює екран та яке місце займає React — Дізнайтеся, як критичний шлях відображення, процес узгодження даних, технологія Fiber та планувальник працюють разом, щоб перетворювати оновлення React на пікселі на екрані.
- Чому існують правила хуків: Fiber, списки хуків та диспетчери — Огляд внутрішньої структури React: вузли Fiber, подвійне буферування, канали обробки даних, лінійні списки хуків та способи, якими кожна з вбудованих груп хуків зберігає свій стан та планує виконання операцій.