Урок 27: Оптимизация React.memo, useMemo, useCallback в React

Мы с вами уже прошли огромный путь, научились создавать компоненты, управлять состоянием, работать с формами и стором данных. Наши приложения работают и это уже прекрасно.

Но сегодня мы перейдем на новый уровень, уровень осознанной разработки. Мы будем говорить о производительности. Вы когда-нибудь замечали, что приложение начинает немного «подтормаживать» по мере его роста? Или что консоль браузера пестрит лишними логами от компонентов, которые, казалось бы, не должны были перерисовываться? Это верные признаки того, что пришло время заняться оптимизацией.

В этом уроке мы детально разберем три мощных инструмента, которые React предоставляет для борьбы с лишними рендерами: React.memouseMemo и useCallback. Мы поймем, когда и зачем их использовать, а главное, как не переборщить, потому что слепая оптимизация может принести больше вреда, чем пользы.

Зачем бороться с лишними рендерами?

Аа почему вообще возникают эти «лишние» рендеры? React устроен таким образом, что когда состояние (state) компонента меняется, этот компонент и все его дочерние компоненты перерисовываются (рендерятся) заново. Это называется «реконсиляция», процесс сравнения старого и нового Virtual DOM и определения, что именно нужно обновить в реальном DOM.

В большинстве случаев этот механизм невероятно быстр и нам не стоит о нем беспокоиться. Проблемы начинаются тогда, когда наше приложение становится большим и сложным. Представьте себе огромный список из сотен элементов (<ProductCard />) или сложный компонент с тяжелыми вычислениями внутри. Если из-за обновления какого-то мелкого состояния в родителе (например, счетчика в хедере) начинает перерисовываться вся страница, включая этот тяжелый список, пользователь может заметить задержку, «проседание» FPS (кадров в секунду).

Лишние рендеры это не только потенциальная медлительность интерфейса. Это также:

  • Напрасная нагрузка на процессор пользователя, что критично для мобильных устройств.

  • Возможные побочные эффекты, которые срабатывают чаще чем нужно.

  • Усложнение отладки, так как сложнее отследить, что и когда обновилось.

Наша цель сегодня научиться говорить React: «Эй, дружище, этот компонент/значение/функция не изменились по-настоящему, не трать на них силы, используй старую версию». И делается это с помощью мемоизации. Мемоизация это техника оптимизации, при которой результат выполнения функции кешируется и при следующих вызовах с теми же аргументами возвращается закешированный результат, вместо повторного вычисления.

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

Первый и самый простой инструмент в нашем арсенале, это React.memo. Это Higher-Order Component (HOC, компонент высшего порядка), о которых мы говорили ранее. Если кратко, он «оборачивает» ваш функциональный компонент и возвращает новый, улучшенный компонент.

Как это работает? Компонент, обернутый в React.memo, будет перерисовываться только тогда, когда изменились его пропсы. React запоминает (мемоизирует) результат его рендера и перед следующим рендером сравнивает старые пропсы с новыми. Если пропсы одинаковы, React использует закешированную версию компонента, избегая лишнего рендера.

Давайте рассмотрим наглядный пример. Без React.memo:

jsx
// Дочерний компонент, который является "тяжелым"
const ExpensiveUserCard = ({ user, onEdit }) => {
  // Симуляция тяжелых вычислений
  console.log(`Рендер UserCard для ${user.name}`);
  return (
    <div>
      <h3>{user.name}</h3>
      <p>{user.email}</p>
      <button onClick={() => onEdit(user.id)}>Редактировать</button>
    </div>
  );
};

// Родительский компонент
const UserList = () => {
  const [users, setUsers] = useState([...]); // какой-то массив пользователей
  const [searchQuery, setSearchQuery] = useState(''); // состояние для поиска

  return (
    <div>
      {/* Поле ввода, которое обновляет searchQuery */}
      <input 
        type="text" 
        value={searchQuery} 
        onChange={(e) => setSearchQuery(e.target.value)} 
      />
      
      {/* Список пользователей */}
      {users.map(user => (
        <ExpensiveUserCard 
          key={user.id} 
          user={user} 
          onEdit={(id) => console.log('Edit user:', id)} 
        />
      ))}
    </div>
  );
};

В этом примере при каждом вводе символа в input обновляется состояние searchQuery. Это приводит к повторному рендеру всего компонента UserList. А так как UserList рендерит дочерние ExpensiveUserCard, то каждая карточка пользователя тоже перерисовывается, хотя данные пользователей (user) и функция onEdit не изменились! В консоли мы увидим лавину сообщений «Рендер UserCard для…».

Теперь применим React.memo:

