Skip to main content

Реконсиляція в React

Реконсиляція (reconciliation) - це процес, за яким React порівнює нове дерево virtual DOM зі старим і знаходить мінімальний набір змін для реального DOM.

Теорія

TL;DR

  • Аналогія: відеоредактор, який перерендерює тільки пікселі що змінились, а не весь фільм.
  • React тримає складність O(n) замість O(n³) двома припущеннями: різні типи елементів дають різні піддерева, а key вказує на стабільні елементи списку.
  • Різний тип (<div> на <span>) - повне розмонтування і перебудова. Однаковий тип - оновлення атрибутів, рекурсія в дітей.
  • Список без стабільних key порівнюється за позицією (індексом), що ламається при видаленні або перестановці.
  • Спочатку профайл через React DevTools Profiler, потім вже думай про React.memo або зміну ключів.

Швидкий приклад

jsx
// Без стабільних ключів: React перемонтовує ВСІ елементи при будь-якій зміні списку function BadList({ items }) { return ( <ul> {items.map(item => ( <li key={Math.random()}>{item.name}</li> // новий ключ кожен рендер = повне перемонтування ))} </ul> ); } // Зі стабільними ключами: оновлюється тільки змінений елемент function GoodList({ items }) { return ( <ul> {items.map(item => ( <li key={item.id}>{item.name}</li> // React відстежує за id, перевикористовує DOM-вузли ))} </ul> ); }

Видали один елемент з BadList - кожен <li> перемонтується, фокус і локальний стан зникають. Видали з GoodList - зникає тільки той вузол. Ось і вся різниця.

Як працює евристика диффінгу

Точне порівняння двох довільних дерев - задача O(n³). React зводить це до O(n) двома ставками на твій код.

Перша ставка: елементи різних типів дають повністю різні піддерева. Тому React зносить старе і будує нове з нуля. <div> перетворився на <span> - кожен дочірній елемент розмонтовується, componentWillUnmount спрацьовує, весь стан компонента стирається.

Друга ставка: розробник сам вказує на ідентичність елементів списку через key. Без нього React порівнює за позицією. Додав елемент на початок - React вважає, що позиція 0 змінилась, позиція 1 змінилась, і перерендерює все.

Елементи одного типу йдуть іншим шляхом. React залишає DOM-вузол, оновлює тільки атрибути що змінились, і рекурсивно обробляє дітей. Для компонентів екземпляр залишається живим, стан зберігається, оновлюються тільки пропси.

jsx
// Старий <div className="card" title="old title" /> // Новий <div className="card" title="new title" /> // React чіпає тільки атрибут title. className не змінюється.

Коли потрібні ключі

Перебудова або фільтрація списку: кожен елемент у .map() потребує стабільного key зі своїх даних, не з поточної позиції.

Перемикання між двома різними типами компонентів: прийми повне перемонтування. Минути його неможливо, і зазвичай це нормально.

Перемикання між двома компонентами одного типу: React перевикористає екземпляр і збереже стан. Якщо тобі потрібен свіжий mount - дай кожному різний key.

Динамічний список з видаленням або додаванням: key={item.id} - єдиний безпечний варіант. Індекс зсувається при видаленні з середини.

Як це працює всередині

Коли спрацьовує setState, пакет react-reconciler стартує від зміненого fiber-вузла і запускає reconcileChildren. Fiber-архітектура, що зʼявилась у React 16 і розвинулась у React 18, зробила реконсиляцію переривчастою. Замість одного синхронного проходу React розбиває роботу на частини через Scheduler з пріоритетними lanes.

Concurrent Mode в React 18 додає useTransition, який позначає деякі оновлення як низькопріоритетні. Scheduler відступає перед терміновим відмальовуванням і виконує відкладений диф пізніше. Сам алгоритм не змінюється. Змінюється тільки момент запуску.

Після диффінгу React збирає всі мутації в список ефектів і скидає їх у реальний DOM одним викликом commitRoot. Саме цей батч тримає кількість reflow мінімальною.

Типові помилки

1. Індекс масиву як ключ

jsx
// Видалення з середини зсуває всі індекси. // React вважає, що елементи змінились, хоча вони просто перемістились. {todos.map((todo, index) => ( <TodoItem key={index} todo={todo} /> ))} // Правильно: id зі своїх даних {todos.map(todo => ( <TodoItem key={todo.id} todo={todo} /> ))}

2. Випадкове значення як ключ

jsx
// Math.random() при кожному рендері = кожен елемент перемонтується щоразу {items.map(item => <li key={Math.random()}>{item.name}</li>)} // Правильно: стабільний id, згенерований один раз при створенні елемента {items.map(item => <li key={item.id}>{item.name}</li>)}

3. Зайвий key на одному умовному дочірньому елементі

jsx
// Зміна key на умовному елементі - розмонтування і монтування при кожному перемиканні function Switcher({ active }) { return ( <div> {active ? <ExpensiveChart key="chart" /> : <Summary key="sum" />} </div> ); } // ExpensiveChart втрачає WebGL-контекст при кожному перемиканні. // Правильно: без key для одиночних умовних елементів function Switcher({ active }) { return ( <div> {active ? <ExpensiveChart /> : <Summary />} </div> ); }

4. Зміна типу елемента за умовою

jsx
// Зміна isLoading - повне розмонтування, бо div !== span function Status({ isLoading }) { if (isLoading) return <div>Завантаження...</div>; return <span>Готово</span>; } // Правильно: залишай однаковий тип елемента function Status({ isLoading }) { return <div>{isLoading ? 'Завантаження...' : 'Готово'}</div>; }

