Это перевод статьи, и цель распространения — только показать интересные поинты заинтересовынным людям.

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

Если вы не совсем новичок в React, вы, вероятно, уже знакомы как минимум с хуками useMemo и useCallback. А если вы работаете над приложением среднего или большого масштаба, скорее всего, вы можете описать некоторые части своего приложения как«непонятную цепочку хуков, useMemoкоторую useCallbackневозможно прочитать и отладить». Эти хуки каким‑то образом способны бесконтрольно распространяться по коду, пока полностью не захватят его, и вы обнаружите, что пишете их просто потому, что они повсюду и все вокруг вас их пишут.

А знаете, что самое печальное? Всё это совершенно излишне. Вы, вероятно, можете удалить 90% всего кода useMemoи useCallbacksв вашем приложении прямо сейчас, и приложение будет работать нормально, а может быть, даже немного ускорится. Не поймите меня неправильно, я не говорю, что useMemoили useCallbackбесполезны. Просто их использование ограничено несколькими очень специфическими и конкретными случаями. И в большинстве случаев мы используем их для обертывания чего‑либо без необходимости.

Итак, сегодня я хочу поговорить о том, какие ошибки допускают разработчики при использовании <script> useMemoи useCallback<script>, каково их истинное назначение и как правильно их использовать.

Существует два основных источника распространения ядовитых «крючков» в приложении:

  • Мемоизация объектов для предотвращения повторной отрисовки

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

Мы рассмотрим их позже в статье, но сначала: каково именно назначение useMemoи useCallback?

Зачем нам нужны useMemo и useCallback?

Ответ прост — мемоизация между перерендерами. Если значение или функция обернуты в один из этих хуков, React кэширует их во время первоначального рендеринга и возвращает ссылку на это сохраненное значение при последующих рендерах. Без этого не примитивные значения, такие как массивы, объекты или функции, будут создаваться заново при каждом перерендере. Мемоизация полезна при сравнении таких значений. Это обычный JavaScript:

const a = { "test": 1 };const b = { "test": 1'}; console.log(a === b); // will be false const c = a; // "c" is just a reference to "a" console.log(a === c); // will be true

Или, если это ближе к нашему типичному сценарию использования React:

const Component = () => {  const a = { test: 1 };   useEffect(() => {    // "a" will be compared between re-renders  }, [a]);   // the rest of the code};

aЗначение является зависимостью useEffectхука. При каждом перерендеринге ComponentReact будет сравнивать его с предыдущим значением. aЭто объект, определенный внутри Component, что означает, что при каждом перерендеринге он будет создаваться заново. Поэтому сравнение a«до перерендеринга» с a«после перерендеринга» вернет false, и useEffectбудет запускаться при каждом перерендеринге.

Чтобы этого избежать, мы можем обернуть aзначение в useMemoхук:

const Component = () => {  // preserving "a" reference between re-renders  const a = useMemo(() => ({ test: 1 }), []);   useEffect(() => {    // this will be triggered only when "a" value actually changes  }, [a]);   // the rest of the code};

Теперь useEffectсрабатывание будет происходить только при фактическом изменении значения (то есть, в данной реализации этого никогда не произойдет).

Точно такая же история и с useCallback, только он более полезен для мемоизации функций:

const Component = () => {  // preserving onClick function between re-renders  const fetch = useCallback(() => {    console.log('fetch some data here');  }, []);   useEffect(() => {    // this will be triggered only when "fetch" value actually changes    fetch();  }, [fetch]);   // the rest of the code};

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

Использование мемоизации объектов для предотвращения повторной отрисовки.

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

  1. Пришлось обернуть onClickuseCallbackчтобы предотвратить повторную отрисовку.

const Component = () => {  const onClick = useCallback(() => {    /* do something */  }, []);  return (    <>      <button onClick={onClick}>Click me</button>      ... // some other components    </>  );};
  1. Пришлось обернуть onClickuseCallbackчтобы предотвратить повторную отрисовку.

const Item = ({ item, onClick, value }) => <button onClick={onClick}>{item.name}</button>; const Component = ({ data }) => {  const value = { a: someStateValue };   const onClick = useCallback(() => {    /* do something on click */  }, []);   return (    <>      {data.map((d) => (        <Item item={d} onClick={onClick} value={value} />      ))}    </>  );};
  1. Пришлось заключить valueв тег useMemo, потому что это зависимость мемоизированного элемента onClick:

const Item = ({ item, onClick }) => <button onClick={onClick}>{item.name}</button>; const Component = ({ data }) => {  const value = useMemo(() => ({ a: someStateValue }), [someStateValue]);  const onClick = useCallback(() => {    console.log(value);  }, [value]);   return (    <>      {data.map((d) => (        <Item item={d} onClick={onClick} />      ))}    </>  );};

Вы сами так делали или видели, как это делают другие? Согласны ли вы с описанным сценарием использования и тем, как хук решил проблему? Если ответ на эти вопросы «да», поздравляю: useMemoон useCallbackзахватил вас в заложники и лишил ненужного контроля над вашей жизнью. Во всех приведенных примерах эти хуки бесполезны, излишне усложняют код, замедляют первоначальную отрисовку и ничего не предотвращают.

Чтобы понять почему, нам нужно помнить одну важную вещь о том, как работает React: причины, по которым компонент может перерисовывать себя.

Почему компонент может перерисовывать себя?

«Компонент перерисовывается при изменении состояния или значения свойства» — это общеизвестный факт. Даже в документации React это сформулировано именно так. И я думаю, что именно это утверждение приводит к ложному выводу о том, что «если свойства не изменяются (то есть не кэшируются), то это предотвратит перерисовку компонента».

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

const App = () => {  const [state, setState] = useState(1);   return (    <div className="App">      <button onClick={() => setState(state + 1)}> click to re-render {state}</button>      <br />      <Page />    </div>  );};

AppКомпонент имеет некоторое состояние и несколько дочерних элементов, включая Pageсам компонент. Что произойдет при нажатии на кнопку? Состояние изменится, это вызовет перерисовку приложения, а это, в свою очередь, вызовет перерисовку всех его дочерних элементов, включая Pageкомпонент. У него даже нет свойств!

Теперь, если внутри этого Pageкомпонента есть еще и дочерние элементы:

const Page = () => <Item />;

Совершенно пустой, он не имеет ни состояния, ни свойств. Но его перерисовка будет запускаться при Appперерисовке, и в результате это вызовет перерисовку его Itemдочернего компонента. AppИзменение состояния компонента запускает цепочку перерисовок по всему приложению. Полный пример смотрите в этом CodeSandbox.

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

Мемоизация компонента:

const Page = () => <Item />;const PageMemoized = React.memo(Page);

Использование в приложении при изменении состояния:

const App = () => {  const [state, setState] = useState(1);   return (    ... // same code as before      <PageMemoized />  );};

В этом, и только в этом сценарии, важно, закодированы ли объекты в мемоизацию или нет.

Для наглядности предположим, что Pageу компонента есть onClickсвойство, которое принимает функцию. Что произойдет, если я передам её, Pageне мемоизировав предварительно?

const App = () => {  const [state, setState] = useState(1);  const onClick = () => {    console.log('Do something on click');  };  return (    // page will re-render regardless of whether onClick is memoized or not    <Page onClick={onClick} />  );};

AppПерерисовку выполнит React, который найдет Pageдочерние элементы и перерисует их. onClickНеважно, обернут ли элемент в useCallback или нет.

А если я буду использовать мемоизацию Page?

const PageMemoized = React.memo(Page); const App = () => {  const [state, setState] = useState(1);  const onClick = () => {    console.log('Do something on click');  };  return (    // PageMemoized WILL re-render because onClick is not memoized    <PageMemoized onClick={onClick} />  );};

AppПри перерисовке React обнаружит PageMemoizedв своих дочерних элементах, поймет, что они обернуты в <div> React.memo, остановит цепочку перерисовок и сначала проверит, изменяются ли свойства этого компонента. В этом случае, поскольку <div> onClick— это не мемоизированная функция, результат сравнения свойств будет неверным, и PageMemoizedкомпонент перерисуется. Наконец, вот несколько примеров использования <div> useCallback:

const PageMemoized = React.memo(Page); const App = () => {  const [state, setState] = useState(1);  const onClick = useCallback(() => {    console.log('Do something on click');  }, []);   return (    // PageMemoized will NOT re-render because onClick is memoized    <PageMemoized onClick={onClick} />  );};

Теперь, когда React останавливается PageMemoizedдля проверки своих свойств, onClickони останутся неизменными и PageMemoizedне будут перерисовываться.

Что произойдет, если я добавлю еще одно немемоизованное значение PageMemoized? Ситуация будет точно такой же:

const PageMemoized = React.memo(Page); const App = () => {  const [state, setState] = useState(1);  const onClick = useCallback(() => {    console.log('Do something on click');  }, []);   return (    // page WILL re-render because value is not memoized    <PageMemoized onClick={onClick} value={[1, 2, 3]} />  );};

React останавливается PageMemoized, чтобы проверить свои свойства (props), onClickони останутся прежними, но valueзатем изменятся и PageMemoizedперерисуются. Полный пример можно посмотреть здесь; попробуйте отключить мемоизацию, чтобы увидеть, как всё снова начнёт перерисовываться.

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

Можете смело удалить все операторы useMemoand useCallbacksиз кода, если:

  • Они передавались в качестве атрибутов, напрямую или через цепочку зависимостей, элементам DOM.

  • Они передавались в качестве свойств (props) напрямую или через цепочку зависимостей компоненту, который не кэшируется.

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

Почему нужно удалять, а не просто исправлять мемоизацию? Ну, если бы у вас были проблемы с производительностью из‑за повторной отрисовки в этой области, вы бы уже это заметили и исправили, не так ли? 😉 А поскольку проблем с производительностью нет, нет необходимости это исправлять. Удаление ненужного useMemoупростит useCallbackкод и немного ускорит первоначальную отрисовку, не оказывая негативного влияния на производительность при повторной отрисовке.

Избегание дорогостоящих вычислений при каждом рендеринге

Согласно документации React, основная цель useMemo — избежать ресурсоемких вычислений при каждом рендеринге. Однако, что именно подразумевается под «ресурсоемкими» вычислениями, ничего не объясняется. В результате разработчики иногда оборачивают useMemoпрактически все вычисления в функцию рендеринга. Создать новую дату? Фильтровать, отображать или сортировать массив? Создать объект? useMemoДля всего!

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

const List = ({ countries }) => {  // sorting list of countries here  const sortedCountries = orderBy(countries, 'name', sort);   return (    <>      {sortedCountries.map((country) => (        <Item country={country} key={country.id} />      ))}    </>  );};

Вопрос в том: является ли сортировка массива из 250 элементов ресурсоемкой операцией? Кажется, что да, не так ли? Наверное, нам следует обернуть ее в другой код, useMemoчтобы избежать пересчета при каждом перерендеринге, верно? Что ж, это легко измерить:

const List = ({ countries }) => {  const before = performance.now();   const sortedCountries = orderBy(countries, 'name', sort);   // this is the number we're after  const after = performance.now() - before;   return (    // same  )};

В итоге? Без мемоизации, при шестикратном замедлении работы процессора, сортировка этого массива с примерно 250 элементами занимает менее 2 миллисекунд. Для сравнения, отрисовка этого списка — просто нативных кнопок с текстом — занимает более 20 миллисекунд. В 10 раз больше! Смотрите в codesandbox.

В реальной жизни массив, скорее всего, будет намного меньше, а отображаемый объект — намного сложнее, и, следовательно, будет работать медленнее. Поэтому разница в производительности будет даже больше, чем в 10 раз.

Вместо того чтобы кэшировать операции с массивом, следует кэшировать наиболее ресурсоемкое вычисление — перерисовку и обновление компонентов. Примерно так:

const List = ({ countries }) => {  const content = useMemo(() => {    const sortedCountries = orderBy(countries, 'name', sort);     return sortedCountries.map((country) => <Item country={country} key={country.id} />);  }, [countries, sort]);   return content;};

Это useMemoпозволяет сократить время ненужной перерисовки всего компонента с ~20 мс до менее чем 2 мс.

Учитывая вышесказанное, я хочу ввести следующее правило мемоизации «дорогостоящих» операций: если вы не вычисляете факториалы больших чисел, удалите useMemoхук для всех операций чистого JavaScript. Перерисовка дочерних элементов всегда будет вашим узким местом. Используйте useMemo только для мемоизации ресурсоемких частей дерева рендеринга.

Зачем же удалять? Разве не лучше было бы просто всё кэшировать? Не приведёт ли это к накопительному эффекту, ухудшающему производительность, если мы просто удалим всё? Одна миллисекунда здесь, две там, и вскоре наше приложение будет работать не так быстро, как могло бы…

Вполне справедливое замечание. И это рассуждение было бы на 100% верным, если бы не одно предостережение: мемоизация не дается бесплатно. Если мы используем  useMemo<react>, то во время первоначального рендеринга React необходимо кэшировать значение результата — это занимает время. Да, это будет совсем немного, в нашем приложении выше мемоизация отсортированных стран занимает менее миллисекунды. Но! Это будет настоящий накопительный эффект. Первоначальный рендеринг происходит, когда ваше приложение впервые появляется на экране. Каждый компонент, который должен отобразиться, проходит через него. В большом приложении с сотнями компонентов, даже если треть из них что‑то мемоизирует, это может привести к добавлению 10, 20, а в худшем случае, возможно, даже 100 миллисекунд к первоначальному рендерингу.

Перерисовка, с другой стороны, происходит только после изменения чего‑либо в одной части приложения. И в хорошо спроектированном приложении перерисовывается только эта небольшая часть, а не всё приложение целиком. Сколько «вычислений», подобных описанному выше случаю, будет в этой изменённой части? 2–3? Допустим, 5. Каждая мемоизация сэкономит нам менее 2 миллисекунд, то есть в общей сложности менее 10 миллисекунд. 10 миллисекунд, которые могут произойти, а могут и не произойти (зависит от того, произойдёт ли событие, которое их запускает), которые не видны невооружённым глазом и которые будут потеряны при перерисовке дочерних элементов, которая всё равно займёт в 10 раз больше времени. Ценой замедления первоначальной отрисовки, которая всегда будет происходить 😔.

На сегодня достаточно

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

  • useCallbackЭти useMemoмеханизмы полезны только для последовательных рендеров (то есть повторных рендеров), а для первоначального рендеринга они фактически вредны.

  • useCallbackЧто касается свойств (props), они useMemoсами по себе не предотвращают перерисовку. Перерисовки можно предотвратить только тогда, когда каждое свойство и сам компонент кэшируются. Одна‑единственная ошибка — и всё рушится, а хуки становятся бесполезными. Удаляйте их, если обнаружите.

  • Удалите useMemoоперации, выполняемые непосредственно в «нативном» JavaScript — в отличие от обновлений компонентов, которые невидимы и занимают лишь дополнительную память и ценное время во время первоначальной отрисовки.

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

Пусть этот день станет твоим последним днем ​​в аду из useMemo иuseCallback! ✌🏼

Жду ваши истории в комментариях про переоптимизацию в проектах:)