jsx
// Обернем дочерний компонент в React.memo
const ExpensiveUserCard = React.memo(({ user, onEdit }) => {
  console.log(`Рендер UserCard для ${user.name}`);
  return (
    <div>
      <h3>{user.name}</h3>
      <p>{user.email}</p>
      <button onClick={() => onEdit(user.id)}>Редактировать</button>
    </div>
  );
});

Теперь при вводе в input компоненты ExpensiveUserCard не перерисовываются. React видит, что пропсы user и onEdit остались прежними и использует закешированную версию. Рендерятся только UserList и input. Это мгновенно решает нашу проблему.

Когда использовать React.memo?

  • Когда ваш компонент часто рендерится с одними и теми же пропсами.

  • Когда его рендер является «дорогим» (сложные вычисления, большой VDOM-субдерево).

  • Не используйте его для всех компонентов подряд. Сравнение пропсов это тоже операция и для простых компонентов она может быть дороже, чем сам рендер.

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

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

Представьте, что в вашем компоненте есть функция, которая фильтрует и сортирует большой массив данных. Выполнять эту операцию при каждом рендере расточительно, особенно если массив и параметры фильтрации не изменились.

Синтаксис: const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

  • Первый аргумент — функция, которая возвращает значение, которое мы хотим мемоизировать.

  • Второй аргумент — массив зависимостей. Хук пересчитает значение только тогда, когда хотя бы одна из зависимостей изменилась.

Пример без useMemo:

