Перейти к содержимому

22. React.memo

Привет, кодеры! С вами Яша, и сегодня мы погрузимся в мир оптимизации производительности React-приложений с помощью очень полезного инструмента — React.memo. Если ваш компонент “моргает” при каждом чихе родителя, даже когда его собственные пропсы не меняются, значит, этот урок для вас!

В мире React компоненты могут рендериться довольно часто. Если родительский компонент обновляет свое состояние, по умолчанию React перерендерит все его дочерние компоненты, даже если пропсы, переданные этим дочерним компонентам, остались прежними. В большинстве случаев это не проблема, так как React очень быстр.

Однако, когда у вас есть “тяжелые” компоненты (сложные вычисления, большой DOM-дерево) или просто слишком много компонентов, частое перерендеривание может замедлить ваше приложение. Тут-то нам на помощь и приходит React.memo!

React.memo – это Higher-Order Component (HOC), который “мемоизирует” ваш функциональный компонент. Помните, мы уже говорили про мемоизацию в контексте useMemo и useCallback? Здесь та же идея: React.memo запоминает последний результат рендера компонента и, если пропсы не изменились, просто возвращает этот запомненный результат, избегая повторного выполнения логики компонента и создания элементов DOM.

Аналогия: Представьте, что у вас есть повар (ваш компонент), который всегда готовит одно и то же блюдо (результат рендера). Если вы просите его приготовить то же блюдо, используя те же ингредиенты (пропсы), React.memo — это его ассистент, который говорит: “Эй, шеф, у нас уже есть это блюдо, и ингредиенты те же. Можешь отдохнуть!” Ассистент посмотрит на ингредиенты и, если они не изменились, просто принесет вам уже готовое блюдо.

По умолчанию React.memo выполняет поверхностное сравнение (shallow comparison) пропсов. Это значит, что он сравнивает только ссылки на объекты, а не их содержимое. Для примитивных значений (числа, строки, булевы) это работает отлично. Для объектов и массивов это означает, что ссылка на объект/массив должна быть той же, чтобы React.memo сработал.

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

