[Перевод] Понимаем полностью useMemo и useCallback

Экскурсия по двум самым известным хукам в React

Если вы изо всех сил пытались разобраться в useMemo и useCallback, вы не одиноки! Я разговаривал со многими разработчиками React, которые cломали голову над этими двумя хуками.

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

Погнали!

Целевая аудитория

Это руководство написано, чтобы помочь начинающим/мидл разработчикам освоиться с React. Если вы делаете свои первые шаги в React, добавьте этот шаг в закладки и вернитесь к нему через несколько недель!

Основная идея

Окей, давайте начнем с useMemo.

Основная идея useMemo заключается в том, что он позволяет нам «запоминать» вычисленное значение между рендерами.

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

Главное, что делает React, — это синхронизирует наш интерфейс с состоянием нашего приложения. Инструмент, который он использует для этого, называется «ре-рендерингом».

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

b404597acfe8fd360f9dbe30c4988c20.png

Каждый «ре-рендеринг» создает мысленную картину того, как должен выглядеть DOM, исходя из текущего состояния. На картинке выше он изображен в виде HTML, но на самом деле это набор объектов JS. Вы наверняка слышали этот термин, его иногда называют »виртуальным DOM».

Мы не говорим React напрямую, какие узлы DOM необходимо изменить. Вместо этого мы сообщаем React, каким должен быть интерфейс, исходя из текущего состояния. При повторном рендеринге React создает новый снимок и может определить, что нужно изменить, сравнивая снимки, как в игре «найди 10 отличий».

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

Грубо говоря, useMemo и useCallback — это инструменты, созданные для того, чтобы помочь нам оптимизировать ре-рендеринг. Они делают это двумя способами:

  1. Уменьшение объема работы, которую необходимо выполнить при каждом рендеринге.

  2. Уменьшение количества раз, когда компонент вообще необходимо перерисовывать.

Давайте обсудим эти случаи по отдельности.

Use case 1: Тяжелые вычисления

Предположим, мы создаем инструмент, который поможет пользователям найти все простые числа от 0 до selectedNum, где selectedNum — это значение, введенное пользователем. Простое число — это число, которое можно разделить только на 1 и себя, например 17.

Вот одна из возможных реализаций:

Код примера

import React from 'react';

