Мемоизация в React: я почитал документацию вместо вас
- воскресенье, 17 марта 2024 г. в 00:00:13
В статье рассмотрены три инструмента мемоизации в React: useMemo, useCallback, memo. Главный источник информации: документация React. Не всем комфортно работать напрямую с документацией, так что если вы постоянно откладываете погружение в документацию React - я сделал это за вас, постарался выделить самое важное, и дать ссылки для углубленного погружения.
Так как мы будем рассматривать не самые базовые вещи, касающиеся React, то я не буду останавливаться на таких основах как хуки, состояние, свойства, чистые функции и чистые компоненты, ожидая, что вы ознакомитесь с ними за пределами статьи. А также все рассмотренное ниже относится в первую очередь к React 18.
useMemo это React хук, который позволяет кэшировать результаты вычислений между ре-рендерами (re-rendering).
На практике это значит следующее: когда происходит первичный рендер вашего компонента, то результат вычислений useMemo помещается в кэш, и при последующих рендерах, если соблюдены определенные условия, эти данные не вычисляются заново, а достаются из кэша.
Хук принимает два параметра:
функцию, которая вычисляет значение, которое нужно кэшировать. Функция должна быть чистой, не принимать аргументов и обязательно возвращать значение.
массив всех значений, которые используются в вычислении и могут меняться. Это могут быть свойства (props), состояния (state), переменные и функции, объявленые непосредственно внутри вашего компонента.
const cachedValue = useMemo(calculateValue, dependencies)
Если изменились значения которые указаны в массиве зависимостей, то произойдет повторное вычисление значения.
Важно понимать, как именно React определяет, что произошли изменения. Он берет каждое значение и сравнивает его с предыдущим используя метод Object.is. Таблица ниже показывает как работает этот метод в сравнении со строгим равно (“===”), я привел тут только отличающееся поведение, всю таблицу вы можете посмотреть тут:
x | y | === | |
+0 | -0 | ✅ true | ❌ false |
-0 | 0 | ✅ true | ❌ false |
NaN | NaN | ❌ false | ✅ true |
Теперь, если вы видите неожиданное поведение useMemo, то в первую очередь сравните предыдущее значение из массива зависимостей с текущим используя Object.is. Проще всего это сделать выведя в консоль сначала первое значение, и сохранить его как глобальную переменную в браузере (Store as a global variable), она по умолчанию сохраниться под названием temp1, потом сохранить новое значение в глобальную переменную (temp2), и сравнить их через Object.is.
Object.is(temp1[0], temp2[0]); // Одинакова ли первая зависимость между ре-рендерами?
Object.is(temp1[1], temp2[1]); // Вторая?
Object.is(temp1[2], temp2[2]); // и так для каждой зависимости
Есть несколько правил, подчеркнутых в документации React которых следует придерживаться, чтобы useMemo вел себя в соответствии с задумкой:
к useMemo относятся стандартные правила использования хуков: не помещать в конструкцию с условием, не помещать в циклы. Обычно это формулируется как “поместите хук в самый верх своего компонента”.
функция переданная в качестве первого аргумента должна быть чистой, не принимать аргументов и всегда иметь возвращаемое значение.
useMemo должен всегда содержать массив зависимостей, даже если он пустой. Без массива зависимостей он будет выполнятся при каждом рендере.
Существует всего три причины использовать этот хук:
Уменьшение количества сложных вычислений.
Уменьшение количества повторных рендеров компонентов.
Мемоизация значения, которое используется в зависимостях другого хука.
Кажется, что эта причина - основной сценарий использования хука useMemo, но на моей практике эта причина чуть ли не самая редкая. Дело в том, как именно определить эту “сложность”. Универсального ответа на этот вопрос нет, чаще всего каждая команда договаривается, что она считает признаком “достаточно сложных” вычислений. Например в документации React так определяется сложность, хоть и очень не настойчиво:
“Если общее зарегистрированное время (вычислений прим. автора) составляет значительную величину (скажем, 1 мс или более), возможно, имеет смысл кэшировать это вычисление.”
Так что тут вы можете опираться на свое представление о том, в какой момент вычисление достаточно дорогостоящее, чтобы закешировать его. При этом будьте готовы к тому, что это всегда будет дискуссионный вопрос, и ваш коллега может вас спросить о необходимости оптимизации, потому что его представления могут отличаться.
Также стоит отметить, что в документации большой упор делается на утверждение:
“Довольно часто код без мемоизации работает нормально. Если ваше взаимодействие происходит достаточно быстро, мемоизация может вам и не понадобиться.”
Эта причина использования useMemo мне видится самой частой. Чтобы понять в чем суть ее использования, мы немного поговорим о функции memo. memo помогает избежать повторного рендеринга компонентов, детально мы разберем его ниже, тут скажем только необходимое.
memo похож на useMamo в части механизма, определяющего необходимо рендерить компонент заново, а не использовать его предыдущее состояние. Он сравнивает предыдущее значение свойства с новым значением используя Object.is. Когда Object.is сравнивает массивы, объекты или функции, то сравнивается не их содержание, а указывают ли они на один и тот же участок в памяти.
Если вы передаёте в дочерний компонент (обёрнутый в memo) переменную или функцию, объявленую в теле родительского компонента, то для дочернего компонента это будет так называемое "всегда новое" ("always new") свойство. В контексте работы с useMemo, useCallback и memo "всегда новое" - это такая зависимость, которая будет вызывать пересчет кэшированного значения при каждом повторном рендере, делая эти инструменты бессмысленными.
Как в этом случае может помочь useMemo? Дело в том, что если массив, объект или функцию положить в кэш используя useMemo или useCallback, и передать его как свойство в дочерний компонент обернутый в memo, то кэшированное значение будет указывать на один и тот же участок в памяти, между повторными рендерами.
import { memo } from 'react';
const List = memo(function List({ items }) {
// ...
});
export default function TodoList({ todos, tab, theme }) {
// используйте useMemo, чтобы данные кэшировались между повторными рендерингами...
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab] // ...до тех пор, пока зависимости не изменятся...
);
return (
<div className={theme}>
{/* ...List получит одинаковые свойства и пропустит ре-рендер */}
<List items={visibleTodos} />
</div>
);
}
Из этого следует, что оборачивать значение, которое будет передано в качестве свойства в дочерний компонент в useMemo, с целью снижения количества повторных рендеров, стоит только если этот дочерний компонент обернут в memo - иначе это не имеет смысла. Это важно помнить, когда вы работаете с компонентами из внешних библиотек, в которых компонент может быть не обернут в memo.
Ответ на главный вопрос: “как понять, что необходимо оптимизировать повторные рендеры” здесь аналогичен предыдущему - это зависит от решения разработчика или команды.
У вас может возникнуть желание по умолчанию оборачивать все в useMemo, вот что говорится в документации по этому поводу:
“В других случаях (кроме описанных выше прим. автора) нет смысла заключать вычисления в useMemo. В этом нет никакого существенного вреда, поэтому некоторые команды предпочитают не думать об отдельных случаях и мемоизировать как можно больше. Недостатком этого подхода является то, что код становится менее читабельным. Кроме того, не всякая мемоизация эффективна: одного «всегда нового» значения достаточно, чтобы нарушить мемоизацию всего компонента.”
Третья причина использования useMemo тоже достаточно популярна на практике. Ситуация такова: у вас есть значение объявленное через обычную переменную в теле компонента, и есть хук у которого в зависимостях лежит эта переменная.
function Dropdown({ allItems, text }) {
const searchOptions = { matchMode: 'whole-word', text };
const visibleItems = useMemo(() => {
return searchItems(allItems, searchOptions);
}, [allItems, searchOptions]); // 🚩 Внимание: зависимость от объекта, созданного в теле компонента.
// ...
И для того, чтобы ваш хук, в зависимостях которого лежит переменная не выполнялся всякий раз, когда эта переменная объявляется заново (а это будет происходить при каждом повторном рендере), у вас может появится желание мемоизировать эту переменную.
function Dropdown({ allItems, text }) {
const searchOptions = useMemo(() => {
return { matchMode: 'whole-word', text };
}, [text]); // ✅ Изменяется только при изменении текста
const visibleItems = useMemo(() => {
return searchItems(allItems, searchOptions);
}, [allItems, searchOptions]); // ✅ Изменяется только при изменении allItems или searchOptions.
// ...
Такое решение рабочее, но основная позиция обозначенная в документации - что с большой долей вероятности возможно так изменить код, что пропадет сама необходимость добавлять такую переменную в зависимости, или пропадет сам эффект, которому необходима эта зависимость. Вот как можно изменить этот код, чтобы избежать зависимости:
function Dropdown({ allItems, text }) {
const visibleItems = useMemo(() => {
const searchOptions = { matchMode: 'whole-word', text };
return searchItems(allItems, searchOptions);
}, [allItems, text]); // ✅ Изменяется только при изменении allItems или текста.
// ...
Подробно о способах уменьшить количество эффектов и зависимостей написано тут.
Необходимо понимать, что кэш, в котором хранится ваше вычисленное значение может быть очищен, и нужно знать при каких условиях это происходит.
Во-первых, в режиме разработки кэш будет очищен при hot-reload. Это никак не влияет на поведение React в проде.
Во-вторых, если вы используете механизм приостановки для ожидания данных, то кэш тоже будет очищен. Приостановка может произойти, например, если ваш компонент использует механизм "suspense" для ожидания данных или других ресурсов перед тем, как он будет полностью отрисован.
Получается, что обычно поведение выглядит так:
Первичный рендер: вычисление значения в useMemo.
Все последующие рендеры: значение берется из кэша, если зависимости не изменились.
А если вы используете lazy-загрузку, то поведение будет выглядеть так:
Первичный рендер: вычисление значения для useMemo.
Приостановка: удаление кэша для таких компонентов.
Первый рендер после приостановки: вычисления значения для useMemo.
Все последующие рендеры: значение берется из кэша, если зависимости не изменились.
Вы можете самостоятельно проверить работу мемоизации в связке в Suspense, я написал небольшое демо, чтобы вы могли “потрогать” это руками.
Третий нюанс, который полезно знать, в режиме разработки, если используется строгий режим, то React дважды вызовет вашу функцию в хуке. Это также не влияет на поведение React в проде.
useCallback это React хук, который позволяет кэшировать функцию между повторными отрисовками.
useCallback принимает два параметра:
функцию, которую необходимо закэшировать;
массив зависимостей, при изменении которых необходимо переопределить функцию.
const cachedFn = useCallback(fn, dependencies)
Попробуем посмотреть на useCallback через призму отличий от useMemo. Механизм работы useMemo и useCallback идентичны, отличаются они только тем - что они кэшируют. useMemo кэширует результат вычисления функции, которую вы передали в качестве первого аргумента, useCallback кэширует саму функцию, которую вы передали в качестве первого аргумента.
Чтобы еще больше понять степень сходства и главные отличия хуков, документация предлагает думать о useCallback, как о частном случае useMemo:
function useCallback(fn, dependencies) {
return useMemo(() => fn, dependencies);
}
React не вызывает переданную в useCallback функцию, он просто сохраняет ее в кэш, а вы можете вызвать ее в любой момент, как обычную функцию.
Как и с useMemo стоит придерживаться нескольких правил:
не использовать внутри условий и циклов;
всегда передавать массив зависимостей, даже если он пустой;
передаваемая в useCallback функция может принимать любые аргументы и возвращать любые значения (в отличие от useMemo, гду функция принимать аргменты не должна).
Сравнение значений в массиве зависимостей происходит аналогично с использованием Object.is
уменьшение количества повторных рендеров компонента;
предотвращение слишком частого срабатывания эффекта;
оптимизация пользовательского хука.
Все, что описано для хука useMemo в части снижения количества повторных рендеров дочерних компонентов, которые обернуты в memo, и принимают некоторое значение как свойство - справедливо и для кэширования функции используя useCallback.
На практике случается ситуация, когда ваша функция будет передана как свойство в дочерний компонент, который вызывается внутри цикла:
const Chart = memo(function Chart({onClick}) {
return (
<div onClick={onCLick}>
...
</div>
);
function ReportList({ items }) {
const handleClick = useCallback((item) => {
sendReport(item)
}, [item]);
return (
<article>
{items.map(item => {
return (
<figure key={item.id}>
//🔴 такой вызов лишает мемоизацию всякого смысла
<Chart onClick={() => handleClick(item)} />
</figure>
);
})}
</article>
);
}
Важно помнить два момента:
Компонент, в который передается функция обернутая в useCallback должен быть обернут в memo.
Передаваться должна сама функция, а не callback внутри которого вызывается мемоизированная функция.
Вот такой код лишает мемоизацию смысла:
...
<Chart onClick={() => handleClick(item)} />
...
В данном случае необходимо передать аргумент, который принимает функция как еще одно свойство:
const Chart = memo(function Chart({item, onClick}) {
return (
<div onClick={() => onClick(item)}>
...
</div>
);
function ReportList({ items }) {
const handleClick = useCallback((item) => {
sendReport(item)
}, [item]);
return (
<article>
{items.map(item => {
return (
<figure key={item.id}>
//✅ теперь useCallback имеет смысл
<Chart onClick={handleClick} item={item} />
</figure>
);
})}
</article>
);
}
Хочу еще раз повторить мысль, которая часто встречается в документации:
Если ваше приложение похоже на сайт react.dev и большинство взаимодействий являются простыми (например, переход по страницам или разделам), мемоизация обычно не нужна. С другой стороны, если ваше приложение больше похоже на редактор рисунков, а большинство взаимодействий являются сложными (например, перемещение фигур), то мемоизация может оказаться очень полезной.
Представьте, что у вас есть функция, которую вы вызываете внутри useEffect, и если эта функция объявлена без кэширования, и передана в качестве зависимости, то useEffect будет срабатывать при каждой повторной отрисовке, так-как функция будет “всегда новым” значением. Именно в данном случае возможно использование useCallback:
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
function createOptions() {
return {
serverUrl: 'https://localhost:1234',
roomId: roomId
};
}
useEffect(() => {
const options = createOptions();
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, [createOptions]); // 🔴 Проблема: эта зависимость меняется при каждом рендеринге.
Решение:
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
const createOptions = useCallback(() => {
return {
serverUrl: 'https://localhost:1234',
roomId: roomId
};
}, [roomId]); // ✅ Изменяется только при изменении roomId
useEffect(() => {
const options = createOptions();
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, [createOptions]); // ✅ Срабатывает только при изменении createOptions
// ...
Но опять же, в документации делается упор на то, что вы скорее должны искать способ отказаться от зависимости, а не кэшировать ее. В данном случае - переместить объявление функции внутрь самого useEffect.
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
useEffect(() => {
function createOptions() { // ✅ Нет необходимости использовать useCallback или зависимости от функций!
return {
serverUrl: 'https://localhost:1234',
roomId: roomId
};
}
const options = createOptions();
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ Срабатывает только при изменении roomId
// ...
Этот случай использования хука useCallback является единственным, в котором в документации прямо рекомендовано его использование:
“Если вы пишете собственный хук, рекомендуется обернуть все возвращаемые функции в useCallback.”
function useRouter() {
const { dispatch } = useContext(RouterStateContext);
const navigate = useCallback((url) => {
dispatch({ type: 'navigate', url });
}, [dispatch]);
const goBack = useCallback(() => {
dispatch({ type: 'back' });
}, [dispatch]);
return {
navigate,
goBack,
};
}
Обоснованность такого подхода в том, что вы даете пользователям вашего кастомного хука базовую возможность по оптимизации их компонентов, внутри которых ваш хук будет использован.
Все технические нюансы касательно строгого режима или очистки кэша справедлива также для useCallback.
Также есть частный случай, в котором вы можете использовать useCallback не оптимально. Если вы используете внутри функции, которая кэшируется с помощью useCallback, состояние компонента, для целей формирования нового состояния, то НЕ передавайте в список зависимостей состояние, а используйте вместо этого функцию updater.
То есть, вот такой код
function TodoList() {
const [todos, setTodos] = useState([]);
const handleAddTodo = useCallback((text) => {
const newTodo = { id: nextId++, text };
setTodos([...todos, newTodo]);
}, [todos]);
// ...
Лучше переписать вот так:
function TodoList() {
const [todos, setTodos] = useState([]);
const handleAddTodo = useCallback((text) => {
const newTodo = { id: nextId++, text };
setTodos(todos => [...todos, newTodo]);
}, []); // ✅ todos не нужен в массиве зависимостей
// ...
memo - это функция высшего порядка, которая позволяет пропустить повторный рендеринг компонента, если его свойства не изменились.
На вход функция memo принимает React компонент, вторым необязательным аргументом является коллбэк, который дает вам возможность написать собственные правила сравнения предыдущих и новых значений свойств, чтобы решить выполнять повторный рендер или нет.
const MemoizedComponent = memo(SomeComponent, arePropsEqual?)
При этом в документации говорится:
“Оберните компонент в memo, чтобы получить мемоизированную версию этого компонента. Эта мемоизированная версия вашего компонента обычно не будет повторно рендериться при повторном рендеринге его родительского компонента, если его свойства не изменились. Но React все равно может его перерисовать: мемоизация — это оптимизация производительности, а не гарантия.”
Компонентом, который memo принимает на вход может быть любой валидный React компонент, в том числе обернутый в forvardRef.
Функция, которая может быть передана вторым аргументом условно называется “arePropsEqual”. Эта функция принимает два аргумента: предыдущие свойства компонента и его новые свойства. Эта функция должна вернуть true - чтобы не перерисовывать компонент, или false, если компонент нужно перерисовать. Опять же, важное уточнение из документации:
“Обычно вы не указываете эту функцию. По умолчанию React сравнивает каждое свойство используя Object.is.”
type ArePropsEqualFunction = (oldProps: Props, newProps: Props) => boolean;
Уменьшение повторного рендеринга компонента.
Использование кастомной функции сравнения.
React обычно ре-рендерит компонент каждый раз, когда ре-рендериться его родитель, это стандартное поведение. Если вы решили, что отрисовка конкретного компонента слишком дорогая, и стоит ее избегать при перерисовке родителя, то можно обернуть этот компонент в memo:
const Greeting = memo(function Greeting({ name }) {
return <h1>Hello, {name}!</h1>;
});
export default Greeting;
При этом важно напомнить, что по умолчанию свойства сравниваются используя Object.is, и если ваше свойство является “всегда новым”, то memo не спасет от ре-рендера. Чтобы избежать появления “всегда нового” пропса, чаще всего вам поможет использование useMemo и useCallback (подробно описано выше).
А вот это напоминание стоит отнести к любой оптимизации:
“Вам следует полагаться на memo только как на оптимизацию производительности. Если ваш код не работает без него, найдите основную проблему и сначала устраните ее. Затем вы можете добавить memo для повышения производительности.”
Бывают редкие случаи, когда сравнение свойств через Object.is не может обеспечить мемоизацию, например если не получается избавиться от “всегда нового” свойства. При каждом рендере у него меняется ссылка, но вы знаете, что при этом данные не меняются каждый раз.
const Chart = memo(function Chart({ dataPoints }) {
// ...
}, arePropsEqual);
function arePropsEqual(oldProps, newProps) {
return (
oldProps.dataPoints.length === newProps.dataPoints.length &&
oldProps.dataPoints.every((oldPoint, index) => {
const newPoint = newProps.dataPoints[index];
return oldPoint.x === newPoint.x && oldPoint.y === newPoint.y;
})
);
}
В данном случае вы пишете функцию, которая проверит конкретные ключи в старых и новых свойствах на равенство.
Используя кастомную функцию сравнения стоит понимать, что эта единственная функция, которая будет контролировать повторный рендер компонента. Например, если компонент принимает 3 свойства, а вы внутри своей функции проверили равенство только для одного, оставшиеся два не будут проверены автоматически через Object.is, вы должны это сделать самостоятельно.
Самым узким местом использования кастомной функции arePropsEqual являются свойства-функции. Дело в том, что вы можете замкнуть в функцию какие-то значения, и если вы не учтете это при сравнении этих функции, то вы можете получить очень сложно идентифицируемые баги, когда ваша функция arePropsEqual будет говорить “не делай повторный рендер компонента”, и в компоненте останется функция замкнувшая в себе некоторое не актуальное значение.
import React, { useState, memo } from 'react';
const arePropsEqual = (prevProps, nextProps) => {
// 🔴 Проблема: ошибка при сравнении, всегда будет true, компонент не будет
// перерисован, и функция onClick замкнет в себе стейт родителя
return prevProps.onClick !== nextProps.onClick;
};
const Child = memo(({ onClick }) => {
return (
<button onClick={onClick}>
Increment Count
</button>
);
}, arePropsEqual);
const Parent = () => {
const [count, setCount] = useState(0);
// 🔴 Функция handleClick замыкает в себе count
// тут необходимо использовать функцию updater
const handleClick = () => {
setCount(count + 1);
};
return (
<div>
<p>Count: {count}</p>
<Child onClick={handleClick} />
</div>
);
};
export default Parent;
Еще документация советует:
“Избегайте проведения глубоких сравнений внутри arePropsEqual, если вы не уверены на 100%, что структура данных, с которой вы работаете, имеет известную ограниченную глубину. Глубокие сравнения могут стать невероятно медленными и могут замораживать ваше приложение на несколько секунд, если кто-то изменит структуру данных позже.”
В документации в отдельные разделы выделены пункты:
Обновление мемоизированного компонента при изменении стейта.
Обновление мемоизированного компонента при изменении контекста.
Которые можно свести к одной фразе:
Мемоизированный компонент определяет пропуск рендара только по входящим свойствам. Если изменилось состояние внутри мемоизированного компонента или в нем изменились данные взятые из контекста - он будет отрендерен заново. Хотите мемоизировать такие случаи? - разбейте ваш компонент на два, и передавайте нужные состояния как свойства в мемоизированный дочерний элемент.
Также отдельно проговаривается, что пытаясь оптимизировать ваш компонент в первую очередь придерживайтесь правила “компонент принимает минимально необходимую информацию в свои пропсы”. Если вам нужен только name из объекта user, то не нужно внутрь передавать весь объект user.
Главная мысль, которую хотелось бы подчеркнуть состоит в том, что нет универсального ответа на вопрос - когда использовать описанные выше инструменты. Это вопрос, ответ на который вырабатывается в дискуссиях и обсуждениях внутри команды.
Дальше хочется подсветить мысли, которые красной нитью идут через все три статьи в документации:
Если ваш код работает некорректно без оптимизации, а с оптимизацией проблема уходит, то вам стоит в первую очередь устранить проблему, и только потом оптимизировать.
Скорее всего в большей части вашего приложения оптимизация не нужна, но если вы ее все же используете там, где она не нужна - большого вреда от этого не будет.
Всего одно “всегда новое” свойство может сделать бессмысленными любую оптимизацию.
Перед тем как использовать инструменты для оптимизации срабатывания эффектов, подумайте, можно ли изменить код так, чтобы избавиться от зависимости, а возможно и от эффекта как такового.
Не используйте useMemo и useCallback для оптимизации свойств, передаваемых в дочерний компонент, если дочерний компонент не обернут в memo.
Будьте очень осторожны с функцией arePropsEqual, особенно в части сравнения свойств-функций и глубокого сравнения объектов.
Демо с Suspence + memo + useMemo
Статья “Equality comparisons and sameness”
Функция updater
Статья о уменьшении количества зависимостей и эффектов
В завершение хочу порекомендовать вам бесплатный урок курса JavaScript Developer. Professional.