← React/memo, useMemo и useCallback#269 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: React или VueПрактика: React setТермин: React HooksТема: React: хуки и экосистема

memo, useMemo и useCallback

Почему React перерисовывает компоненты

По умолчанию, когда компонент перерисовывается, все его дочерние компоненты тоже перерисовываются — даже если их пропсы не изменились. Это нормальное поведение React: лучше перерисовать лишний раз, чем пропустить нужное обновление.

Однако при сложных вычислениях или больших деревьях компонентов это может замедлить приложение. Тогда на помощь приходят инструменты мемоизации.

React.memo: мемоизация компонентов

React.memo — HOC (компонент высшего порядка), который оборачивает компонент и пропускает его ре-рендер, если пропсы не изменились:

// Без memo: ChildComponent перерисовывается при каждом ре-рендере Parent
function ChildComponent({ name }) {
  console.log('ChildComponent render')
  return <div>{name}</div>
}

// С memo: ChildComponent перерисовывается только если name изменился
const ChildComponent = React.memo(function ChildComponent({ name }) {
  console.log('ChildComponent render')
  return <div>{name}</div>
})

Сравнение пропсов происходит через поверхностное сравнение (shallow equal) — как Object.is для каждого пропса.

Проблема референциального равенства

Здесь кроется главная ловушка: объекты и функции в JavaScript сравниваются по ссылке:

function Parent() {
  const [count, setCount] = useState(0)

  // ПРОБЛЕМА: новый объект создаётся на каждом рендере!
  const style = { color: 'red' }  // {} !== {} всегда false

  // ПРОБЛЕМА: новая функция на каждом рендере!
  const handleClick = () => console.log('click')

  // Child получит новые style и handleClick — и перерисуется, даже с memo!
  return <Child style={style} onClick={handleClick} />
}

useMemo: мемоизация вычислений

useMemo кешируует результат функции между рендерами:

function ProductList({ products, filterText }) {
  // БЕЗ useMemo: фильтрация запускается при КАЖДОМ рендере
  const filtered = products.filter(p => p.name.includes(filterText))

  // С useMemo: фильтрация запускается только при изменении зависимостей
  const filteredMemo = useMemo(
    () => products.filter(p => p.name.includes(filterText)),
    [products, filterText]  // зависимости — как у useEffect
  )

  return filteredMemo.map(p => <Product key={p.id} {...p} />)
}

useMemo также стабилизирует ссылку на объект, что помогает с memo:

const style = useMemo(() => ({ color: 'red' }), [])
// style — одна и та же ссылка между рендерами!

useCallback: стабильная ссылка на функцию

useCallback — это useMemo для функций:

function Parent() {
  const [count, setCount] = useState(0)
  const [text, setText] = useState('')

  // БЕЗ useCallback: новая функция при каждом рендере
  const handleClick = () => console.log(count)

  // С useCallback: функция пересоздаётся только при изменении count
  const handleClickMemo = useCallback(
    () => console.log(count),
    [count]
  )

  // Child с React.memo не будет ре-рендериться при изменении text
  return <Child onClick={handleClickMemo} />
}

Когда НЕ нужна мемоизация

Мемоизация сама по себе имеет стоимость: React должен сравнивать зависимости на каждом рендере и хранить кеш. Это накладные расходы!

