Практические паттерны мемоизации в JavaScript
Вы профилировали своё приложение и обнаружили функцию, которая выполняется тысячи раз с идентичными входными данными. Мемоизация кажется очевидным решением. Но прежде чем обернуть всё в кеш, вам следует знать: неправильно реализованная мемоизация создаёт баги, которые сложнее обнаружить, чем проблемы производительности, с которых вы начали.
Эта статья охватывает практические паттерны мемоизации в JavaScript, распространённые ошибки мемоизации, с которыми сталкиваются JavaScript-разработчики, и способы безопасного применения этих техник в production-коде — включая асинхронную мемоизацию и лучшие практики использования React useMemo.
Ключевые выводы
- Мемоизация кэширует результаты функций на основе аргументов, обменивая память на скорость — но надёжно работает только с чистыми функциями и примитивными аргументами.
- Ссылки на объекты вызывают устаревшие попадания в кеш при изменении данных; используйте библиотеки вроде fast-memoize для пользовательских ключей или придерживайтесь примитивов.
- Неограниченные кеши приводят к утечкам памяти в долгоживущих приложениях; реализуйте вытеснение LRU, истечение TTL или привязывайте кеши к жизненным циклам компонентов.
- Асинхронная мемоизация требует немедленного кэширования промисов и удаления неудачных записей для предотвращения дублирующих запросов и обеспечения повторных попыток.
- React useMemo — это целевая оптимизация, а не стандартный подход — сначала профилируйте и применяйте только когда вычисления измеримо медленные.
Что на самом деле делает мемоизация
Мемоизация кэширует результаты функций на основе их аргументов. Вызовите функцию снова с теми же входными данными, и вы получите кэшированный результат вместо повторного вычисления.
В JavaScript нет встроенной мемоизации. TC39 обсуждал предложения (например, Function.prototype.memo), но ничего production-ready пока нет (предложение). Вам придётся реализовать это самостоятельно или использовать библиотеку.
Вот базовый паттерн для функций с одним аргументом:
function memoize(fn) {
const cache = new Map()
return (...args) => {
const key = args[0]
if (cache.has(key)) return cache.get(key)
const result = fn(...args)
cache.set(key, result)
return result
}
}
Это работает для примитивных аргументов. Но ломается неочевидным образом для всего остального.
Проблема ссылок на объекты
Объекты кэшируются по ссылке, а не по значению. Это постоянно ловит разработчиков:
const memoizedFn = memoize(processData)
const config = { threshold: 10 }
memoizedFn(config) // Вычисляет
config.threshold = 20
memoizedFn(config) // Возвращает устаревший кэшированный результат
Одна и та же ссылка означает попадание в кеш, даже если данные изменились.
Некоторые разработчики пытаются исправить это, используя JSON.stringify(args) в качестве ключа кеша. Это может работать для простых данных, но не справляется с циклическими ссылками, теряет функции и символы и может быть медленным для больших объектов.
Решение: Мемоизируйте только функции с примитивными аргументами или используйте библиотеку вроде fast-memoize, которая поддерживает пользовательские резолверы ключей/сериализаторы для более сложных случаев.
Когда мемоизация создаёт проблемы
Нечистые функции
Мемоизация нечистых функций создаёт проблемы, которые невозможно отладить:
// Никогда не мемоизируйте это для долгосрочного кэширования
const getData = memoize(() => {
return fetch('/api/data').then(r => r.json())
})
Первый вызов кэширует промис. Каждый последующий вызов возвращает тот же промис — даже если данные на сервере изменились.
Асинхронная мемоизация безопасна только когда вы намеренно хотите дедуплицировать конкурентные запросы, или когда вы также реализуете инвалидацию или истечение на основе TTL (рассмотрено ниже).
Неограниченный рост кеша
Без стратегий вытеснения из кеша ваш кеш растёт бесконечно:
// Утечка памяти, которая только ждёт своего часа
const processUserInput = memoize((input) => expensiveOperation(input))
Каждый уникальный ввод добавляется в кеш. В долгоживущем приложении это приводит к утечке памяти.
Решения:
- Установите максимальный размер кеша (вытеснение LRU)
- Добавьте истечение TTL (time-to-live)
- Привяжите кеши к жизненным циклам компонентов
function memoizeWithLimit(fn, maxSize = 100) {
const cache = new Map()
return (...args) => {
const key = args[0]
if (cache.has(key)) return cache.get(key)
if (cache.size >= maxSize) {
const firstKey = cache.keys().next().value
cache.delete(firstKey)
}
const result = fn(...args)
cache.set(key, result)
return result
}
}
Discover how at OpenReplay.com.
Правильная асинхронная мемоизация
Асинхронная мемоизация требует специальной обработки конкурентных вызовов и ошибок:
function memoizeAsync(fn) {
const cache = new Map()
return async (...args) => {
const key = args[0]
if (cache.has(key)) return cache.get(key)
const promise = fn(...args).catch(err => {
cache.delete(key) // Не кэшируем ошибки
throw err
})
cache.set(key, promise)
return promise
}
}
Кэшируйте промис немедленно. Это предотвращает дублирующие конкурентные запросы. Удаляйте при ошибке, чтобы повторные попытки работали.
Лучшие практики React useMemo
useMemo и React.memo — это целевые оптимизации, а не стандартный подход. Они добавляют сложность и могут ухудшить производительность при неправильном использовании (см. официальную документацию React: https://react.dev/reference/react/useMemo).
Используйте useMemo когда:
- Вычисление производных данных из props/state измеримо медленное
- Вы передаёте объекты мемоизированным дочерним компонентам
Пропускайте useMemo когда:
- Вычисление тривиальное
- Вы не измерили проблему производительности
// Вероятно, не нужно
const doubled = useMemo(() => value * 2, [value])
// Потенциально полезно
const sortedItems = useMemo(
() => items.slice().sort((a, b) => a.name.localeCompare(b.name)),
[items]
)
Сравнение зависимостей в React использует Object.is — равенство по ссылке. Новые объектные литералы ломают мемоизацию при каждом рендере.
Фреймворк принятия решений
Перед мемоизацией спросите себя:
- Функция чистая? Нет побочных эффектов, одинаковые входные данные всегда производят одинаковые результаты.
- Она действительно медленная? Сначала профилируйте. Не гадайте.
- Аргументы примитивные или стабильные ссылки? Объектные аргументы требуют осторожной обработки.
- Каково время жизни кеша? Неограниченные кеши приводят к утечкам памяти.
Заключение
Мемоизация обменивает память на скорость. Убедитесь, что вы получаете выгодную сделку. Начните с профилирования, чтобы подтвердить существование реальной проблемы производительности, затем применяйте мемоизацию выборочно к чистым функциям с предсказуемыми аргументами. Реализуйте ограничения кеша для предотвращения утечек памяти, осторожно обрабатывайте асинхронные операции и помните, что useMemo в React — это инструмент оптимизации, а не стандартный паттерн. При правильном применении мемоизация устраняет избыточные вычисления. При неправильном — вносит неочевидные баги, которые переживают выигрыш в производительности.
Часто задаваемые вопросы
Мемоизация API-вызовов рискованна, потому что данные на сервере меняются со временем. Если вы кэшируете промис, последующие вызовы возвращают устаревшие данные. Мемоизируйте API-вызовы только когда вы явно хотите дедуплицировать конкурентные запросы и реализуете инвалидацию кеша или истечение TTL для периодического обновления данных.
Объекты сравниваются по ссылке, а не по значению. Если вы мутируете объект и снова вызываете мемоизированную функцию, она возвращает кэшированный результат, потому что ссылка не изменилась. Используйте паттерны иммутабельных данных, создавайте новые объекты вместо мутации или сериализуйте аргументы с помощью JSON.stringify для простых случаев.
Профилируйте до и после, используя DevTools браузера или инструменты профилирования Node.js. Измеряйте время выполнения и использование памяти. Если функция выполняется редко или вычисляется быстро, накладные расходы мемоизации могут превысить выигрыш. Процент попаданий в кеш тоже важен — низкий процент означает потраченную впустую память с минимальной пользой.
Нет. useMemo добавляет накладные расходы на отслеживание и сравнение зависимостей. Для простых вычислений вроде базовой математики или конкатенации строк стоимость мемоизации превышает стоимость вычисления. Резервируйте useMemo для дорогих операций вроде сортировки больших массивов, сложной фильтрации или создания объектов, передаваемых мемоизированным дочерним компонентам.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Check our GitHub repo and join the thousands of developers in our community.