Мемоизация (Memoization) в JavaScript
Что такое мемоизация?
Мемоизация (Memoization) — это техника оптимизации, которая сохраняет (кэширует) результаты выполнения функции для определённых аргументов. При повторном вызове с теми же аргументами функция возвращает закэшированный результат вместо повторного вычисления.
Простыми словами
Мемоизация — это "запоминание" результатов дорогостоящих вычислений, чтобы не делать их заново.
Зачем нужна мемоизация?
- Оптимизация производительности дорогостоящих вычислений
- Уменьшение количества повторных вычислений
- Ускорение рекурсивных алгоритмов
- Оптимизация рендеринга в React
Базовая реализация
function memoize(fn) {
const cache = {}; // Объект для хранения результатов
return function(...args) {
const key = JSON.stringify(args); // Ключ из аргументов
if (key in cache) {
console.log('Из кэша');
return cache[key];
}
console.log('Вычисляем');
const result = fn(...args);
cache[key] = result;
return result;
};
}
// Использование
function expensiveSum(a, b) {
// Имитация долгой операции
for (let i = 0; i < 1000000000; i++) {}
return a + b;
}
const memoizedSum = memoize(expensiveSum);
console.time('First call');
memoizedSum(5, 10); // Вычисляем
console.timeEnd('First call'); // ~1000ms
console.time('Second call');
memoizedSum(5, 10); // Из кэша
console.timeEnd('Second call'); // ~0ms
Классический пример: Числа Фибоначчи
Без мемоизации (очень медленно)
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
console.time('fib');
fibonacci(40); // ~1.5 секунды
console.timeEnd('fib');
Проблема: Функция вычисляет одни и те же значения множество раз.
fib(3) вычисляется 2 раза, fib(2) — 3 раза!
С мемоизацией (быстро)
const fibonacci = memoize((n) => {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
});
console.time('fib');
fibonacci(40); // ~0.5 миллисекунды
console.timeEnd('fib');
Результат:
Скорость увеличивается в тысячи раз для больших значений N.
Продвинутая реализация
С ограничением размера кэша
function memoize(fn, maxSize = 100) {
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const result = fn(...args);
// Ограничиваем размер кэша
if (cache.size >= maxSize) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey); // Удаляем самый старый
}
cache.set(key, result);
return result;
};
}
С TTL (Time To Live)
function memoizeWithTTL(fn, ttl = 5000) {
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
const cached = cache.get(key);
if (cached && Date.now() - cached.timestamp < ttl) {
return cached.value;
}
const result = fn(...args);
cache.set(key, {
value: result,
timestamp: Date.now()
});
return result;
};
}
// Использование
const fetchUser = memoizeWithTTL(async (id) => {
const response = await fetch(`/api/users/${id}`);
return response.json();
}, 10000); // Кэш на 10 секунд
С LRU (Least Recently Used)
function memoizeLRU(fn, maxSize = 100) {
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
const value = cache.get(key);
// Перемещаем в конец (отмечаем как недавно использованное)
cache.delete(key);
cache.set(key, value);
return value;
}
const result = fn(...args);
cache.set(key, result);
// Удаляем самый старый (первый) элемент
if (cache.size > maxSize) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
return result;
};
}
Мемоизация в React
React.memo
Мемоизирует компонент целиком — пропускает ре-рендер, если пропсы не изменились.
import { memo } from 'react';
const ExpensiveComponent = memo(({ data }) => {
console.log('Рендер ExpensiveComponent');
// Тяжёлые вычисления
const processed = processData(data);
return <div>{processed}</div>;
});
// Компонент ре-рендерится только если data изменилась
С кастомным сравнением
const UserCard = memo(
({ user }) => {
return <div>{user.name}</div>;
},
(prevProps, nextProps) => {
// Вернуть true, если НЕ нужно обновлять
return prevProps.user.id === nextProps.user.id;
}
);
useMemo
Мемоизирует результат вычислений внутри компонента.
import { useMemo } from 'react';
function ProductList({ products, filterText }) {
const filteredProducts = useMemo(() => {
console.log('Фильтруем продукты');
return products.filter(p =>
p.name.toLowerCase().includes(filterText.toLowerCase())
);
}, [products, filterText]);
return (
<ul>
{filteredProducts.map(p => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
Важно:
useMemo пересчитывает значение только когда изменяются зависимости в массиве [products, filterText].
useCallback
Мемоизирует саму функцию (чтобы избежать создания новой функции при каждом рендере).
import { useCallback, memo } from 'react';
const Button = memo(({ onClick, children }) => {
console.log(`Рендер кнопки "${children}"`);
return <button onClick={onClick}>{children}</button>;
});
function Parent() {
const [count, setCount] = useState(0);
const [other, setOther] = useState(0);
// Создаёт новую функцию при каждом рендере
const handleClick = () => setCount(count + 1);
// Функция создаётся один раз
const handleClickMemo = useCallback(() => {
setCount(prev => prev + 1);
}, []);
return (
<>
<Button onClick={handleClickMemo}>Count: {count}</Button>
<button onClick={() => setOther(other + 1)}>Other: {other}</button>
</>
);
}
Когда НЕ использовать мемоизацию?
Для простых вычислений
// Не нужно
const doubled = useMemo(() => count * 2, [count]);
// Лучше просто
const doubled = count * 2;
Для функций с побочными эффектами
// Плохо - API вызов закэшируется навсегда
const fetchData = memoize(async (id) => {
return await fetch(`/api/users/${id}`);
});
Когда кэш займёт больше памяти, чем сэкономит времени
// Если функция вызывается редко с разными аргументами
const memoizedSort = memoize(arr => [...arr].sort());
// Кэш будет расти бесконечно
Библиотеки для мемоизации
Lodash
import { memoize } from 'lodash';
const expensiveFn = memoize((a, b) => {
return a + b;
});
// Можно задать свой resolver для ключа
const memoized = memoize(
(obj) => obj.value,
(obj) => obj.id // Ключ кэша
);
fast-memoize
import memoize from 'fast-memoize';
const fn = memoize((a, b) => a + b);
reselect (для Redux)
import { createSelector } from 'reselect';
const getUsers = state => state.users;
const getFilter = state => state.filter;
const getFilteredUsers = createSelector(
[getUsers, getFilter],
(users, filter) => users.filter(u => u.name.includes(filter))
);
// Результат кэшируется
Подводные камни
1. Сериализация аргументов
// Объекты с одинаковыми данными, но разные ссылки
const obj1 = { id: 1 };
const obj2 = { id: 1 };
JSON.stringify(obj1) === JSON.stringify(obj2); // true, но медленно
// Лучше использовать примитивы как ключи
function memoize(fn) {
const cache = new Map();
return function(id) { // Только примитивный аргумент
if (cache.has(id)) return cache.get(id);
const result = fn(id);
cache.set(id, result);
return result;
};
}
2. Утечки памяти
// Кэш растёт бесконечно
const memoized = memoize(expensiveFn);
// Ограничиваем размер или добавляем TTL
const memoized = memoizeLRU(expensiveFn, 100);
3. Мутации в React
// useMemo не сработает при мутации объекта
const obj = { count: 0 };
obj.count++; // Мутация - ссылка та же
// Создавайте новый объект
setObj({ ...obj, count: obj.count + 1 });
Вывод
Мемоизация:
- Оптимизирует дорогостоящие вычисления
- Критична для рекурсивных алгоритмов
- Важна для производительности React-приложений
- Не всегда нужна — измеряйте производительность
- Может привести к утечкам памяти без контроля размера кэша
- Требует правильной работы с зависимостями
На собеседовании:
Важно уметь:
- Объяснить, что такое мемоизация и зачем она нужна
- Реализовать базовую функцию
memoize - Привести примеры использования (Fibonacci, API запросы)
- Объяснить разницу между
useMemo,useCallbackиReact.memo - Понимать, когда мемоизация не нужна или вредна