nmk

Лекція №10 (2 години). Оптимізація продуктивності: useMemo та useCallback.

План лекції

  1. Природа рендерингу в React: Каскадні оновлення та ціна обчислень.
  2. Мемоїзація обчислень: Хук useMemo та оптимізація “важких” операцій.
  3. Мемоїзація функцій: Хук useCallback та уникнення перестворення логіки.
  4. Проблема Referential Equality (Строгої рівності посилань) у JavaScript.
  5. React.memo (HOC): Переривання ланцюга рендерингу через Shallow Comparison.
  6. Правильне та надлишкове використання мемоїзації: Коли useMemo робить гірше (Over-optimization).
  7. Інструменти профілювання: React Developer Tools (Profiler) та аналіз Flame Graphs.

Перелік умовних скорочень

Вступ

Вивчення механізмів мемоїзації для запобігання зайвим обчисленням та рендерингам. Аналіз випадків, коли використання цих хуків є виправданим, а коли — надлишковим. Розглядається React.memo як засіб поверхневого порівняння пропсів для компонентів.

Для студентів 4-го курсу важливо розуміти, що швидкість сучасних веб-додатків визначається не стільки швидкістю інтернету, скільки ефективністю використання процесора пристрою (CPU). React за замовчуванням оптимізований для швидкого рендерингу, але на складних архітектурах каскадні оновлення (коли зміна кореневого стану викликає перерахунок тисяч “дочірніх” компонентів) знижують FPS сторінки. Ця лекція присвячена інженерному патерну мемоїзації (Memoization) — збереженню результатів “важких” обчислень у пам’яті для уникнення їхнього повторного виконання. Ми розглянемо інструментарій мемоїзації в React та головну проблему розробників: коли оптимізація стає шкідливою.


1. Природа рендерингу в React: Каскадні оновлення

Щоразу, коли в компоненті змінюється стан (useState) або приходять нові пропси (props), React викликає функцію цього компонента наново, генеруючи новий Virtual DOM.

Фундаментальне правило React: Рендер батьківського компонента автоматично (рекурсивно) викликає рендер УСІХ його дочірніх компонентів, незалежно від того, змінилися пропси цих компонентів чи ні.

const Parent = () => {
  const [ticker, setTicker] = useState(0); // Оновлюється кожну секунду

  // Хоча Footer візуально статичний, функція Footer() буде викликатися
  // кожної секунди тільки тому, що вона знаходиться всередині Parent.
  return (
    <div>
      <h1>Таймер: {ticker}</h1>
      <Footer companyName="My Corp" />
    </div>
  );
};

У більшості випадків виклик маленьких функцій-компонентів не є проблемою. Але якщо Footer містить складну SVG-графіку або таблицю на 1000 рядків, додаток почне гальмувати (Drop Frames).


2. Мемоїзація обчислень: Хук useMemo

Ідеалогія функціонального програмування стверджує, що чисті функції (pure functions) для однакових вхідних аргументів завжди повертають однаковий результат. Якщо функція математично складна (O(n^2), сортування масивів, обробка зображень), ми можемо не виконувати її наново, а просто “згадати” збережений результат. Цей процес і є мемоїзацією.

Хук useMemo дозволяє “закешувати” результат виконання блоку коду між рендерами.

const DataView = ({ items, filterText }) => {
  // Це обчислення виконуватиметься ПРИ КОЖНОМУ рендері.
  // Якщо items містить 50 000 елементів, інтерфейс зависне під час набору букв в input.
  const filteredItems = items.filter((item) => item.name.includes(filterText));

  // ОПТИМІЗАЦІЯ з useMemo:
  // React запам'ятає результат. Він виконає фільтрацію наново ТІЛЬКИ якщо
  // масив items АБО рядок filterText зміняться.
  const memoizedFilteredItems = useMemo(() => {
    return items.filter((item) => item.name.includes(filterText));
  }, [items, filterText]);

  return <List data={memoizedFilteredItems} />;
};

