3 лютого 2026 р.
React надає зручний декларативний API для створення інтерфейсів користувача. Завдяки абстрагуванню від прямої роботи з DOM-деревом, розробники можуть повністю зосередитися на бізнес-логіці та станах додатку. Однак у великих проектах ця абстракція може створювати проблеми з продуктивністю.
Розуміння того, як React виконує компоненти на рівні рантайму, є критичним для забезпечення швидкого відгуку інтерфейсу. У цій статті ми детально розглянемо роботу рушія реконсиляції Fiber, проаналізуємо накладні витрати на мемоізацію, побудуємо кастомний віртуалізований список з нуля та навчимося оптимізувати React Context API для запобігання зайвим рендерам.
Для оптимізації будь-якого React додатку потрібно насамперед розрізняти дві ключові фази оновлення інтерфейсу: фазу Render та фазу Commit.
Конвеєр рендерингу React
+-------------------------------------------------------------------------+
| ФАЗА RENDER |
| (Асинхронна, конкурентна, переривана) |
+-------------------------------------------------------------------------+
| 1. Тригер оновлення -> 2. Побудова Virtual DOM -> 3. Порівняння (Diff) |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| ФАЗА COMMIT |
| (Синхронна, запис у DOM, швидка) |
+-------------------------------------------------------------------------+
| 4. Запис змін у DOM -> 5. Запуск LayoutEffects -> 6. Запуск Effects |
+-------------------------------------------------------------------------+
Фаза Render - це процес, під час якого React обходить дерево компонентів, обчислює їхні пропси та стан і будує Virtual DOM. Починаючи з React 16, цим процесом керує новий рушій Fiber.
"Fiber" - це звичайний об'єкт JavaScript, який містить інформацію про компонент, його стан, зв'язки з іншими елементами дерева та чергу оновлень. На відміну від старого синхронного обходу (stack reconciler), Fiber працює асинхронно. Двигун V8 може призупинити обхід дерева для обробки пріоритетних дій користувача (наприклад, введення тексту в поле введення) і відновити його згодом.
Під час фази Render React викликає функцію компонента, отримує JSX-елементи та порівнює їх із попереднім станом дерева Fiber. Цей процес порівняння називається Diffing. Результатом фази Render є список інструкцій (ефектів) щодо того, які саме зміни потрібно внести в реальний DOM.
Фаза Commit виконується повністю синхронно. React бере сформований на фазі Render список змін і застосовує його безпосередньо до DOM за допомогою браузерних методів (як-от appendChild або removeChild).
Після оновлення DOM запускаються синхронні ефекти розмітки (useLayoutEffect), оновлюються посилання (refs), а після того, як браузер відобразить оновлений екран, запускаються асинхронні ефекти useEffect.
Основне правило: Повторний рендеринг компонента не означає оновлення DOM. Компонент може виконувати свій рендер (викликати функцію тіла) під час фази Render, але якщо механізм diffing виявить, що повернута JSX-структура не змінилася, React не здійснюватиме жодного запису в реальний DOM під час Commit. Проте сам обхід великого дерева на фазі Render все одно навантажує центральний процесор та забирає час головного потоку.
useMemo та useCallbackБагато розробників вважають, що додавання useMemo для кожного масиву та useCallback для кожної функції є безкоштовним способом підвищити продуктивність. Насправді передчасна або неправильна мемоізація може зробити додаток повільнішим.
Кожен виклик хука несе певні накладні витрати:
Object.is).Розглянемо такий приклад:
// Погана практика: Накладні витрати перевищують користь
const SimpleButton = ({ onClick, title }) => {
const formattedTitle = useMemo(() => {
return title.trim().toUpperCase();
}, [title]);
const handleClick = useCallback(() => {
onClick(formattedTitle);
}, [onClick, formattedTitle]);
return <button onClick={handleClick}>{formattedTitle}</button>;
};
Чому це неефективно?
Операція title.trim().toUpperCase() є дуже простою і триває частку мікросекунди. Витрати на ініціалізацію хука useMemo, створення масиву залежностей та їх порівняння при кожному рендері є вищими, ніж виконання самої текстової операції.
Більше того, цей компонент SimpleButton все одно буде повторно рендеритися при кожному оновленні батька, оскільки ми не обгорнули його в React.memo(). Якщо дочірній компонент не мемоізований за допомогою React.memo, стабілізація його пропсів через useCallback не має жодного сенсу.
Використовуйте useMemo та useCallback лише у двох випадках:
React.memo, або використання цих посилань у масивах залежностей інших хуків (useEffect).Рендеринг великих списків (наприклад, 5,000 елементів) створює тисячі DOM-вузлів, що негативно впливає на чуйність інтерфейсу та показники Core Web Vitals (такі як LCP та INP). Віртуалізація (або windowing) вирішує цю проблему за допомогою рендерингу лише тих елементів, які в даний момент потрапляють у видиму частину екрана.
Давайте напишемо простий кастомний вертикальний віртуалізатор списку, щоб розібратися з його логікою роботи:
import React, { useState, useRef } from 'react';
export const VirtualList = ({ items, itemHeight, containerHeight }) => {
const containerRef = useRef(null);
const [scrollTop, setScrollTop] = useState(0);
const handleScroll = (e) => {
setScrollTop(e.currentTarget.scrollTop);
};
const totalHeight = items.length * itemHeight;
// Визначаємо індекси видимих елементів
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - 2); // 2 елементи запасу зверху
const endIndex = Math.min(
items.length - 1,
Math.floor((scrollTop + containerHeight) / itemHeight) + 2 // 2 елементи запасу знизу
);
const visibleItems = [];
for (let i = startIndex; i <= endIndex; i++) {
visibleItems.push({
item: items[i],
index: i,
style: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${itemHeight}px`,
transform: `translateY(${i * itemHeight}px)`,
}
});
}
return (
<div
ref={containerRef}
onScroll={handleScroll}
style={{
overflowY: 'auto',
height: `${containerHeight}px`,
position: 'relative',
border: '1px solid rgba(255,255,255,0.1)',
}}
>
<div style={{ height: `${totalHeight}px`, width: '100%', position: 'relative' }}>
{visibleItems.map(({ item, style, index }) => (
<div key={index} style={style}>
<div className="list-item-card">
<span>#{index + 1}</span> {item.name}
</div>
</div>
))}
</div>
</div>
);
};
Як це працює:
containerHeight та увімкнений скрол overflow-y: auto.items.length * itemHeight). Завдяки цьому прокрутка браузера працює коректно.scrollTop.startIndex та endIndex для видимих елементів. Додатковий запас у 2 елементи зверху та знизу запобігає появі білих порожнеч при швидкому скролі.translateY(index * itemHeight). Це зберігає DOM дерево компактним.React Context є зручним рішенням для передачі стану між компонентами, але він має одну серйозну проблему з продуктивністю: при зміні значення контексту всі компоненти, що використовують цей контекст, рендеряться заново, незалежно від того, яка саме частина стану змінилася.
// Погана практика: Викликає рендеринг усіх споживачів при будь-якій зміні
const UserContext = React.createContext();
export const UserProvider = ({ children }) => {
const [state, setState] = useState({ username: '', theme: 'dark' });
// Об'єкт значення контексту створює нове посилання при кожному рендері
return (
<UserContext.Provider value={{ state, setState }}>
{children}
</UserContext.Provider>
);
};
Навіть якщо компоненту потрібна лише функція setState для зміни теми, він все одно буде оновлюватися кожного разу, коли змінюватиметься username, оскільки вони ділять один спільний канал контексту.
Створивши два окремих контексти (один для читання значень стану, другий - для функцій оновлення), ви зможете розірвати цей зв'язок:
import React, { createContext, useContext, useState, useMemo } from 'react';
const UserStateContext = createContext(null);
const UserDispatchContext = createContext(null);
export const UserProvider = ({ children }) => {
const [state, setState] = useState({ username: '', theme: 'dark' });
// Стабільне посилання на функції оновлення стану
const dispatch = useMemo(() => ({
setUsername: (name) => setState(prev => ({ ...prev, username: name })),
setTheme: (theme) => setState(prev => ({ ...prev, theme }))
}), []);
return (
<UserStateContext.Provider value={state}>
<UserDispatchContext.Provider value={dispatch}>
{children}
</UserDispatchContext.Provider>
</UserStateContext.Provider>
);
};
export const useUserState = () => {
const context = useContext(UserStateContext);
if (!context) throw new Error('useUserState має бути всередині UserProvider');
return context;
};
export const useUserDispatch = () => {
const context = useContext(UserDispatchContext);
if (!context) throw new Error('useUserDispatch має бути всередині UserProvider');
return context;
};
Тепер компонент, який лише змінює тему або оновлює профіль користувача, викличе useUserDispatch. Оскільки об'єкт dispatch має стабільне посилання, цей компонент ніколи не буде повторно рендеритися при зміні імені користувача.
Ніколи не оптимізуйте код навмання. Використовуйте спеціальне розширення React Developer Tools для вашого браузера:
Props changed: [onClick]).Застосування цих методів дозволить вам виявити критичні затримки у роботі програми та створювати по-справжньому швидкі інтерфейси користувача.