jsx
const ExpensiveComponent = ({ users, filter }) => {
  // Эта тяжелая функция выполняется при КАЖДОМ рендере
  const filteredUsers = users.filter(user => 
    user.name.toLowerCase().includes(filter.toLowerCase())
  ).sort((a, b) => a.name.localeCompare(b.name));

  return (
    <ul>
      {filteredUsers.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
};

Если родитель ExpensiveComponent перерендерится по любой другой причине (не связанной с users или filter), дорогая операция фильтрации и сортировки выполнится снова впустую.

Оптимизируем с помощью useMemo:

jsx
const ExpensiveComponent = ({ users, filter }) => {
  // useMemo запомнит результат вычислений. 
  // Он пересчитает его только если изменился `users` или `filter`.
  const filteredUsers = useMemo(() => {
    console.log('Выполняется тяжелое вычисление...');
    return users
      .filter(user => user.name.toLowerCase().includes(filter.toLowerCase()))
      .sort((a, b) => a.name.localeCompare(b.name));
  }, [users, filter]); // Зависимости

  return (
    <ul>
      {filteredUsers.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
};

Теперь, даже если компонент ExpensiveComponent будет перерендерен, массив filteredUsers будет браться из кеша до тех пор, пока не изменятся users или filter. Это мощный буст для производительности.

Когда использовать useMemo?

  • Для мемоизации результатов тяжелых вычислений (фильтрация, сортировка, математические операции).

  • Когда вы передаете в дочерний компонент (обернутый в React.memo) объект или массив, который создается заново при каждом рендере. useMemo сохранит ссылку на этот объект.

useCallback: мемоизация колбэк-функций

И, наконец, мы подошли к самому, пожалуй, сложному для понимания хуку useCallback. Он является логическим продолжением useMemo, но специализируется на функциях.

Проблема. В JavaScript функции это объекты. При каждом рендере компонента объявление функции внутри него создает новую функцию (новую ссылку в памяти). Посмотрите на наш первый пример с React.memo. Мы передавали в ExpensiveUserCard пропс onEdit. Эта функция создавалась заново при каждом рендере UserList. Для React.memo новая функция, это новый пропс! А значит, мемоизация ломается и наш ExpensiveUserCard все равно будет перерисовываться.

Решение. useCallback мемоизирует саму функцию между рендерами, пока не изменятся зависимости.

Синтаксис. const memoizedCallback = useCallback(() => { doSomething(a, b); }, [a, b]);

  • Первый аргумент — функция, которую мы хотим мемоизировать.

  • Второй аргумент — массив зависимостей. Функция будет пересоздана только при изменении зависимостей.

Вернемся к нашему примеру с UserList и ExpensiveUserCard. Давайте исправим его окончательно, заменив обычную функцию на мемоизированную с useCallback.

jsx
const UserList = () => {
  const [users, setUsers] = useState([...]);
  const [searchQuery, setSearchQuery] = useState('');

  // Раньше было так: 
  // const handleEdit = (id) => { console.log('Edit user:', id); };
  // Эта функция создавалась заново при каждом рендере.

  // Теперь мемоизируем функцию с useCallback.
  // Поскольку функция не зависит от состояния этого компонента, массив зависимостей пуст.
  const handleEdit = useCallback((id) => {
    console.log('Edit user:', id);
  }, []); // Зависимостей нет, функция никогда не пересоздастся.

  // Альтернативный пример: если функция зависит от состояния `users`
  // const handleEdit = useCallback((id) => {
  //   setUsers(prevUsers => prevUsers.filter(u => u.id !== id)); // Используем функциональное обновление, чтобы не добавлять `users` в зависимости
  // }, []); // Теперь `setUsers` стабильна, зависимости не нужны

  return (
    <div>
      <input ... />
      {users.map(user => (
        <ExpensiveUserCard 
          key={user.id} 
          user={user} 
          onEdit={handleEdit} // Теперь эта пропс стабильна между рендерами!
        />
      ))}
    </div>
  );
};

// ExpensiveUserCard обернут в React.memo и теперь не будет перерисовываться из-за функции!
const ExpensiveUserCard = React.memo(({ user, onEdit }) => { ... });

Теперь связка React.memo + useCallback работает идеально! При вводе в input создается новая функция handleEdit? Нет. А значит, для React.memo пропс onEdit не изменился и перерисовки ExpensiveUserCard не происходит.

Когда использовать useCallback?

  • Чаще всего, когда вы передаете колбэк в дочерний компонент, обернутый в React.memo.

  • Когда функция является зависимостью в других хуках (например, в useEffect).

Практические задачи для закрепления

Попробуйте решить эти задачи.

Задача 1: Оптимизируйте список товаров

У вас есть компонент ProductList, который отображает список из 1000 товаров ProductItem. В ProductList есть состояние для счетчика и кнопка его увеличения. При нажатии на кнопку весь список товаров перерисовывается.

  1. Создайте компонент ProductItem, который принимает name и price и выводит их в div. Добавьте console.log внутрь компонента.

  2. Создайте ProductList, который хранит массив товаров и состояние счетчика. Рендерите список ProductItem и кнопку для увеличения счетчика.

  3. Убедитесь, что при клике на кнопку в консоли появляются логи от всех 1000 товаров.

  4. Используйте React.memo для ProductItem. Убедитесь, что логи пропали.

Задача 2: Избегайте повторных вычислений с useMemo

В компоненте Dashboard есть два раздела: статистика и список уведомлений. Статистика вычисляется на основе массива транзакций (transactions) путем сложных вычислений (например, подсчет общей суммы, количества и т.д.).

  1. Создайте состояние для transactions и состояние notifications (простой массив строк).

  2. Создайте функцию calculateStatistics, которая имитирует тяжелые вычисления (добавьте console.log или цикл на 1 млн итераций) и возвращает объект статистики.

  3. Рендерьте статистику и список уведомлений. Добавьте кнопку, которая добавляет новое уведомление в состояние notifications.

  4. Убедитесь, что при добавлении уведомления тяжелая функция calculateStatistics выполняется снова.

  5. Используйте useMemo, чтобы мемоизировать результат calculateStatistics, зависящий только от transactions. Убедитесь, что при добавлении уведомления вычисления не повторяются.

Задача 3: Почините мемоизацию с useCallback

Возьмите решение из Задачи 1. Добавьте в ProductItem кнопку «Добавить в избранное», которая вызывает функцию onAddToFavorites, переданную из ProductList.

  1. Передайте из ProductList в каждый ProductItem пропс onAddToFavorites. Функция может просто логировать id товара.

  2. Убедитесь, что React.memo перестал работать, при клике на счетчик все товары снова перерисовываются.

  3. Используйте useCallback для мемоизации функции onAddToFavorites в ProductList. Убедитесь, что мемоизация компонентов ProductItem снова заработала.

Важные предостережения и итоги

Пожалуйста, не начинайте оборачивать в memouseMemo и useCallback все подряд с самого начала проекта. Это преждевременная оптимизация.

Сначала пишите код так, чтобы он был понятным и работал правильно. Затем, когда вы почувствовали (или измерили с помощью React DevTools Profiler) реальные проблемы с производительностью, применяйте эти хуки точечно.

Главные правила:

  1. React.memo для тяжелых компонентов, которые часто рендерятся с одинаковыми пропсами.

  2. useMemo для сохранения результатов дорогих вычислений или стабильности ссылок на объекты/массивы.

  3. useCallback для сохранения ссылок на функции, которые передаются в оптимизированные дочерние компоненты или являются зависимостями эффектов.

Сегодня вы научились не просто «делать, чтобы работало», а понимать внутренние процессы и управлять ими для создания быстрых и отзывчивых интерфейсов.

На этом наш 27-й урок завершен. Чтобы не пропустить следующие уроки и закрепить знания на практике, посмотрите полный курс по React для начинающих на моем сайте: https://max-gabov.ru/react-dlya-nachinaushih

Удачи в освоении React и до скорых встреч.

Поделиться статьей:
Поддержать автора блога

Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.

Персональные рекомендации
Оставить комментарий