function App() {
  // Мы сохраняем выбранный пользователем номер в состоянии.
  const [selectedNum, setSelectedNum] = React.useState(100);
  
  // Мы вычисляем все простые числа от 0 до выбранного пользователем 
  // числа «selectedNum»:
  const allPrimes = [];
  for (let counter = 2; counter < selectedNum; counter++) {
    if (isPrime(counter)) {
      allPrimes.push(counter);
    }
  }
  
  return (
    <>
      
{ // Чтобы компьютеры не взрывались, мы ограничимся 100 тысячами. let num = Math.min(100_000, Number(event.target.value)); setSelectedNum(num); }} />

There are {allPrimes.length} prime(s) between 1 and {selectedNum}: {' '} {allPrimes.join(', ')}

); } // Вспомогательная функция, которая определяет, // является ли данное число простым или нет. function isPrime(n){ const max = Math.ceil(Math.sqrt(n)); if (n === 2) { return true; } for (let counter = 2; counter <= max; counter++) { if (n % counter === 0) { return false; } } return true; } export default App;

Я не ожидаю, что вы прочтете здесь каждую строку кода, поэтому вот основные моменты:

  • У нас есть одно состояние, число под названием selectedNum.

  • Используя цикл for, мы вручную вычисляем все простые числа от 0 до selectedNum.

  • Мы визуализируем ввод числа, чтобы пользователь мог изменить selectedNum.

  • Мы показываем пользователю все простые числа, которые мы вычислили.

Этот код требует значительного объема вычислений. Если пользователь выберет большое значение selectedNum, нам придется просмотреть десятки тысяч чисел, проверяя, является ли каждое из них простым. И хотя существуют более эффективные алгоритмы проверки простых чисел, чем тот, который я использовал выше, они всегда будут требовать больших вычислительных ресурсов.

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

Например, предположим, что в нашем примере также есть цифровые часы:

import format from 'date-fns/format';

function App() {
  //тот же код
  
  // `time` — это переменная состояния, которая меняется 
  // раз в секунду, поэтому она всегда синхронизируется с текущим временем.
  const time = useTime();
  
  //тот же код
  
  return (
    <>
      

{format(time, 'hh:mm:ss a')}

//... ); } function useTime() { const [time, setTime] = React.useState(new Date()); React.useEffect(() => { const intervalId = window.setInterval(() => { setTime(new Date()); }, 1000); return () => { window.clearInterval(intervalId); } }, []); return time; } //тот же код export default App;

Наше приложение теперь имеет два состояния: selectedNum и time. Раз в секунду переменная времени обновляется, отражая текущее время, и это значение используется для отображения часов в правом верхнем углу.

Вот в чем проблема: всякий раз, когда меняется какая-либо из переменных состояния, мы заново запускаем все эти дорогостоящие вычисления простых чисел. А поскольку время меняется раз в секунду, это означает, что мы постоянно заново генерируем этот список простых чисел, даже если выбранное пользователем число не изменилось!

954d7a642af7aa047d2cdf1294859817.png

В JavaScript у нас есть только один основной поток, и мы постоянно его нагружаем, запуская этот код снова и снова, каждую секунду. Это означает, что приложение может работать медленно, когда пользователь пытается делать другие вещи, особенно на устройствах более низкого уровня.

Но что, если бы мы могли «пропустить» эти вычисления? Если у нас уже есть список простых чисел для данного числа, почему бы не использовать это значение повторно вместо того, чтобы каждый раз вычислять его с нуля?

Именно это позволяет нам сделать useMemo. Вот как это выглядит:

const allPrimes = React.useMemo(() => {
  const result = [];
  for (let counter = 2; counter < selectedNum; counter++) {
    if (isPrime(counter)) {
      result.push(counter);
    }
  }
  return result;
}, [selectedNum]);

useMemo принимает два аргумента:

  1. Кусок кода, который необходимо выполнить, завернутый в функцию

  2. Список зависимостей

Во время монтирования, когда этот компонент отображается в первый раз, React вызывает эту функцию для запуска всей логики, вычисляя простые числа. Все, что мы возвращаем из этой функции, присваивается переменной allPrimes.

Теперь для каждого последующего рендеринга у React есть выбор:

  1. Вызвать функцию еще раз, чтобы пересчитать значение, или

  2. Повторно использовать данные с момента последнего выполнения.

Чтобы ответить на этот вопрос, React просматривает предоставленный список зависимостей. Изменилось ли что-нибудь из них со времени предыдущего рендера? Если да, React перезапустит функцию, чтобы вычислить новое значение. В противном случае вся эта работа будет пропущена и будет повторно использовано ранее вычисленное значение.

useMemo по сути похож на небольшой кеш, а зависимости — это стратегия изменения этого кеша.

В этом случае мы, по сути, говорим:»пересчитывать список простых чисел только при изменении selectedNum». Когда компонент ре-рендерится по другим причинам (например, из-за изменения состояния времени), useMemo игнорирует функцию и передает кэшированное значение.

Это широко известно как мемоизация, и именно поэтому этот хук называется »useMemo».

Вот рабочая версия этого решения:

Полная версия кода

import React from 'react';
import format from 'date-fns/format';

function App() {
  const [selectedNum, setSelectedNum] = React.useState(100);
  const time = useTime();
  
  const allPrimes = React.useMemo(() => {
    const result = [];
    
    for (let counter = 2; counter < selectedNum; counter++) {
      if (isPrime(counter)) {
        result.push(counter);
      }
    }
    
    return result;
  }, [selectedNum]);
  
  return (
    <>
      

{format(time, 'hh:mm:ss a')}

{ // To prevent computers from exploding, // we'll max out at 100k let num = Math.min(100_000, Number(event.target.value)); setSelectedNum(num); }} />

There are {allPrimes.length} prime(s) between 1 and {selectedNum}: {' '} {allPrimes.join(', ')}

); } function useTime() { const [time, setTime] = React.useState(new Date()); React.useEffect(() => { const intervalId = window.setInterval(() => { setTime(new Date()); }, 1000); return () => { window.clearInterval(intervalId); } }, []); return time; } function isPrime(n){ const max = Math.ceil(Math.sqrt(n)); if (n === 2) { return true; } for (let counter = 2; counter <= max; counter++) { if (n % counter === 0) { return false; } } return true; } export default App;

Альтернативный подход

Итак, хук useMemo может помочь нам избежать ненужных вычислений…, но действительно ли это лучшее решение?

Часто мы можем избежать необходимости использования useMemo путем реструктуризации нашего приложения.

Вот один из способов:

import React from 'react';

import Clock from './Clock';
import PrimeCalculator from './PrimeCalculator';

function App() {
  return (
    <>
      
      
    
  );
}

export default App;

Я извлек два новых компонента: Clock и PrimeCalculator. Отделившись от App, каждый из этих двух компонентов управляет своим собственным состоянием. Повторный рендеринг одного компонента не повлияет на другой.

Мы много слышим о поднятии состояния, но иногда лучший подход — передать наш стэйт вниз! Каждый компонент должен нести одну ответственность, и в примере выше приложение делало две совершенно несвязанные вещи.

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

У меня есть еще один трюк на этот случай.

Давайте посмотрим на пример. Предположим, нам нужно, чтобы переменная времени была поднята выше PrimeCalculator:

import React from 'react';
import { getHours } from 'date-fns';

import Clock from './Clock';
import PrimeCalculator from './PrimeCalculator';

// Превращаем наш PrimeCalculator в чистый компонент:
const PurePrimeCalculator = React.memo(PrimeCalculator);

function App() {
  const time = useTime();

  // Придумываем подходящий цвет фона, исходя из времени суток:
  const backgroundColor = getBackgroundColorFromTime(time);

  return (
    
); } const getBackgroundColorFromTime = (time) => { const hours = getHours(time); if (hours < 12) { // A light yellow for mornings return 'hsl(50deg 100% 90%)'; } else if (hours < 18) { // Dull blue in the afternoon return 'hsl(220deg 60% 92%)' } else { // Deeper blue at night return 'hsl(220deg 100% 80%)'; } } function useTime() { const [time, setTime] = React.useState(new Date()); React.useEffect(() => { const intervalId = window.setInterval(() => { setTime(new Date()); }, 1000); return () => { window.clearInterval(intervalId); } }, []); return time; } export default App;

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

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

Более традиционный подход

В примере выше я применяю React.memo к импортированному компоненту PrimeCalculator.

По правде говоря, это немного необычно. Я решил структурировать его так, чтобы все было видно в одном файле и было легче понять.

На практике я часто применяю React.memo для экспорта компонентов, например:

// PrimeCalculator.js
function PrimeCalculator() {
  /* Наполнение компонента */
}
export default React.memo(PrimeCalculator);

Наш компонент PrimeCalculator теперь всегда будет »чистым», и нам не придется с ним возиться, когда мы соберёмся его использовать.

Если нам когда-нибудь понадобится »нечистая» версия PrimeCalculator, мы сможем экспортировать компонент как именованный экспорт. Хотя я не думаю, что мне когда-либо приходилось так делать.

Здесь интересный сдвиг перспективы: раньше мы запоминали результат определенного вычисления, однако в данном случае я запомнил весь компонент.

В любом случае, дорогостоящие вычисления будут выполняться повторно только тогда, когда пользователь выберет новый selectedNum. Но мы оптимизировали родительский компонент, а не отдельные медленные строки кода.

Я не утверждаю, что один подход лучше другого — каждому инструменту есть свое место. Но в данном конкретном случае я предпочитаю именно такой подход.

Теперь, если вы когда-либо пытались использовать чистые компоненты в реальных условиях, вы, вероятно, заметили одну особенность: чистые компоненты часто перерисовываются, даже если кажется, что ничего не изменилось!

Это плавно подводит нас ко второй проблеме, которую решает useMemo.

Use case 2: Сохраненные ссылки

В примере ниже я создал компонент Boxes. В нем отображен набор ярких коробок, которые можно использовать в каких-то декоративных целях.

А также несвязанное состояние — имя пользователя.

Код

App.js

import React from 'react';

import Boxes from './Boxes';

function App() {
  const [name, setName] = React.useState('');
  const [boxWidth, setBoxWidth] = React.useState(1);
  
  const id = React.useId();
  
  // Попробуйте поменять какие-нибудь из этих значений!
  const boxes = [
    { flex: boxWidth, background: 'hsl(345deg 100% 50%)' },
    { flex: 3, background: 'hsl(260deg 100% 40%)' },
    { flex: 1, background: 'hsl(50deg 100% 60%)' },
  ];
  
  return (
    <>
      
      
      
{ setName(event.target.value); }} /> { setBoxWidth(Number(event.target.value)); }} />
); } export default App;

Boxes.js

import React from 'react';

function Boxes({ boxes }) {
  return (
    
{boxes.map((boxStyles, index) => (
))}
); } export default React.memo(Boxes);

Boxes — это чистый компонент благодаря использованию React.memo() вокруг экспорта по умолчанию в Boxes.js. Это означает, что он должен выполнять повторный рендеринг только при изменении реквизитов.

И тем не менее, всякий раз, когда пользователь меняет свое имя, Boxes также перерисовывается!

Что за черт?! Почему силовое поле React.memo() не защищает нас здесь?

Компонент Boxes имеет только один проп — boxes, и создается впечатление, что мы передаем ему одни и те же данные при каждом рендеринге. Всегда одно и то же: красная коробка, широкая фиолетовая коробка, желтая коробка. У нас есть переменная состояния boxWidth, которая влияет на массив блоков, но мы ее не меняем!

Вот в чем проблема: каждый раз, когда React выполняет повторный рендеринг, мы создаем совершенно новый массив. Они эквивалентны с точки данных, но не с точки зрения ссылки.

Я думаю, будет полезно на секунду забыть о React и поговорить о старом добром JavaScript. Давайте рассмотрим аналогичную ситуацию:

function getNumbers() {
  return [1, 2, 3];
}
const firstResult = getNumbers();
const secondResult = getNumbers();
console.log(firstResult === secondResult);

Что вы думаете? Равен ли первый результат второму?

В каком-то смысле так оно и есть. Обе переменные имеют одинаковую структуру [1, 2, 3]. Но это не то, что на самом деле проверяет оператор ===.

Оператор === проверяет, являются ли два выражения одним и тем же.

Мы создали два разных массива. Они могут содержать одно и то же содержимое, но не являются одним и тем же массивом, точно так же, как два идентичных близнеца не являются одним и тем же человеком.

78b9671e2237046212dd0eb0064640e0.png

Каждый раз, когда мы вызываем функцию getNumbers, мы создаем совершенно новый массив, отдельный объект, хранящийся в памяти компьютера. Если мы вызовем его несколько раз, мы сохраним в памяти несколько копий этого массива.

Обратите внимание, что простые типы данных — такие как строки, числа и логические значения — можно сравнивать по значению. Но когда дело доходит до массивов и объектов, они сравниваются только по ссылке.

Возвращаясь к React: наш компонент Boxes также является функцией JavaScript. Когда мы его визуализируем, мы вызываем эту функцию:

// Каждый раз, когда мы отображаем этот компонент, мы вызываем эту функцию...
function App() {
  // ...и создаём совершенно новый массив...
  const boxes = [
    { flex: boxWidth, background: 'hsl(345deg 100% 50%)' },
    { flex: 3, background: 'hsl(260deg 100% 40%)' },
    { flex: 1, background: 'hsl(50deg 100% 60%)' },
  ];
  // ...который затем передается в качестве аргумента этому компоненту!
  return (
    
  );
}

Когда состояние name меняется, наш компонент выполняет ре-рендеринг, что повторно запускает весь код. Мы создаем новый массив boxes и передаем его в наш компонент Boxes.

И Boxes перерисовывается, потому что мы дали ему совершенно новый массив!

Структура массива boxes не менялась между рендерами, но это не имеет значения. Все, что знает React, это то, что проп boxes получил только что созданный, никогда ранее не виданный массив.

Чтобы решить эту проблему, мы можем использовать хук useMemo:

const boxes = React.useMemo(() => {
  return [
    { flex: boxWidth, background: 'hsl(345deg 100% 50%)' },
    { flex: 3, background: 'hsl(260deg 100% 40%)' },
    { flex: 1, background: 'hsl(50deg 100% 60%)' },
  ];
}, [boxWidth]);

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

Мы указываем boxWidth как зависимость, потому что хотим, чтобы компонент Boxes ре-рендерился, когда пользователь настраивает ширину красного поля.

Думаю, небольшой набросок поможет проиллюстрировать это. Раньше мы создавали новый массив как часть каждого снимка:

cf9cfcd8f19e38f8c0d2ea023e00daaf.png

Однако с помощью useMemo мы повторно используем ранее созданный массив блоков:

1940e3b7f2f1a6453ea66d2d715141f9.png

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

Хук useCallback

Окей, это всё касалось UseMemo…, а что насчет useCallback?

Вот короткая версия: это то же самое, но для функций вместо массивов/объектов.

Подобно массивам и объектам, функции сравниваются по ссылке, а не по значению:

const functionOne = function() {
  return 5;
};
const functionTwo = function() {
  return 5;
};
console.log(functionOne === functionTwo); // false

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

Давайте посмотрим на пример:

import React from 'react';

import MegaBoost from './MegaBoost';

function App() {
  const [count, setCount] = React.useState(0);

  function handleMegaBoost() {
    setCount((currentValue) => currentValue + 1234);
  }

  return (
    <>
      Count: {count}
      
      
    
  );
}

export default App;

Этот SandBox изображает типичное приложение-счетчик, но со специальной кнопкой «Mega Boost». Эта кнопка значительно увеличивает счетчик, на тот случай, если вы спешите и не хотите нажимать стандартную кнопку несколько раз.

Компонент MegaBoost является чистым компонентом благодаря React.memo. Это не зависит от счётчика… Но оно перерисовывается всякий раз, когда количество меняется!

Как мы видели в случае с массивом boxes, проблема здесь в том, что мы генерируем совершенно новую функцию при каждом рендере. Если мы отрисуем 3 раза, мы создадим 3 отдельные функции handleMegaBoost, прорываясь через силовое поле React.memo.

Используя то, что мы узнали об useMemo, мы могли бы решить проблему следующим образом:

const handleMegaBoost = React.useMemo(() => {
  return function() {
    setCount((currentValue) => currentValue + 1234);
  }
}, []);

Вместо возврата массива мы возвращаем функцию. Эта функция затем сохраняется в переменной handleMegaBoost.

Это работает…, но есть способ получше:

const handleMegaBoost = React.useCallback(() => {
  setCount((currentValue) => currentValue + 1234);
}, []);

useCallback служит той же цели, что и useMemo, но он создан специально для функций. Мы передаем ему функцию напрямую, и он запоминает эту функцию, распределяя ее между рендерами.

Другими словами, эти два выражения имеют одинаковый эффект:

// Это:
React.useCallback(function helloWorld(){}, []);
// ...Функционально эквивалентно этому:
React.useMemo(() => function helloWorld(){}, []);

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

Когда использовать эти хуки

Хорошо, мы увидели, как useMemo и useCallback позволяют нам прокидывать ссылки между несколькими рендерами, повторно использовать сложные вычисления или избегать нарушения работы чистых компонентов. Вопрос в том, как часто нам нужно их использовать?

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

Лучший способ использовать эти хуки — это реакция на проблему. Если вы заметили, что ваше приложение становится немного медленным, вы можете использовать React Profiler, чтобы отслеживать медленный рендеринг. В некоторых случаях производительность можно повысить путем реструктуризации приложения. В других случаях useMemo и useCallback могут помочь ускорить процесс.

Тем не менее, есть несколько сценариев, в которых я заранее применяю эти хуки.

Это может измениться в будущем!

Команда React активно исследует, возможно ли «авто-мемоизация» кода на этапе компиляции. Он все еще находится на стадии исследования, но ранние эксперименты кажутся многообещающими.

Возможно, в будущем все это будет делаться за нас автоматически. Однако до тех пор нам все равно придется что-то оптимизировать самостоятельно.

Внутри кастомных хуков

Один из моих любимых маленьких пользовательских хуков — useToggle, помощник, который работает почти так же, как useState, но может только переключать состояние между true и false:

function App() {
  const [isDarkMode, toggleDarkMode] = useToggle(false);
  return (
    
  );
}

Вот как определяется этот хук:

function useToggle(initialValue) {
  const [value, setValue] = React.useState(initialValue);
  const toggle = React.useCallback(() => {
    setValue(v => !v);
  }, []);
  return [value, toggle];
}

Обратите внимание, что функция переключения запоминается с помощью useCallback.

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

В провайдерах внутреннего контекста

Когда мы делимся данными в приложении с помощью контекста, обычно в качестве атрибута передается большой объект.

Рекомендую запомнить этот объект:

const AuthContext = React.createContext({});
function AuthProvider({ user, status, forgotPwLink, children }){
  const memoizedValue = React.useMemo(() => {
    return {
      user,
      status,
      forgotPwLink,
    };
  }, [user, status, forgotPwLink]);
  return (
    
      {children}
    
  );
}

Почему это выгодно? Могут существовать десятки чистых компонентов, использующих этот контекст. Без useMemo все эти компоненты будут вынуждены выполнить ре-рендер, если родительский элемент AuthProvider произведет ре-рендер.

Уф! Вы дошли до конца. Я знаю, что этот урок охватывает довольно непростую тему.

Я знаю, что эти два хука сложны, что сам React может показаться очень сложным и запутанным. Это сложный инструмент!

Но вот в чем дело: если вы сможете преодолеть первоначальный барьер, использовать React будет сплошное удовольствие.

Habrahabr.ru прочитано 12930 раз