5. Припущення, що реконсиляція завжди зберігає стан

Щойно key змінюється або тип елемента змінюється, React розмонтовує все і втрачає стан. Бачив, як команди міняли key щоб "скинути" форму, не помічаючи, що це вбиває анімації і WebSocket-підписки в польоті. Якщо потрібно скинути тільки поля форми - роби це явно всередині компонента.

Де зустрічається в реальних проектах

  • React core: react-reconciler у репозиторії facebook/react обробляє всі рендери. Його також можна використовувати окремо для не-DOM середовищ - canvas, native.
  • Next.js app router: клієнтська навігація дифить дерево компонентів через реконсиляцію. React Server Components надсилають серіалізоване дерево, яке клієнтський reconciler зливає з вже змонтованим.
  • Remix: після сабміту форми відбувається стандартний прохід реконсиляції.
  • React DevTools Profiler: показує які компоненти перерендерились, скільки часу зайняли і чому. Запускай перед тим, як думати про React.memo.

Follow-up питання

Q: Чому O(n) замість O(n³)?
A: Точний алгоритм диффінгу двох дерев - O(n³). React зводить це до O(n) двома евристиками: порівнює тільки елементи одного рівня і використовує keys для ідентифікації елементів списку за даними, а не за позицією.

Q: Що відбувається, якщо не додати keys у маппованому списку?
A: React порівнює за позицією. Видаляєш перший елемент - всі наступні "змінились" за позицією, і всі перерендеряться або перемонтуються залежно від того, чи змінився тип чи тільки контент.

Q: Чим stack reconciler відрізняється від Fiber?
A: Старий stack reconciler обробляв все дерево синхронно і не міг перерватись. Fiber, що зʼявився в React 16, - це work loop на зв'язних списках: його можна поставити на паузу, продовжити або скасувати. Саме це дає Concurrent Mode в React 18.

Q: Як useTransition впливає на реконсиляцію в React 18?
A: Позначає оновлення як низькопріоритетне. Scheduler через модель lanes відкладає цей диф до того, як браузер відмалює термінові кадри. Сам алгоритм реконсиляції однаковий - змінюється тільки час запуску.

Q: Як змусити React перемонтувати компонент без зміни його типу або позиції?
A: Змінити key. React сприймає інший key як сигнал про новий елемент, розмонтовує старий екземпляр і монтує свіжий. Корисно для скидання компонента, але стеж за побічними ефектами - підписки і анімації теж зникнуть.

Приклади

Базовий: key і збереження стану в списку задач

jsx
function TodoList({ todos }) { return ( <ul> {todos.map(todo => ( // Стабільний id: React зіставляє цей todo між рендерами. // Фільтрація, перестановка, додавання - локальний стан у TodoItem виживає. <TodoItem key={todo.id} todo={todo} /> ))} </ul> ); } function TodoItem({ todo }) { const [editing, setEditing] = useState(false); return ( <li> {editing ? <input defaultValue={todo.text} /> : todo.text} <button onClick={() => setEditing(!editing)}>Редагувати</button> </li> ); } // Без key={todo.id}: відкрий редагування одного todo, додай інший. // Всі TodoItem перемонтуються і відкритий input втрачає курсор. // З key={todo.id}: рендериться тільки новий todo, решта зберігає стан.

Стан editing живе всередині кожного TodoItem. Зі стабільним key React знає, який екземпляр відповідає якому todo, і зберігає його. Без стабільного key - перемонтовує все.

Середній: фільтрований список і фокус на input

jsx
function UserList({ users, filter }) { const visible = users.filter(u => u.name.includes(filter)); return ( <ul> {visible.map(user => ( <li key={user.id}> {user.name} <input placeholder="Нотатка про користувача" /> </li> ))} </ul> ); } // З key={user.id}: написав нотатку для Аліси, змінив фільтр, // повернув Алісу - її нотатка на місці. // // З key={index}: зміна фільтра зсуває індекси. // Аліса зникає з позиції 0, Боб займає позицію 0. // React перевикористовує DOM-вузол від Аліси для Боба - нотатка Аліси тепер у Боба.

Саме через цю помилку інпути в списках поводяться непередбачувано. Один пропс це виправляє. Причина - React порівнює за позицією, коли нема ключа для порівняння за ідентичністю.

Сеньйорський: навмисне перемонтування через key для скидання форми

jsx
// Навмисна зміна key щоб отримати чистий компонент при зміні userId function ProfilePage({ userId }) { return <ProfileForm key={userId} userId={userId} />; } function ProfileForm({ userId }) { const [name, setName] = useState(''); useEffect(() => { fetchUser(userId).then(user => setName(user.name)); }, [userId]); return ( <form> <input value={name} onChange={e => setName(e.target.value)} /> <button type="submit">Зберегти</button> </form> ); } // При зміні userId змінюється key. // React розмонтовує старий ProfileForm і монтує свіжий з порожнім станом. // Не потрібно вручну скидати кожне поле в useEffect. // // Компроміс: також розмонтовуються анімації і підписки всередині. // Використовуй цей патерн, коли чистий аркуш - саме те що потрібно.

Цей патерн часто зустрічається в адмін-панелях, де клік між записами має давати повністю свіжу форму. Простіше ніж скидати десяток полів вручну, але обовʼязково задокументуй навіщо тут key - інакше наступний розробник його прибере.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?