3. Мемоїзація функцій: Хук useCallback

Функції в JavaScript є об’єктами першого класу (First-class citizens). Це означає, що при кожному виклику компонента-функції, всі локальні змінні та локальні функції створюються наново, отримуючи нові адреси в оперативній пам’яті комп’ютера.

const App = () => {
  // Ця функція перестворюється (нове місце в RAM) при кожному рендері App
  const handleClick = () => console.log("Click");

  return <Button onClick={handleClick} />;
};

Сам по собі процес створення об’єкта функції є миттєвим, тому useCallback майже ніколи не використовують “просто так”. Його використовують виключно для стабілізації посилань (Referential Equality) при передачі цієї функції дочірнім компонентам.

// React збереже оригінальну функцію і повертатиме те саме посилання,
// поки не зміниться масив залежностей (в даному випадку [] - ніколи).
const handleClick = useCallback(() => {
  console.log("Click");
}, []);

4. Проблема Referential Equality (Строгої рівності посилань)

Це найважливіша концепція теми. У JavaScript є два типи порівняння:

[1, 2, 3] === [1, 2, 3]; // FALSE! (Це два різні масиви в пам'яті)
const a = () => {};
const b = () => {};
a === b; // FALSE! (Це дві різні функції)

Коли React перевіряє, чи змінилися пропси компонента, чи варто запускати ефекти у масиві залежностей (useEffect([...])), він використовує метод Object.is (схоже на ===).

Отже, якщо ви передаєте інлайн-об’єкт style= як пропс, ви гарантуєте, що для React цей пропс ЗАВЖДИ буде новим при кожному рендері, оскільки ви постійно виділяєте нову пам’ять (Allocation).


5. React.memo (HOC) та Shallow Comparison

Щоб зупинити нескінченний каскад рендерингів “вниз”, React надає компонент вищого порядку (HOC) — React.memo. Він огортає ваш компонент і створює “перешкоду”. Перед тим як відрендерити дочірній компонент, React.memo робить Shallow Comparison (Поверхневе порівняння) старих та нових пропсів. Якщо жоден з пропсів не змінився (за посиланням/значенням), компонент ігнорує виклик рендеру від батька і залишається старим.

// HeavyChart.tsx
const HeavyChart = ({ data, onZoom }) => {
  /* ... дуже важкий графік canvas ... */
};
// Експортуємо обгорнуту версію
export default React.memo(HeavyChart);

Чим React.memo відрізняється від useMemo?

Глобальна пастка мемоїзації

Робота React.memo ПОВНІСТЮ ЗНИЩУЄТЬСЯ, якщо батько передає йому “нестабільні” (немемоїзовані) об’єкти чи функції!

const Dashboard = () => {
  // ПРОБЛЕМА: Кожен рендер створює нову функцію в пам'яті
  const handleZoom = () => setZoom((z) => z + 1);

  // React.memo робить Shallow Compare: oldProps.onZoom !== newProps.onZoom
  // РЕЗУЛЬТАТ: false. Компонент HeavyChart відрендериться,
  // хоча візуально нічого не змінилося. Всі зусилля React.memo марні!
  return <HeavyChart data={historicalData} onZoom={handleZoom} />;
};

Наслідок (Single Source of Truth): Якщо ви оптимізуєте “дитину” через React.memo, ви зобов’язані оптимізувати всі функції (useCallback) та об’єкти (useMemo), які ви туди передаєте.


6. Правильне та надлишкове використання мемоїзації (Over-optimization)

Коли useMemo / useCallback є ШКІДЛИВИМИ? Мемоїзація не є безкоштовною. Сама функція useMemo має свій код, вимагає порівняння масиву залежностей і виділяє додаткову оперативну пам’ять (RAM) для зберігання кешу.

Золоті правила використання:

  1. Використовуйте useMemo ТІЛЬКИ для масивних обчислень (O(N) де N > 1000) або дуже складного парсингу.
  2. Використовуйте useCallback і useMemo (для об’єктів), ЯКЩО ви передаєте їх як пропс до компонента, що вже обгорнутий у React.memo.
  3. Використовуйте їх, якщо передаєте об’єкт або функцію як залежність в масив useEffect([...]), щоб уникнути нескінченних циклів спрацювання ефекту (Reference Bug).

7. Інструменти профілювання: React Profiler & Flame Graphs

Senior-інженер ніколи не оптимізує код “всліпу” (на основі припущень). Процес оптимізації завжди розпочинається з вимірювань (Profiling). Для цього використовується вкладка Profiler у офіційному розширенні React Developer Tools.

Він будує Flame Graph (Графік полум’я).


Висновки

  1. Каскадний рендеринг вниз по дереву є стандартною гарантією узгодженості даних в UI React. Блокувати його варто лише при об’єктивних метриках “просідання” продуктивності.
  2. useMemo зберігає результат обчислення у пам’яті (кешування обчислень). useCallback зберігає посилання на одну і ту ж саму функцію в пам’яті (кешування інструкцій).
  3. Проблема Referential Equality є центральною для всієї оптимізації. Оскільки в JavaScript {} === {} дорівнює false, кожен інлайн-об’єкт та інлайн-функція в компоненті сприймається фреймворком як абсолютно нова сутність під час наступного виконання.
  4. Оптимізація HOC-обгорткою React.memo (Shallow-порівняння пропсів) працює у симбіозі з хуками useMemo/useCallback. Якщо порушити стабільність посилань батьківських пропсів, React.memo стане паразитичним (викликатиме марні порівняння перед неминучим ререндером).
  5. “Передчасна оптимізація — корінь усіх бід”. Застосовуйте інженерний підхід: виміряйте FPS -> знайдіть проблемний вузол через Profiler Flame Graph -> застосуйте мемоїзацію -> виміряйте знову.

Джерела

  1. Офіційна документація React. “Skiping Re-renders with memo”. URL: https://react.dev/reference/react/memo
  2. React Docs. “useMemo Hook”. URL: https://react.dev/reference/react/useMemo
  3. React Docs. “useCallback Hook”. URL: https://react.dev/reference/react/useCallback
  4. Dan Abramov Blog. “Before You memo()”. URL: https://overreacted.io/before-you-memo/
  5. “When to useMemo and useCallback”. Kent C. Dodds. URL: https://kentcdodds.com/blog/usememo-and-usecallback
  6. “A Visual Guide to React Rendering”. Alex Sidorenko Blog.
  7. MDN Web Docs. “Equality comparisons and sameness (Object.is)”. URL: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness
  8. “How to use React Profiler to find performance bottlenecks”. React Blog Legacy. URL: https://legacy.reactjs.org/blog/2018/09/10/introducing-the-react-profiler.html

Запитання для самоперевірки

  1. З точки зору архітектури каскадних оновлень React, як зміна тексту в маленькому компоненті Navbar (розміщеному в корені додатку App) гарантовано вплине на важкий статичний компонент Footer, і як цьому запобігти?
  2. Поясніть сутність поняття “Shallow Comparison” (Поверхневе порівняння), на якому працює функція HOC обгортки React.memo. Чому не використовується глибоке (Deep) порівняння всіх полів об’єкта?
  3. В чому принципова технічна різниця повернення значень між хуками useMemo(() => func()) та useCallback(func) під час їх декларації в коді?
  4. Опишіть патологічний сценарій (Over-optimization), коли додавання useMemo до кожного виразу в компоненті зробить рендеринг повільнішим і призведе до втрати продуктивності процесора пристрою?
  5. Чому передача порожнього масиву залежностей [] в анонімну функцію-колбек неможлива без руйнування бізнес логіки, якщо всередині тієї функції ми використовуємо змінні зі Стейту компонента (замикання)?