Не мемоизируйте:

  • Простые примитивные значения (useMemo(() => count * 2, [count]) — лишнее)
  • Компоненты, которые и так редко рендерятся
  • Функции, которые не передаются в memo-обёрнутые компоненты
  • Мемоизируйте только когда:

  • Вычисления действительно дорогостоящие (> 1мс)
  • Компонент часто ре-рендерится с неизменными пропсами
  • Функция передаётся как зависимость в useEffect или дочерний memo-компонент
  • Правило: сначала напишите без мемоизации, замеряйте, потом оптимизируйте.

    Примеры

    Демонстрация мемоизации через Map-кеш, замер производительности дорогостоящих вычислений, проблема референциального равенства

    // Мемоизация — это кеширование результата вычислений.
    // Реализуем её вручную, чтобы понять как работают useMemo и useCallback.
    
    // --- Проблема: дорогостоящее вычисление без кеша ---
    
    function isPrime(n) {
      if (n < 2) return false
      for (let i = 2; i <= Math.sqrt(n); i++) {
        if (n % i === 0) return false
      }
      return true
    }
    
    function filterPrimes(numbers) {
      // Имитируем дорогостоящее вычисление
      return numbers.filter(isPrime)
    }
    
    // Генерируем тестовые данные
    const bigArray = Array.from({ length: 10000 }, (_, i) => i + 2)
    
    console.log('=== Без мемоизации ===')
    const start1 = performance.now()
    const result1 = filterPrimes(bigArray)
    const time1 = (performance.now() - start1).toFixed(2)
    console.log(`Простых чисел: ${result1.length}, время: ${time1}мс`)
    
    // Симулируем "повторный рендер" — вычисление запускается снова!
    const start2 = performance.now()
    const result2 = filterPrimes(bigArray)
    const time2 = (performance.now() - start2).toFixed(2)
    console.log(`Повторный рендер: ${time2}мс (те же данные, но вычисление повторяется!)`)
    
    // --- Реализация useMemo через Map-кеш ---
    
    function createMemoCache() {
      const cache = new Map()
    
      return function memoize(computeFn, deps) {
        // Ключ кеша — строковое представление зависимостей
        const key = JSON.stringify(deps)
    
        if (cache.has(key)) {
          console.log(`  [cache HIT] ключ: ${key.substring(0, 40)}...`)
          return cache.get(key)
        }
    
        console.log(`  [cache MISS] вычисляем...`)
        const result = computeFn()
        cache.set(key, result)
        return result
      }
    }
    
    const useMemo = createMemoCache()
    
    console.log('
    === С мемоизацией (useMemo) ===')
    
    // "Первый рендер"
    const startMemo1 = performance.now()
    const memoResult1 = useMemo(
      () => filterPrimes(bigArray),
      [bigArray.length, bigArray[0]]  // зависимости
    )
    console.log(`Первый вызов: ${(performance.now() - startMemo1).toFixed(2)}мс`)
    
    // "Второй рендер" с теми же зависимостями
    const startMemo2 = performance.now()
    const memoResult2 = useMemo(
      () => filterPrimes(bigArray),
      [bigArray.length, bigArray[0]]  // зависимости не изменились
    )
    console.log(`Второй вызов (из кеша): ${(performance.now() - startMemo2).toFixed(2)}мс`)
    
    // --- Проблема референциального равенства ---
    
    console.log('
    === Референциальное равенство ===')
    
    // Примитивы сравниваются по значению
    console.log('5 === 5:', 5 === 5)           // true
    console.log('"a" === "a":', 'a' === 'a')   // true
    
    // Объекты — по ссылке
    const obj1 = { color: 'red' }
    const obj2 = { color: 'red' }
    console.log('obj1 === obj2:', obj1 === obj2)  // false! Разные объекты!
    
    // Функции — тоже по ссылке
    const fn1 = () => console.log('click')
    const fn2 = () => console.log('click')
    console.log('fn1 === fn2:', fn1 === fn2)  // false!
    
    // React.memo сравнивает пропсы через Object.is (как ===)
    // Если компонент получает { style: {color:'red'} } каждый рендер —
    // это ВСЕГДА новый объект, memo не поможет!
    console.log('
    Вывод: передавай в memo-компоненты только:')
    console.log('- примитивы (string, number, boolean)')
    console.log('- стабилизированные через useMemo объекты')
    console.log('- стабилизированные через useCallback функции')
    
    // React-код для справки:
    // const style = useMemo(() => ({ color: 'red' }), [])
    // const handleClick = useCallback(() => doSomething(id), [id])
    // const filtered = useMemo(() => filterPrimes(numbers), [numbers])

    memo, useMemo и useCallback

    Почему React перерисовывает компоненты

    По умолчанию, когда компонент перерисовывается, все его дочерние компоненты тоже перерисовываются — даже если их пропсы не изменились. Это нормальное поведение React: лучше перерисовать лишний раз, чем пропустить нужное обновление.

    Однако при сложных вычислениях или больших деревьях компонентов это может замедлить приложение. Тогда на помощь приходят инструменты мемоизации.

    React.memo: мемоизация компонентов

    React.memo — HOC (компонент высшего порядка), который оборачивает компонент и пропускает его ре-рендер, если пропсы не изменились:

    // Без memo: ChildComponent перерисовывается при каждом ре-рендере Parent
    function ChildComponent({ name }) {
      console.log('ChildComponent render')
      return <div>{name}</div>
    }
    
    // С memo: ChildComponent перерисовывается только если name изменился
    const ChildComponent = React.memo(function ChildComponent({ name }) {
      console.log('ChildComponent render')
      return <div>{name}</div>
    })

    Сравнение пропсов происходит через поверхностное сравнение (shallow equal) — как Object.is для каждого пропса.

    Проблема референциального равенства

    Здесь кроется главная ловушка: объекты и функции в JavaScript сравниваются по ссылке:

    function Parent() {
      const [count, setCount] = useState(0)
    
      // ПРОБЛЕМА: новый объект создаётся на каждом рендере!
      const style = { color: 'red' }  // {} !== {} всегда false
    
      // ПРОБЛЕМА: новая функция на каждом рендере!
      const handleClick = () => console.log('click')
    
      // Child получит новые style и handleClick — и перерисуется, даже с memo!
      return <Child style={style} onClick={handleClick} />
    }

    useMemo: мемоизация вычислений

    useMemo кешируует результат функции между рендерами:

    function ProductList({ products, filterText }) {
      // БЕЗ useMemo: фильтрация запускается при КАЖДОМ рендере
      const filtered = products.filter(p => p.name.includes(filterText))
    
      // С useMemo: фильтрация запускается только при изменении зависимостей
      const filteredMemo = useMemo(
        () => products.filter(p => p.name.includes(filterText)),
        [products, filterText]  // зависимости — как у useEffect
      )
    
      return filteredMemo.map(p => <Product key={p.id} {...p} />)
    }

    useMemo также стабилизирует ссылку на объект, что помогает с memo:

    const style = useMemo(() => ({ color: 'red' }), [])
    // style — одна и та же ссылка между рендерами!

    useCallback: стабильная ссылка на функцию

    useCallback — это useMemo для функций:

    function Parent() {
      const [count, setCount] = useState(0)
      const [text, setText] = useState('')
    
      // БЕЗ useCallback: новая функция при каждом рендере
      const handleClick = () => console.log(count)
    
      // С useCallback: функция пересоздаётся только при изменении count
      const handleClickMemo = useCallback(
        () => console.log(count),
        [count]
      )
    
      // Child с React.memo не будет ре-рендериться при изменении text
      return <Child onClick={handleClickMemo} />
    }

    Когда НЕ нужна мемоизация

    Мемоизация сама по себе имеет стоимость: React должен сравнивать зависимости на каждом рендере и хранить кеш. Это накладные расходы!

    Не мемоизируйте:

  • Простые примитивные значения (useMemo(() => count * 2, [count]) — лишнее)
  • Компоненты, которые и так редко рендерятся
  • Функции, которые не передаются в memo-обёрнутые компоненты
  • Мемоизируйте только когда:

  • Вычисления действительно дорогостоящие (> 1мс)
  • Компонент часто ре-рендерится с неизменными пропсами
  • Функция передаётся как зависимость в useEffect или дочерний memo-компонент
  • Правило: сначала напишите без мемоизации, замеряйте, потом оптимизируйте.

    Примеры

    Демонстрация мемоизации через Map-кеш, замер производительности дорогостоящих вычислений, проблема референциального равенства

    // Мемоизация — это кеширование результата вычислений.
    // Реализуем её вручную, чтобы понять как работают useMemo и useCallback.
    
    // --- Проблема: дорогостоящее вычисление без кеша ---
    
    function isPrime(n) {
      if (n < 2) return false
      for (let i = 2; i <= Math.sqrt(n); i++) {
        if (n % i === 0) return false
      }
      return true
    }
    
    function filterPrimes(numbers) {
      // Имитируем дорогостоящее вычисление
      return numbers.filter(isPrime)
    }
    
    // Генерируем тестовые данные
    const bigArray = Array.from({ length: 10000 }, (_, i) => i + 2)
    
    console.log('=== Без мемоизации ===')
    const start1 = performance.now()
    const result1 = filterPrimes(bigArray)
    const time1 = (performance.now() - start1).toFixed(2)
    console.log(`Простых чисел: ${result1.length}, время: ${time1}мс`)
    
    // Симулируем "повторный рендер" — вычисление запускается снова!
    const start2 = performance.now()
    const result2 = filterPrimes(bigArray)
    const time2 = (performance.now() - start2).toFixed(2)
    console.log(`Повторный рендер: ${time2}мс (те же данные, но вычисление повторяется!)`)
    
    // --- Реализация useMemo через Map-кеш ---
    
    function createMemoCache() {
      const cache = new Map()
    
      return function memoize(computeFn, deps) {
        // Ключ кеша — строковое представление зависимостей
        const key = JSON.stringify(deps)
    
        if (cache.has(key)) {
          console.log(`  [cache HIT] ключ: ${key.substring(0, 40)}...`)
          return cache.get(key)
        }
    
        console.log(`  [cache MISS] вычисляем...`)
        const result = computeFn()
        cache.set(key, result)
        return result
      }
    }
    
    const useMemo = createMemoCache()
    
    console.log('
    === С мемоизацией (useMemo) ===')
    
    // "Первый рендер"
    const startMemo1 = performance.now()
    const memoResult1 = useMemo(
      () => filterPrimes(bigArray),
      [bigArray.length, bigArray[0]]  // зависимости
    )
    console.log(`Первый вызов: ${(performance.now() - startMemo1).toFixed(2)}мс`)
    
    // "Второй рендер" с теми же зависимостями
    const startMemo2 = performance.now()
    const memoResult2 = useMemo(
      () => filterPrimes(bigArray),
      [bigArray.length, bigArray[0]]  // зависимости не изменились
    )
    console.log(`Второй вызов (из кеша): ${(performance.now() - startMemo2).toFixed(2)}мс`)
    
    // --- Проблема референциального равенства ---
    
    console.log('
    === Референциальное равенство ===')
    
    // Примитивы сравниваются по значению
    console.log('5 === 5:', 5 === 5)           // true
    console.log('"a" === "a":', 'a' === 'a')   // true
    
    // Объекты — по ссылке
    const obj1 = { color: 'red' }
    const obj2 = { color: 'red' }
    console.log('obj1 === obj2:', obj1 === obj2)  // false! Разные объекты!
    
    // Функции — тоже по ссылке
    const fn1 = () => console.log('click')
    const fn2 = () => console.log('click')
    console.log('fn1 === fn2:', fn1 === fn2)  // false!
    
    // React.memo сравнивает пропсы через Object.is (как ===)
    // Если компонент получает { style: {color:'red'} } каждый рендер —
    // это ВСЕГДА новый объект, memo не поможет!
    console.log('
    Вывод: передавай в memo-компоненты только:')
    console.log('- примитивы (string, number, boolean)')
    console.log('- стабилизированные через useMemo объекты')
    console.log('- стабилизированные через useCallback функции')
    
    // React-код для справки:
    // const style = useMemo(() => ({ color: 'red' }), [])
    // const handleClick = useCallback(() => doSomething(id), [id])
    // const filtered = useMemo(() => filterPrimes(numbers), [numbers])

    Задание

    Создай компонент App с двумя состояниями: список чисел (numbers) и строка поиска (search). Используй useMemo для вычисления отфильтрованных чисел — перефильтровывай только при изменении numbers или search, не при каждом рендере. Добавь кнопку "Добавить число" которая добавляет случайное число в список.

    Подсказка

    useMemo(() => { ... }, [numbers, search]) — первый аргумент функция, второй массив зависимостей. Открой консоль — "Вычисление filtered..." должно появляться только при изменении numbers или search. addNumber: setNumbers(prev => [...prev, newNum]).

    Загружаем среду выполнения...
    Загружаем AI-помощника...