Перейти к содержимому
Тема 11 из 13
Теория

Производительность: memo, useMemo, useCallback

Сначала — не бойся перерисовок

Ты уже знаешь, что компонент перерисовывается при изменении состояния и при отрисовке родителя. Новички часто пугаются: «всё перерисовывается на каждый чих!». Но отрисовка — это вызов функции и сравнение объектов, обычно это доли миллисекунды. Настоящий DOM меняется только там, где есть разница. В большинстве приложений ничего оптимизировать не нужно.

Оптимизация нужна, когда есть заметная проблема: поле ввода «подтормаживает», список из тысяч строк перерисовывается целиком, где-то идёт тяжёлый расчёт. Тогда у React есть три инструмента: useMemo, memo и useCallback. Понимать их нужно обязательно — они встречаются в любом настоящем проекте, — а применять с умом.

useMemo: запомнить результат вычисления

const { useMemo } = React;

function ProductList({ products, query }) {
  const [dark, setDark] = useState(false);

  const visible = useMemo(
    () => products.filter((p) => p.title.toLowerCase().includes(query.toLowerCase())),
    [products, query],
  );

  return (
    <div className={dark ? "dark" : ""}>
      <button onClick={() => setDark(!dark)}>Тема</button>
      <ul>{visible.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
    </div>
  );
}
  • useMemo(функция, [зависимости]) вызывает функцию при первой отрисовке и запоминает результат.
  • При следующих отрисовках, если зависимости не изменились, сразу возвращает запомненное значение, не вызывая функцию.
  • Переключение темы больше не фильтрует список заново: products и query те же.

Для фильтра из ста товаров это не нужно — он и так быстрый. useMemo нужен, когда расчёт действительно тяжёлый (тысячи элементов, сложная математика) или когда важно сохранить ту же ссылку на объект (об этом ниже).

memo: пропустить отрисовку компонента

memo оборачивает компонент и говорит React: «если props те же, что в прошлый раз, не вызывай меня — используй прошлый результат»:

const { memo } = React;

const UserCard = memo(function UserCard({ name, avatar }) {
  console.log("render", name);
  return (
    <div className="card">
      <img src={avatar} alt="" />
      <p>{name}</p>
    </div>
  );
});

Теперь при отрисовке родителя UserCard перерисуется, только если изменился name или avatar. Свои изменения состояния и контекста перерисовывают компонент как обычно — memo смотрит лишь на props.

Ловушка: сравнение по ссылке

memo сравнивает каждый prop через Object.is — для объектов, массивов и функций это сравнение ссылок. А в теле компонента при каждой отрисовке создаются новые объекты и функции:

function Parent() {
  const [n, setN] = useState(0);

  const style = { color: "red" };           // новый объект каждый раз
  const handleClick = () => console.log(1); // новая функция каждый раз

  return (
    <>
      <button onClick={() => setN(n + 1)}>{n}</button>
      <Child style={style} onClick={handleClick} />   {/* memo не поможет! */}
    </>
  );
}

Для memo это каждый раз «новые props», и Child всё равно перерисуется. Чтобы memo сработал, объекты и функции в props должны сохранять ссылку между отрисовками:

  • константы — вынести за пределы компонента;
  • вычисляемые объекты и массивы — useMemo;
  • функции — useCallback.

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

const { useCallback } = React;

function TodoList() {
  const [todos, setTodos] = useState([]);

  const handleDelete = useCallback((id) => {
    setTodos((list) => list.filter((t) => t.id !== id));
  }, []);

  return todos.map((t) => <TodoItem key={t.id} todo={t} onDelete={handleDelete} />);
}

const TodoItem = memo(function TodoItem({ todo, onDelete }) {
  return <li onClick={() => onDelete(todo.id)}>{todo.text}</li>;
});

useCallback(функция, [зависимости]) возвращает ту же самую функцию, пока зависимости не изменились. По сути это useMemo(() => функция, зависимости).

Обрати внимание на setTodos((list) => …). Если бы внутри было todos.filter(…), todos пришлось бы добавить в зависимости — и функция менялась бы при каждом изменении списка. Функция обновления берёт свежий список из аргумента, поэтому зависимостей нет и функция стабильна навсегда.

Как найти, что тормозит

Не угадывай — измеряй:

  • React Developer Tools (расширение браузера) → вкладка Profiler: записываешь действие и видишь, какие компоненты перерисовались и сколько это заняло;
  • в настройках там же — «Highlight updates»: перерисованные компоненты подсвечиваются на странице;
  • console.log в теле компонента — самый простой счётчик отрисовок.

Частые настоящие причины медленной работы: очень длинные списки (решение — показывать только видимую часть, «виртуализация»), тяжёлые вычисления при каждой отрисовке, состояние слишком высоко в дереве (поле ввода в корне приложения перерисовывает всё приложение — опусти состояние ниже, туда, где оно нужно).

Когда НЕ оптимизировать

  • useMemo и useCallback сами стоят времени и памяти и усложняют код. Обёртывать в них всё подряд — вредно.
  • useCallback без memo у дочернего компонента почти бесполезен: если ребёнок не мемоизирован, он перерисуется всё равно.
  • Сначала — правильная структура: состояние как можно ниже, вычисления при отрисовке, без лишних эффектов. Это решает большинство проблем без единого memo.

В новых версиях React появляется React Compiler, который расставляет такую мемоизацию автоматически. Но понимать, что он делает и почему, всё равно нужно.

Итог

  • Отрисовка обычно дешёвая. Оптимизируй, когда есть измеренная проблема.
  • useMemo(() => расчёт, [deps]) — запомнить результат тяжёлого вычисления или объект.
  • memo(Компонент) — пропускать отрисовку при тех же props.
  • Props сравниваются по ссылке: новые объекты и функции ломают memo.
  • useCallback(fn, [deps]) — та же функция между отрисовками; вместе с setX((old) => …) — без лишних зависимостей.
  • Измеряй через React DevTools Profiler.

Тяжёлый расчёт

Лёгкая+10 XP

Компонент Primes с prop limit показывает, сколько простых чисел до limit (функция countPrimes уже написана и работает медленно), и кнопку переключения темы Theme.

Сейчас countPrimes вызывается при каждой отрисовке — даже когда меняется только тема. Сделай так, чтобы она пересчитывалась только при изменении limit.

клик Theme   → countPrimes не вызывается
limit 100→200 → countPrimes вызывается один раз

Подключи коннектор, чтобы решать задачи

Код задач выполняется на твоём компьютере — так быстрее и надёжнее, чем в браузере. Для этого нужна бесплатная программа-коннектор CodeWithMe. Подключить её нужно один раз.

Ищем коннектор на этом компьютере…

  1. 1
    Скачай коннектор для своей системы (Windows, macOS или Linux).Скачать коннекторНа странице загрузки выбери файл со своей системой в названии, например windows-amd64.
  2. 2Запусти его и выбери папку, где будут лежать твои решения. Python и Node.js он поставит туда сам, когда понадобится.
  3. 3
    Введи ниже код из 6 цифр — он написан в окне коннектора («Код для сайта CodeWithMe»). Если браузер спросит про доступ к локальной сети — разреши.

Коннектор открытый (исходный код на GitHub), работает только на твоём компьютере и выполняет код лишь после ввода кода сопряжения.