Hack Frontend Community

Мемоизация (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
  • Понимать, когда мемоизация не нужна или вредна