22. React.memo
TypeScript: Оптимизация производительности с React.memo
Заголовок раздела «TypeScript: Оптимизация производительности с React.memo»Привет, кодеры! С вами Яша, и сегодня мы погрузимся в мир оптимизации производительности React-приложений с помощью очень полезного инструмента — React.memo. Если ваш компонент “моргает” при каждом чихе родителя, даже когда его собственные пропсы не меняются, значит, этот урок для вас!
🌟 Что такое React.memo и зачем он нужен?
Заголовок раздела «🌟 Что такое 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
Заголовок раздела «🚀 Базовое использование 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.memoconst 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);
// Родительский компонент, который использует MemoizedProductCardfunction 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 — мощный инструмент, но он не является “волшебной таблеткой”. Некоторые типичные ошибки могут свести на нет всю его пользу:
-
Мутабельные объекты или массивы как пропсы: Если вы передаете в мемоизированный компонент объект или массив, и изменяете его содержимое, но не ссылку,
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 }));}; -
Функции как пропсы без
useCallback: При каждом рендере родительского компонента создается новая функция, если она не обернута вuseCallback.React.memoвидит новую ссылку на функцию и всегда перерендерит дочерний компонент. Решение: Оборачивайте функции, передаваемые в мемоизированные компоненты, вuseCallback. -
Передача пропсов, которые всегда разные: Например, передача
new Date()илиMath.random()как пропса. Эти значения всегда будут новыми при каждом рендере родителя, даже если обернуть дочерний компонент вReact.memo. Решение: Избегайте передачи динамически генерируемых, постоянно меняющихся значений в мемоизированные компоненты, если их изменение не должно вызывать ререндер. Либо используйте кастомную функцию сравнения, чтобы игнорировать эти пропсы. -
Чрезмерное использование:
React.memoимеет небольшой оверхед для сравнения пропсов. Если компонент очень простой, рендерится быстро, или его пропсы действительно часто меняются, то оверхед отReact.memoможет превысить выгоду. Решение: ИспользуйтеReact.memoстратегически, когда профилирование указывает на узкие места производительности, или для компонентов, которые являются “тяжелыми” и имеют стабильные пропсы.
🎯 Практика
Заголовок раздела «🎯 Практика»Время применить полученные знания! Создайте следующие компоненты:
-
Задание 1: Счетчик кликов с мемоизацией Создайте компонент
<ClickCounter>который отображает число и кнопку для его увеличения. Оберните его вReact.memo. Создайте родительский компонент<AppContainer>, который имеет два состояния:userName: stringиglobalClicks: number.userNameменяется по кнопке,globalClicksувеличивается каждую секундуsetInterval.<AppContainer>должен рендерить<ClickCounter>и передавать емуuserNameв качестве пропса, а также отображатьglobalClicks. Цель: Убедитесь, что<ClickCounter>рендерится только при измененииuserName, но не при измененииglobalClicks. -
Задание 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: Компонент настроек с кастомным сравнением Создайте компонент
<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!
🔗 Полезные ссылки
Заголовок раздела «🔗 Полезные ссылки»Практика
Заголовок раздела «Практика»Попробуйте примеры в интерактивном редакторе: