Блог

Назад
Глибоке занурення в оптимізацію React: Fiber-архітектура, помилки мемоізації та віртуалізація списків

3 лютого 2026 р.

Глибоке занурення в оптимізацію React: Fiber-архітектура, помилки мемоізації та віртуалізація списків

React надає зручний декларативний API для створення інтерфейсів користувача. Завдяки абстрагуванню від прямої роботи з DOM-деревом, розробники можуть повністю зосередитися на бізнес-логіці та станах додатку. Однак у великих проектах ця абстракція може створювати проблеми з продуктивністю.

Розуміння того, як React виконує компоненти на рівні рантайму, є критичним для забезпечення швидкого відгуку інтерфейсу. У цій статті ми детально розглянемо роботу рушія реконсиляції Fiber, проаналізуємо накладні витрати на мемоізацію, побудуємо кастомний віртуалізований список з нуля та навчимося оптимізувати React Context API для запобігання зайвим рендерам.


1. Під капотом: React Fiber та фази рендерингу

Для оптимізації будь-якого React додатку потрібно насамперед розрізняти дві ключові фази оновлення інтерфейсу: фазу Render та фазу Commit.

                          Конвеєр рендерингу React
                          
+-------------------------------------------------------------------------+
|                              ФАЗА RENDER                                |
|                  (Асинхронна, конкурентна, переривана)                  |
+-------------------------------------------------------------------------+
| 1. Тригер оновлення -> 2. Побудова Virtual DOM -> 3. Порівняння (Diff)  |
+-------------------------------------------------------------------------+
                                     |
                                     v
+-------------------------------------------------------------------------+
|                              ФАЗА COMMIT                                |
|                  (Синхронна, запис у DOM, швидка)                       |
+-------------------------------------------------------------------------+
| 4. Запис змін у DOM -> 5. Запуск LayoutEffects -> 6. Запуск Effects     |
+-------------------------------------------------------------------------+

Фаза Render

Фаза Render - це процес, під час якого React обходить дерево компонентів, обчислює їхні пропси та стан і будує Virtual DOM. Починаючи з React 16, цим процесом керує новий рушій Fiber.

"Fiber" - це звичайний об'єкт JavaScript, який містить інформацію про компонент, його стан, зв'язки з іншими елементами дерева та чергу оновлень. На відміну від старого синхронного обходу (stack reconciler), Fiber працює асинхронно. Двигун V8 може призупинити обхід дерева для обробки пріоритетних дій користувача (наприклад, введення тексту в поле введення) і відновити його згодом.

Під час фази Render React викликає функцію компонента, отримує JSX-елементи та порівнює їх із попереднім станом дерева Fiber. Цей процес порівняння називається Diffing. Результатом фази Render є список інструкцій (ефектів) щодо того, які саме зміни потрібно внести в реальний DOM.

Фаза Commit

Фаза Commit виконується повністю синхронно. React бере сформований на фазі Render список змін і застосовує його безпосередньо до DOM за допомогою браузерних методів (як-от appendChild або removeChild).

Після оновлення DOM запускаються синхронні ефекти розмітки (useLayoutEffect), оновлюються посилання (refs), а після того, як браузер відобразить оновлений екран, запускаються асинхронні ефекти useEffect.

Основне правило: Повторний рендеринг компонента не означає оновлення DOM. Компонент може виконувати свій рендер (викликати функцію тіла) під час фази Render, але якщо механізм diffing виявить, що повернута JSX-структура не змінилася, React не здійснюватиме жодного запису в реальний DOM під час Commit. Проте сам обхід великого дерева на фазі Render все одно навантажує центральний процесор та забирає час головного потоку.


2. Реальна вартість мемоізації: підводні камені useMemo та useCallback

Багато розробників вважають, що додавання useMemo для кожного масиву та useCallback для кожної функції є безкоштовним способом підвищити продуктивність. Насправді передчасна або неправильна мемоізація може зробити додаток повільнішим.

Кожен виклик хука несе певні накладні витрати:

  • Двигун V8 повинен виділити додаткову пам'ять для збереження масиву залежностей.
  • При кожному рендеринг V8 виконує порівняння кожного елемента в масиві залежностей за допомогою оператора строгої рівності (Object.is).
  • Посилання на збережене значення та масив залежностей залишаються в пам'яті, що збільшує навантаження на збирач сміття (GC).

Антипатерн передчасної мемоізації

Розглянемо такий приклад:

// Погана практика: Накладні витрати перевищують користь
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 лише у двох випадках:

  1. Ресурсомісткі обчислення: Складне сортування чи фільтрація масивів даних (наприклад, перетворення великого плаского списку на деревоподібну структуру).
  2. Гарантія стабільності посилань: Передача функцій чи об'єктів як пропсів у дочірні компоненти, які загорнуті в React.memo, або використання цих посилань у масивах залежностей інших хуків (useEffect).

3. Віртуалізація списків: створення кастомного віртуалізатора

Рендеринг великих списків (наприклад, 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>
  );
};

Як це працює:

  1. Зовнішній контейнер має фіксовану висоту containerHeight та увімкнений скрол overflow-y: auto.
  2. Всередині нього створюється блок-заповнювач висотою, яка дорівнює повній висоті всього списку (items.length * itemHeight). Завдяки цьому прокрутка браузера працює коректно.
  3. При скролі ми зчитуємо поточне положення прокрутки scrollTop.
  4. Обчислюємо startIndex та endIndex для видимих елементів. Додатковий запас у 2 елементи зверху та знизу запобігає появі білих порожнеч при швидкому скролі.
  5. Ми виводимо тільки видимі елементи, позиціонуючи їх абсолютно за допомогою translateY(index * itemHeight). Це зберігає DOM дерево компактним.

4. Оптимізація Context API: Розділення State та Dispatch

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 має стабільне посилання, цей компонент ніколи не буде повторно рендеритися при зміні імені користувача.


5. Профілювання за допомогою React DevTools

Ніколи не оптимізуйте код навмання. Використовуйте спеціальне розширення React Developer Tools для вашого браузера:

  1. Запуск профілювання: Відкрийте інструменти розробника, перейдіть до вкладки Profiler та натисніть кнопку запису.
  2. Симуляція дій користувача: Взаємодійте з інтерфейсом додатку (вводьте текст, натискайте кнопки, скрольте).
  3. Аналіз Flamegraph: Зупиніть запис. Профілювальник покаже кольорове відображення дерева рендерингу.
    • Жовті та помаранчеві блоки: Компоненти, рендеринг яких тривав найдовше.
    • Сірі блоки: Компоненти, які не рендерилися взагалі (мемоізація спрацювала успішно!).
  4. Визначення причин оновлення: У налаштуваннях профілювальника увімкніть опцію "Record why each component rendered during profiling". При виборі будь-якого компонента у правій панелі відобразиться точна причина оновлення (наприклад, Props changed: [onClick]).

Застосування цих методів дозволить вам виявити критичні затримки у роботі програми та створювати по-справжньому швидкі інтерфейси користувача.