Введение
Оптимизация производительности React приложений — важная часть разработки, особенно когда речь идет о сложных интерфейсах. В основном разработчики лишь краем уха слышат о необходимости использования хуков useMemo, useCallback и React.memo для оптимизации кода. Но если бездумно использовать их, то можно даже навредить вашему приложению
В этой статье мы попытаемся разобрать, когда действительно стоит использовать useMemo, useCallback и React.memo, а когда их использование излишне. Мы изучим каждый из хуков, их влияние на рендеринг компонентов в React, а также рассмотрим практические примеры с подробными объяснениями работы каждого из хуков.
Особенности рендеринга в React
Прежде чем углубиться в детали, важно понять, как именно работает рендеринг в React.
Компонент как функция: В функциональном компоненте тело функции выполняется при каждом рендере. Это значит, что все переменные и функции, объявленные внутри компонента, будут пересоздаваться при каждом рендере.
Пересоздание функций и объектов: Функции и объекты, созданные внутри компонента, будут "новыми" при каждом рендере (рекомендую почитать про сравнение по ссылке и по значению). Однако хуки, такие как useState и useEffect, сохраняют своё состояние между ре-рендерами благодаря внутренним механизмам React.
Краткое описание хуков
React.memo
React.memo — это компонент высшего порядка (Higher Order Component, HOC), который мемоизирует функциональный компонент. Если пропсы компонента не изменились, React не сделает ре-рендер, он вернёт предыдущее состояние компонента.
Особенности:
Поверхностное сравнение (shallow equal): React.memo сравнивает текущие и предыдущие пропсы с помощью Object.is, сравнивая их по ссылке.
Эффективен с пропсами примитивами: Лучше всего работает, когда пропсы — примитивные типы (числа, строки, булевы значения).
Неэффективен с сложными объектами: Если пропсы — объекты или функции, необходимо дополнительно использовать useMemo или useCallback для мемоизации этих пропсов.
Примеры использования React.memo:
Пример 1: Компонент с пропсами примитивами без React.memo
function Display({ value }) {
console.log('Рендер Display');
return <div>{value}</div>;
}
function App() {
const [count, setCount] = React.useState(0);
return (
<div>
<Display value="Статичный текст" />
<button onClick={() => setCount(count + 1)}>Увеличить</button>
</div>
);
}
Что происходит: При каждом нажатии на кнопку, компонент Display делает ре-рендер, хотя его пропс value не изменился. Это происходит из-за того, что изменяется стейт, следовательно, родительский компонент App (в котором изменился стейт) перерисовывается, и его дочерние компоненты также должны перерисоваться.
Пример 2: Компонент с примитивными пропсами, обёрнутый в React.memo
const Display = React.memo(function Display({ value }) {
console.log('Рендер Display');
return <div>{value}</div>;
});
function App() {
const [count, setCount] = React.useState(0);
return (
<div>
<Display value="Статичный текст" />
<button onClick={() => setCount(count + 1)}>Увеличить</button>
</div>
);
}
Что происходит: Теперь компонент Display не перерисовывается при изменении стейта в App, так как его пропс value не изменился, и благодаря React.memo он избегает ненужного ре-рендеринга.
Пример 3: Компонент с непримитивными пропсами, обёрнутый в React.memo
const Display = React.memo(function Display({ data }) {
console.log('Рендер Display');
return <div>{data.value}</div>;
});
function App() {
const [count, setCount] = React.useState(0);
const data = { value: 'Статичный текст' };
return (
<div>
<Display data={data} />
<button onClick={() => setCount(count + 1)}>Увеличить</button>
</div>
);
}
Что происходит: Несмотря на то, что data не изменяется, Display перерисовывается при каждом ре-рендере App, потому что объект data пересоздаётся при каждом рендере, и его ссылка меняется (снова рекомендую почитать про сравнение по ссылке и по значению). React.memo видит, что пропс data изменился (ссылочно), и перерисовывает компонент.
Как исправить:
Использовать useMemo для мемоизации объекта data:
const Display = React.memo(function Display({ data }) {
console.log('Рендер Display');
return <div>{data.value}</div>;
});
function App() {
const [count, setCount] = React.useState(0);
const data = React.useMemo(() => ({ value: 'Статичный текст' }), []);
return (
<div>
<Display data={data} />
<button onClick={() => setCount(count + 1)}>Увеличить</button>
</div>
);
}
Теперь data будет иметь стабильную ссылку между рендерами, и Display не будет перерисовываться без необходимости.
useCallback
useCallback возвращает мемоизированную версию функции, которая сохраняется между рендерами, пока не изменятся указанные зависимости.
Пример использования useCallback:
const Button = React.memo(function Button({ onClick, label }) {
console.log(`Рендер кнопки: ${label}`);
return <button onClick={onClick}>{label}</button>;
});
function Counter() {
const [count, setCount] = React.useState(0);
const increment = React.useCallback(() => setCount((c) => c + 1), []);
return (
<div>
<h1>Счетчик: {count}</h1>
<Button onClick={increment} label="Увеличить" />
</div>
);
}
Что происходит:
increment мемоизирован через useCallback, и его ссылка остаётся одинаковой между рендерами, пока зависимости не изменятся.
Button обёрнут в React.memo, поэтому он не будет перерисовываться, пока его пропсы не изменятся.
В данном случае, т.к. increment не использует переменных из внешнего окружения и они не изменяются, его ссылка остаётся стабильной.
Обратите внимание:
Необходимо следить за массивом зависимостей, чтобы избежать проблем с устаревшими замыканиями (stale closure).
Функция всё так же создаётся: Несмотря на то, что ссылка на increment остаётся стабильной, сама функция пересоздаётся при каждом ре-рендере, но useCallback возвращает нам предыдущую версию, т.к. зависимости не изменились.
Проблема с устаревшим замыканием
function Counter() {
const [count, setCount] = React.useState(0);
const increment = React.useCallback(() => setCount(count + 1), []);
return (
<div>
<h1>Счетчик: {count}</h1>
<button onClick={increment}>Увеличить</button>
</div>
);
}
Что происходит:
count не указан в зависимостях у useCallback.
Из-за этого, increment всегда использует значение count, которое было при первом рендере.
Это приводит к тому, что счётчик однажды увеличивается, но дальше не происходит увеличения, даже если мы кликнем 100500 раз.
Решение:
Добавить count в зависимости (но в таком случае конечно теряется весь смысл его использования, но это только в нашем банальном примере так)
const increment = React.useCallback(() => setCount(count + 1), [count]);
useMemo
useMemo позволяет мемоизировать результат вычислений между ре-рендерами и пересчитывает его только тогда, когда изменятся зависимости.
Пример использования useMemo
function HeavyComputation({ num }) {
const compute = (n) => {
// Имитация тяжелых вычислений
let result = 0;
for (let i = 0; i < 1e7; i++) {
result += n * Math.random();
}
return result;
};
const value = React.useMemo(() => compute(num), [num]);
return <div>Результат вычислений: {value}</div>;
}
function App() {
const [number] = React.useState(42);
const [toggle, setToggle] = React.useState(false);
return (
<div>
<button onClick={() => setToggle((t) => !t)}>Переключить</button>
<HeavyComputation num={number} />
</div>
);
}
Что происходит:
compute(num) выполняется только тогда, когда проп num изменяется.
Это позволяет не делать лишние тяжелые вычисления при каждом ре-рендере App.
Мемоизация объектов и массивов
function App() {
const [count, setCount] = React.useState(0);
const data = React.useMemo(() => ({ value: 'Статичный текст' }), []);
return (
<div>
<Display data={data} />
<button onClick={() => setCount(count + 1)}>Увеличить</button>
</div>
);
}
Теперь data имеет стабильную ссылку между ре-рендерами, и компоненты, зависящие от него, не будут перерисовываться без необходимости.
Вопрос к читателям
Рассмотрим пример:
function Button({ onClick, label }) {
console.log(`Рендер кнопки: ${label}`);
return <button onClick={onClick}>{label}</button>;
}
function Counter() {
const [count, setCount] = React.useState(0);
const increment = () => setCount(count + 1);
return (
<div>
<h1>Счетчик: {count}</h1>
<Button onClick={increment} label="Увеличить" />
</div>
);
}
Каждый раз при рендере Counter создаётся новая функция increment (для нас главное, что меняется ссылка на эту функцию), и Button получает новый проп onClick.
Вопрос: Как думаете, станет ли лучше производительность, если обернуть increment в useCallback и Button в React.memo? Какой из следующих вариантов будет более эффективным с точки зрения производительности?
Использовать useCallback для increment и обернуть Button в React.memo.
Оставить код без изменений.
Подумайте над ответом, прежде чем читать дальше.
Объяснение
Создание функций при каждом ре-рендере
В функциональных компонентах все функции и объекты, объявленные внутри компонента, пересоздаются при КАЖДОМ рендере.
Это значит, что ссылки на эти функции и объекты меняются при каждом ре-рендере, не смотря на то, что их содержимое остаётся без изменений.
Влияние на дочерние компоненты
Если дочерний компонент получает функцию или объект в качестве пропса и обёрнут в React.memo, изменение ссылки у какого-нибудь из пропсов всё равно приведёт к его перерисовке.
Это вызовет ненужные перерисовки, если функции или объекты переданные в пропсах не мемоизированы.
Ответ
На первый взгляд, оборачивание increment в useCallback и Button в React.memo должно предотвратить ненужные перерисовки Button. Но в данном случае выигрыш в производительности будет либо незначительным, либо его не будет вообще.
Почему?
Простые компоненты: Компонент Button очень простой, и время его рендеринга минимально. Оптимизация его ре-рендеров в этом случае не даёт какого-то заметного эффекта.
Мемоизации не бесплатны: Использование useCallback и React.memo под капотом добавляет затраты на саму мемоизацию и сравнение пропсов между ре-рендерами.
Нужно следить за зависимостями: В useCallback необходимо правильно указать зависимости, чтобы избежать проблем с устаревшими замыканиями.
Резюмируя: В этом примере, оборачивание increment в useCallback и использование React.memo для Button не даст нам значимого буста производительности, более того, это усложнит нам код. Поэтому оставить код в исходном виде лучше.
Смысл оптимизации
Главная мысль: Надеюсь, вы заметили, что на протяжении всего текст я пытаюсь донести до вас вполне очевидную, но почему-то часто ускользающую мысль – оптимизация не бесплатна. Каждая оптимизация добавляет сложность со своей стороны и требует ресурсы. Ключевым является умение оценивать, а нужна ли нам здесь вообще эта оптимизация, не попадаем ли мы в ловушку преждевременной оптимизации.
Память и производительность: Нельзя забывать, что мемоизация не бесплатна, она использует дополнительную память для хранения результатов и отслеживания зависимостей.
Сложность кода: Повсеместное обёртывание функций, компонентов (тут место для холивара, что компонент и есть функция) и вычислений в хуки усложняет код и делает его менее читаемым.
Нужно измерять пользу от оптимизации: Оптимизируйте только после того, как убедитесь что у вас есть проблемы с этим, используя инструменты профилирования.
Массив зависимостей и устаревшие замыкания: Неправильное указание зависимостей может привести к багам. Всегда следите за тем, чтобы массив зависимостей был актуален.
Кстати, из последних новостей. В 19 версии разработчики React показали нам свой новый компайлер, который сам, под капотом занимается большей частью оптимизации, это вполне возможно сильно изменит привычный нам подход к оптимизации приложений
Помните: Главная цель — писать чистый, понятный и эффективный код. Оптимизации должны служить этой цели, а не препятствовать ей.
Комментарии (8)
winkyBrain
05.01.2025 11:11const Display = React.memo(function Display({ data }) { console.log('Рендер Display'); return <div>{data.value}</div>; }); function App() { const [count, setCount] = React.useState(0); const data = React.useMemo(() => ({ value: 'Статичный текст' }), []); return ( <div> <Display data={data} /> <button onClick={() => setCount(count + 1)}>Увеличить</button> </div> ); }
А здесь useMemo чтобы что? Чтобы на каждый рендер была проверка пустого массива зависимостей? Почему не в реф?
Izripov_Yusuf Автор
05.01.2025 11:11В этой статье я рассматривал хуки useMemo, useCallback и HOC React.memo. Чтобы показать решение проблемы с изменяющейся ссылкой у объектов, массивов и функций, для примера был использован useMemo. Но согласен, что можно было бы сделать это и через реф, возможно, об использовании рефа для оптимизации я напишу в следующей статье)
NMTEG
05.01.2025 11:11Хорошая статья, но не хватает реальных кейсов и измерений. Добавьте примеры с анализом производительности, чтобы лучше показать, когда мемоизация действительно полезна
jbourne
05.01.2025 11:11Ответ
На первый взгляд, оборачивание increment в useCallback и Button в React.memo должно предотвратить ненужные перерисовки Button. Но в данном случае выигрыш в производительности будет либо незначительным, либо его не будет вообще.
Ответ не совсем верный. Если кейс именно как в статье - ускорения не будет вообще. Так как ваш инкремент мемоизируется следующим образом:
const increment = React.useCallback(() => setCount(count + 1), [count]);
С зависимостью -
count
. А значит коллбек будет пересоздаваться после каждого клика на кнопку. Значит и кнопка будет рендериться заново после каждого клика, потому как ссылка на коллбек новая.Что бы этого не было, нужно создавать коллбек без зависимости (как вы делали ранее):
const increment = React.useCallback(() => setCount((c) => c + 1), []);
И мне кажется нужно было специально обратить внимание на эту фишку создания коллбека без зависимости.
Именно данная зависимость на
count
и ломает полностью оптимизацию в вопросе. А если сделать коллбек без зависимости - тогда уже можно рассуждать о том, будет ли перформанс буст и имеет ли смысл, но так он хотя бы возможен.Отдельно про рассуждения про сложность, целесообразность, ..., скорость оптимизации из вашего вопроса:
Для тех кто это все умеет - эта информация не ценна, а для тех кто новичек - они тут ничего не запомнят и не поймут, будет просто как вода. Потому что у вас просто общие рассуждения, по верхам.
Нужно делать как в вашем же утверждении в пунктах: все мерять и сравнивать (скорость, память, сложность, размер, ...). Без замеров перформанс статьи неполны.
YChebotaev
Почти никогда не стоит. Если в реакте меняется стейт, значит, что-то должно быть перерисовано. Оборачивать вообще все дорого как с точки зрения производительности, так и с точки зрения багов
Оборачивать кое-что иногда имеет смысл
sovaz1997
Именно! Оборачивать только, когда на то есть веская причина. Даже если кажется, что "операция дорогая, надо обернуть", если видимых проблем с перформансом нет, то не имеет смысла почти 100%
Izripov_Yusuf Автор
Рад, что в комментарии пришли единомышленники)