import React, { useState, useEffect } from 'react';
// 1. Дочерний компонент, который мы хотим мемоизировать
interface ChildProps {
message: string;
}
const ChildComponent: React.FC<ChildProps> = ({ message }) => {
// Выводим сообщение в консоль каждый раз при рендере, чтобы видеть разницу
console.log(' 👉 MemoizedChildComponent рендерится. Сообщение:', message);
return (
<div style={{ background: '#e0ffe0', padding: '5px', margin: '5px' }}>
<p>Memoized: {message}</p>
</div>
);
};
// Оборачиваем компонент в React.memo
const MemoizedChildComponent = React.memo(ChildComponent);
// 2. Дочерний компонент без мемоизации для сравнения
const NonMemoizedChildComponent: React.FC<ChildProps> = ({ message }) => {
console.log(' ❌ NonMemoizedChildComponent рендерится. Сообщение:', message);
return (
<div style={{ background: '#ffe0e0', padding: '5px', margin: '5px' }}>
<p>Non-memoized: {message}</p>
</div>
);
};
// 3. Родительский компонент, который будет часто ререндериться
function ParentComponentBasic() {
const [count, setCount] = useState(0);
const [userName, setUserName] = useState("Яша");
useEffect(() => {
// Каждую секунду обновляем счетчик, вызывая ререндер родителя
const timer = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
console.log('ParentComponentBasic рендерится. Счетчик родителя:', count);
return (
<div style={{ border: '1px dashed blue', padding: '10px', margin: '10px' }}>
<h3>Родительский компонент (Базовый пример)</h3>
<p>Счетчик родителя: {count}</p>
<button onClick={() => setUserName(userName === "Яша" ? "Алекс" : "Яша")}>
Сменить имя (обновляет проп MemoizedChildComponent)
</button>
{/* MemoizedChildComponent рендерится только при изменении userName */}
<MemoizedChildComponent message={`Привет, ${userName}!`} />
{/* NonMemoizedChildComponent рендерится при каждом рендере родителя */}
<NonMemoizedChildComponent message={`Привет, ${userName}!`} />
</div>
);
}
// export default ParentComponentBasic; // Для демонстрации в App

Запустив этот код, вы увидите, что NonMemoizedChildComponent рендерится каждую секунду вместе с ParentComponentBasic, в то время как MemoizedChildComponent рендерится только тогда, когда вы нажимаете кнопку “Сменить имя”, потому что только тогда меняется проп message.

⚡ Продвинутые техники: useCallback, useMemo и кастомное сравнение

Заголовок раздела «⚡ Продвинутые техники: useCallback, useMemo и кастомное сравнение»

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

Вот тут нам и пригодятся хуки useCallback и useMemo!

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

В паре с React.memo они создают мощный механизм для контроля рендеров.

А что, если нам нужно более сложное сравнение, чем просто поверхностное? Например, мы хотим игнорировать изменения определенных пропсов или проверять глубоко вложенные свойства. Для этого React.memo принимает второй аргумент — функцию сравнения:

const MyComponent = React.memo(MyComponentFn, (prevProps, nextProps) => {
// Возвращаем true, если пропсы равны (НЕ нужно рендерить)
// Возвращаем false, если пропсы отличаются (НУЖНО рендерить)
return prevProps.someProp === nextProps.someProp;
});

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

import React, { useState, useEffect, useCallback, useMemo } from 'react';
// Дочерний компонент для демонстрации продвинутых техник
interface ProductCardProps {
product: { id: number; name: string; price: number };
onAddToCart: (id: number) => void;
// Этот проп может часто меняться, но не должен вызывать ререндер карточки
showDiscountBadge: boolean;
}
const ProductCardComponent: React.FC<ProductCardProps> = ({ product, onAddToCart, showDiscountBadge }) => {
console.log(` 🛒 ProductCardComponent рендерится для: ${product.name}. Скидка: ${showDiscountBadge}`);
return (
<div style={{ border: '1px solid #ccc', margin: '10px', padding: '10px' }}>
<h4>{product.name} - ${product.price}</h4>
{showDiscountBadge && <span style={{ color: 'red', fontWeight: 'bold' }}>-10%!</span>}
<button onClick={() => onAddToCart(product.id)}>Добавить в корзину</button>
</div>
);
};
// Функция для кастомного сравнения пропсов.
// Возвращаем true, если НЕ нужно рендерить (пропсы считаются равными), false, если нужно рендерить.
const areProductCardPropsEqual = (prevProps: ProductCardProps, nextProps: ProductCardProps): boolean => {
// Мы хотим рендерить карточку, только если изменились ID продукта, его цена или функция добавления в корзину.
// Изменение `showDiscountBadge` не должно вызывать ререндер компонента.
return (
prevProps.product.id === nextProps.product.id &&
prevProps.product.price === nextProps.product.price &&
prevProps.onAddToCart === nextProps.onAddToCart
// Обратите внимание: prevProps.showDiscountBadge === nextProps.showDiscountBadge не включено.
// Это ключевой момент кастомного сравнения — мы игнорируем изменения этого пропа.
);
};
// Оборачиваем компонент в React.memo с нашей кастомной функцией сравнения
const MemoizedProductCard = React.memo(ProductCardComponent, areProductCardPropsEqual);
// Родительский компонент, который использует MemoizedProductCard
function ProductListAdvanced() {
const [products, setProducts] = useState([
{ id: 101, name: 'Книга "Изучаем JS"', price: 30 },
{ id: 102, name: 'Видеокурс по TS', price: 150 },
]);
const [globalDiscountActive, setGlobalDiscountActive] = useState(false);
const [parentToggle, setParentToggle] = useState(false); // Для принудительного ререндера родителя
// Мемоизируем функцию обратного вызова, чтобы ее ссылка не менялась при ререндерах родителя.
// Зависимости пусты, так как функция не использует внешние состояния.
const handleAddToCart = useCallback((id: number) => {
console.log(` ✅ Продукт с ID ${id} добавлен в корзину!`);
}, []);
useEffect(() => {
// Имитация частых изменений состояния, которое передается как проп, но должно игнорироваться
const interval = setInterval(() => {
setGlobalDiscountActive(prev => !prev);
setParentToggle(prev => !prev); // Для частых ререндеров родителя
}, 2500);
return () => clearInterval(interval);
}, []);
console.log('ProductListAdvanced рендерится. Общая скидка:', globalDiscountActive);
return (
<div style={{ border: '1px dashed green', padding: '10px', margin: '10px' }}>
<h3>Продвинутый Родительский Компонент</h3>
<p>Статус общей скидки (меняется, но не ререндерит карточки): {globalDiscountActive ? 'Активна' : 'Неактивна'}</p>
<button onClick={() => setProducts(prev => [...prev.map(p => ({ ...p, price: p.price + 1 }))])}>
Изменить цены продуктов (должно вызвать ререндер)
</button>
{products.map(product => (
<MemoizedProductCard
key={product.id}
product={product} // Объект, но его ID и цена сравниваются кастомно
onAddToCart={handleAddToCart} // Мемоизированная функция (ссылка стабильна)
showDiscountBadge={globalDiscountActive && product.price > 100} // Этот проп часто меняется, но игнорируется
/>
))}
</div>
);
}
// export default ProductListAdvanced; // Для демонстрации в App

Здесь MemoizedProductCard использует кастомную функцию сравнения areProductCardPropsEqual. Несмотря на то, что проп showDiscountBadge постоянно меняется, карточка продукта не будет перерендериваться, пока product.id, product.price или onAddToCart остаются прежними. При этом onAddToCart мемоизирована с помощью useCallback, чтобы ее ссылка всегда оставалась стабильной.

React.memo — мощный инструмент, но он не является “волшебной таблеткой”. Некоторые типичные ошибки могут свести на нет всю его пользу:

  1. Мутабельные объекты или массивы как пропсы: Если вы передаете в мемоизированный компонент объект или массив, и изменяете его содержимое, но не ссылку, React.memo не заметит изменений. Пример ошибки:

    const initialTask = { id: 1, text: 'Купить хлеб', completed: false };
    const [task, setTask] = useState(initialTask);
    // ...
    // В родительском компоненте:
    const handleClick = () => {
    task.completed = !task.completed; // ❌ Мутируем объект напрямую
    setTask(task); // ❌ Передаем ту же ссылку на объект
    };
    // ...
    // <MemoizedTaskItem task={task} /> // MemoizedTaskItem не перерендерится

    Решение: Всегда создавайте новые объекты или массивы при обновлении состояния, чтобы React мог обнаружить изменения через изменение ссылки. Используйте spread-оператор (...) или методы для создания копий.

    // ✅ Правильно: Создаем новый объект
    const handleClick = () => {
    setTask(prevTask => ({ ...prevTask, completed: !prevTask.completed }));
    };
  2. Функции как пропсы без useCallback: При каждом рендере родительского компонента создается новая функция, если она не обернута в useCallback. React.memo видит новую ссылку на функцию и всегда перерендерит дочерний компонент. Решение: Оборачивайте функции, передаваемые в мемоизированные компоненты, в useCallback.

  3. Передача пропсов, которые всегда разные: Например, передача new Date() или Math.random() как пропса. Эти значения всегда будут новыми при каждом рендере родителя, даже если обернуть дочерний компонент в React.memo. Решение: Избегайте передачи динамически генерируемых, постоянно меняющихся значений в мемоизированные компоненты, если их изменение не должно вызывать ререндер. Либо используйте кастомную функцию сравнения, чтобы игнорировать эти пропсы.

  4. Чрезмерное использование: React.memo имеет небольшой оверхед для сравнения пропсов. Если компонент очень простой, рендерится быстро, или его пропсы действительно часто меняются, то оверхед от React.memo может превысить выгоду. Решение: Используйте React.memo стратегически, когда профилирование указывает на узкие места производительности, или для компонентов, которые являются “тяжелыми” и имеют стабильные пропсы.

Время применить полученные знания! Создайте следующие компоненты:

  1. Задание 1: Счетчик кликов с мемоизацией Создайте компонент <ClickCounter> который отображает число и кнопку для его увеличения. Оберните его в React.memo. Создайте родительский компонент <AppContainer>, который имеет два состояния: userName: string и globalClicks: number. userName меняется по кнопке, globalClicks увеличивается каждую секунду setInterval. <AppContainer> должен рендерить <ClickCounter> и передавать ему userName в качестве пропса, а также отображать globalClicks. Цель: Убедитесь, что <ClickCounter> рендерится только при изменении userName, но не при изменении globalClicks.

  2. Задание 2: Список задач с useCallback Создайте компонент <TaskItem> который отображает текст задачи и чекбокс для отметки о выполнении. Принимает пропсы { task: { id: string; text: string; completed: boolean; }, onToggle: (id: string) => void }. Оберните <TaskItem> в React.memo. Создайте родительский компонент <TaskList>, который управляет списком задач tasks: Task[]. Он должен передавать task и onToggle в <TaskItem>. Используйте useCallback для мемоизации onToggle, чтобы <TaskItem> не перерендеривался при каждом рендере <TaskList> (например, при добавлении новой задачи или изменении фильтра), если только task или onToggle не изменились.

  3. Задание 3: Компонент настроек с кастомным сравнением Создайте компонент <UserSettings> который принимает объект settings: { theme: string; notificationsEnabled: boolean; lastLogin: Date; } и функцию onSave: () => void. Оберните <UserSettings> в React.memo с кастомной функцией сравнения. Функция сравнения должна рендерить компонент только при изменении theme или notificationsEnabled. Изменения lastLogin (который является Date объектом и всегда новой ссылкой) должны игнорироваться. В родительском компоненте обновляйте settings так, чтобы lastLogin менялся часто, но theme и notificationsEnabled оставались прежними, и убедитесь, что <UserSettings> не рендерится. Используйте useCallback для onSave.

React.memo — ваш союзник в борьбе за производительность, но как и любой мощный инструмент, требует вдумчивого использования. Всегда начинайте с рабочего решения, а к оптимизации прибегайте, когда есть реальные проблемы с производительностью, подтвержденные профилированием. И помните, useCallback и useMemo — лучшие друзья React.memo!


Попробуйте примеры в интерактивном редакторе: