Що таке мемоізація?
Мемоізація - це оптимізація, при якій функція зберігає результат для конкретного набору аргументів і при повторному виклику з тими самими аргументами повертає збережений результат замість повторного обчислення.
Теорія
TL;DR
- Уяви кухаря, який записує час приготування в нотатнику: та сама страва - дивишся в нотатник замість готування з нуля
- Працює тільки на чистих функціях; якщо функція читає
Date.now()або глобальний лічильник, кеш поверне застарілий результат - Використовуй, коли та сама функція запускається багато разів з однаковими аргументами і кожен виклик коштує більше ~1мс
- В React для цього є
useMemoіuseCallback
Швидкий приклад
// Без мемоізації: fib(40) робить ~2 мільярди рекурсивних викликів
function fib(n) {
if (n < 2) return n;
return fib(n - 1) + fib(n - 2);
}
// З мемоізацією: кожне n обчислюється один раз, решта з кешу
const memoize = (fn) => {
const cache = {};
return (...args) => {
const key = JSON.stringify(args);
return cache[key] ?? (cache[key] = fn(...args));
};
};
const fastFib = memoize(fib);
fastFib(40); // Обчислює один раз
fastFib(40); // Повертає з кешу, без повторного обчисленняКеш - це звичайний об'єкт, де ключ - серіалізовані аргументи. При попаданні в кеш тіло функції не запускається взагалі.
Тільки чисті функції
Мемоізація працює тому, що одні й ті самі аргументи завжди дають один і той самий результат. Ця умова порушується, як тільки функція читає зовнішній стан: Math.random(), Date.now(), запит до бази - будь-що таке робить кешований результат одразу застарілим. Кеш зберігає першу відповідь назавжди, навіть коли дані навколо вже змінились.
На практиці найнепомітніший варіант цього бага - функція, яка читає змінну на рівні модуля. Виглядає як чиста, бо аргументи стабільні, але результат залежить від чогось зовнішнього.
В React це проявляється з об'єктними пропсами. Якщо батьківський компонент щоразу передає нове посилання на об'єкт, useMemo перераховує значення при кожному рендері, навіть якщо дані всередині ідентичні. React порівнює масив deps через Object.is неглибоко (shallow compare), тому два окремі {a: 1} - це різні значення, якщо це різні об'єкти в пам'яті.
Коли використовувати
- Рекурсивні алгоритми з перекривними підзадачами, як числа Фібоначчі:
fib(40)падає з O(2^n) до O(n) - React-компоненти, що фільтрують або трансформують великі списки при кожному рендері
- Функції, які часто викликаються з однаковими ID, наприклад конфігурація per-user
- Пропускай для одноразових викликів, функцій з побічними ефектами або операцій швидше ~1мс
Як це працює всередині
Базова реалізація - це замикання (closure) навколо звичайного об'єкта або Map. При кожному виклику аргументи серіалізуються в ключ кешу і перевіряється попадання. useMemo у React зберігає кешоване значення у вузлі fiber і при кожному рендері порівнює масив deps через Object.is. Якщо deps рівні - повертає збережений результат і не запускає функцію.
Типові помилки
Мемоізація нечистої функції
const getRandom = memoize(() => Math.random());
getRandom(); // 0.42
getRandom(); // Знову 0.42 - заморожено на першому викликуНемає аргументів - немає диференціації ключа. Перший результат кешується і ніколи не оновлюється.
Застарілі deps в React
// БАГ: порожній масив deps закриває початкове значення items
const result = useMemo(() => heavyCalc(items), []);
// Виправлення: вказуй реальні залежності
const result = useMemo(() => heavyCalc(items), [items]);Це найпоширеніша помилка на співбесідах. Правило лінтера exhaustive-deps відловлює її автоматично.
Зайва мемоізація дешевих операцій
// Не варто: V8 і так оптимізує цей цикл
const doubled = useMemo(() => arr.map(x => x * 2), [arr]);Мемоізація сама по собі має накладні витрати: генерація ключа, пошук у кеші, пам'ять. Для операцій швидше ~1мс ці витрати часто перевищують вигоду. Спочатку профілюй.
Мутація закешованого об'єкта
function getData(id) {
if (!cache[id]) cache[id] = fetchSync(id);
cache[id].tags.push('new-tag'); // Ламає всіх, хто тримає це посилання
}Кеш зберігає посилання, не копію. Будь-який код, що звертається до цього запису, побачить мутацію.
Де зустрічається
- React 18:
useMemoдля фільтрованих списків,useCallbackдля стабілізації посилань на обробники подій - Lodash:
_.memoizeдля утилітарних функцій, які використовуються в багатьох компонентах - Redux Toolkit:
createSelectorчерез Reselect для похідного стану на зразок todo, відфільтрованих за userId - Next.js 14:
unstable_cacheдля серверних компонентів, що запитують однакові дані в різних запитах - Node.js/Express: middleware для кешування JSON-відповідей на read-heavy ендпоінтах
Питання на співбесіді
Q: В чому різниця між мемоізацією і кешуванням?
A: Мемоізація прив'язана до функції і живе в пам'яті програми або замикання. Загальне кешування - це на рівні застосунку, часто в зовнішніх сховищах на зразок Redis з явною политикою expire.
Q: Коли useMemo не запобігає повторному рендеру?
A: Коли батьківський компонент передає нове посилання на об'єкт, навіть якщо дані всередині однакові. Порівняння deps - shallow через Object.is, тому два окремі {a: 1} не рівні. Потрібен useCallback вище по дереву, щоб стабілізувати посилання.
Q: Який просторовий трейдоф у мемоізації?
A: Кеш росте з кожною унікальною комбінацією аргументів. Для функцій з великою кількістю різних вхідних даних це стає помітним. Виправлення для довгоживучих процесів - TTL: зберігати {value, expiry} і перераховувати після закінчення терміну.
Q: Навіщо мемоізувати fib, якщо V8 вже оптимізує JS?
A: В JavaScript немає оптимізації хвостових викликів (TCO) на практиці. Без мемоізації fib(40) робить ~2^40 рекурсивних викликів. З нею кожне значення від 0 до 40 обчислюється рівно один раз.
Приклади
Базовий: функція-обгортка memoize
function memoize(fn) {
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
cache.set(key, result);
return result;
};
}
function slowSquare(n) {
// Уяви, що це дорога операція на 50мс
return n * n;
}
const fastSquare = memoize(slowSquare);
fastSquare(4); // 16, обчислено
fastSquare(4); // 16, з кешу
fastSquare(9); // 81, обчислено (новий аргумент)Обгортка перехоплює кожен виклик, спочатку перевіряє кеш і запускає оригінальну функцію тільки при промаху. Map надійніше обробляє ключі, ніж звичайний об'єкт для нерядкових аргументів.
Середній рівень: React-список з useMemo
Список 1000 todo не повинен фільтруватися при кожному натисканні клавіші у непов'язаному інпуті.
import { useMemo, useState } from 'react';
function TodoList({ todos }) {
const [filter, setFilter] = useState('all');
const [searchText, setSearchText] = useState('');
// Без useMemo: фільтрує 1000 елементів при кожному рендері, включно зі зміною searchText
const visibleTodos = useMemo(() =>
todos.filter(todo => {
if (filter === 'all') return true;
return filter === 'done' ? todo.done : !todo.done;
}),
[todos, filter] // Перераховує тільки при зміні todos або filter, але не searchText
);
return (
<>
<input
value={searchText}
onChange={e => setSearchText(e.target.value)}
placeholder="Пошук..."
/>
<ul>{visibleTodos.map(todo => <li key={todo.id}>{todo.text}</li>)}</ul>
</>
);
}Друкування в пошуковому інпуті викликає рендери, але visibleTodos щоразу читається з кешу. Фільтр запускається знову тільки коли todos або filter справді змінились.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.