В прошлом году я сделал e-commerce приложение, которое загружалось за 1,5 секунды. Спустя полгода, после добавления пары новых функций – динамических фильтров товаров и чата – время загрузки выросло до 8 секунд. Пользователи перестали оплачивать заказы, заказчик пришел в ярость. Что же стало причиной такого замедления? Неконтролируемые повторные рендеры и монолитный код.
React с его виртуальным DOM – не волшебная палочка. Как любой механизм, он требует регулярной оптимизации. В этой статье разберем:
- Скрытые проблемы рендеринга в React (и как их избежать).
- Инструменты для аудита производительности.
- Оптимизации в коде, которые ускорили приложение на 300% (с примерами «до» и «после»).
Как React рендерит компоненты
При работе с виртуальным DOM React отслеживает состояние приложения. Когда оно изменяется, он сравнивает новый виртуальный DOM со старым и обновляет измененные части. При этом часто React заодно обновляет то, что не нужно.
Ненужные повторные рендеры
Рассмотрим этот компонент:
function App() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>Increment</button>
<StaticComponent /> {/* Renders every time count changes! */}
</div>
);
}
function StaticComponent() {
console.log("I'm rendering unnecessarily ");
return <div>I never change!</div>;
}
Что здесь происходит? Каждый раз, когда нажимается кнопка Increment
, компонент повторно рендерится, хотя он вообще не зависит от count
. У меня был случай, когда я несколько часов оптимизировал дашборд, а потом понял, что статический футер рендерился 100 раз в секунду!
Инструменты для поиска проблем с рендерингом
React DevTools
Как выявить лишние рендеры? Поможет плагин React DevTools для браузера Chrome:
- Открываем DevTools → вкладка Profiler → Start Recording.
- Взаимодействуем с приложением (кликаем по кнопкам, переключаем страницы).
- Останавливаем запись и анализируем отчет.
Отчет будет выглядеть примерно так:
▲ Flamegraph Snapshot (After 3 clicks)
├─ App (Root) [Duration: 2.5ms]
│ ├─ button [Duration: 0.2ms]
│ └─ StaticComponent [Duration: 1.8ms]
│ └─ console.log call
├─ App (Root) [Duration: 2.3ms]
│ ├─ button [Duration: 0.1ms]
│ └─ StaticComponent [Duration: 1.7ms]
├─ App (Root) [Duration: 2.6ms]
│ ├─ button [Duration: 0.2ms]
│ └─ StaticComponent [Duration: 1.9ms]
Этот отчет показывает, что StaticComponent
рендерился 3 раза без всякой на то необходимости!
📌Как найти проблемные места? Ищите оранжевые/желтые полосы в профайлере –они указывают на медленные рендеры. В нашем случае StaticComponent
засветился, как новогодняя елка.
Как логировать ненужные рендеры
Для логирования ненужных ререндеров достаточно установить библиотеку why-did-you-render:
npm install @welldone-software/why-did-you-render
Затем нужно добавить этот код в index.js или App.js, чтобы в консоли отображались предупреждения о компонентах, рендерящихся без необходимости:
import React from 'react';
if (process.env.NODE_ENV !== 'production') {
const whyDidYouRender = require('@welldone-software/why-did-you-render');
whyDidYouRender(React, {
trackAllPureComponents: true, // Shames all components into behaving
});
}
Как-то раз эта библиотека помогла мне обнаружить, что компонент Header
постоянно ререндерился из-за объекта userPreferences
, который обновлялся при каждом рендере. Исправить проблему помогла мемоизация (но об этом позже).
Оснoвные способы оптимизации
React.memo – баунсер компонентов
React.memo останавливает повторный рендер компонента, если его пропсы не изменились.
❌ До (проблема):
Этот компонент рендерится при каждом обновлении родителя, даже если user
остался тем же:
<UserProfile user={user} /> {/* Лишний ререндер! */}
✅ После (исправление):
React.memo запоминает предыдущее значения пропсов и не рендерит компонент заново, если они не изменились:
const UserProfile = React.memo(({ user }) => {
return <div>{user.name}</div>;
});
⚠️Важный нюанс!
React.memo использует поверхностное сравнение, и в случае с объектами или массивами может не заметить лишний рендер:
// Не сработает, если `user` - это новый объект с теми же данными
<UserProfile user={{ name: 'Alice' }} />
// Работает с примитивами
<UserProfile userId={42} />
Для решения проблемы нужно передать кастомную функцию, которая определяет, изменился ли пропс:
const UserProfile = React.memo(
({ user }) => <div>{user.name}</div>,
(prevProps, nextProps) => prevProps.user.id === nextProps.user.id
);
Когда использовать React.memo:
- Компоненты с тяжелым UI (таблицы, графики).
- Часто обновляемые родительские компоненты (новостная лента, фид соцсети и т. п.)
useCallback и useMemo
Эти два хука помогают оптимизировать производительность, предотвращая ненужные ререндеры и дорогостоящие вычисления.
useCallback для предотвращения лишних ререндеров из-за функций
Функции в JavaScript создаются заново при каждом рендере. Это может вызвать ненужные перерисовки дочерних компонентов.
❌ Пример проблемы – кнопка перерисовывается при каждом рендере, потому что onClick
каждый раз получает новую функцию:
function App() {
const [count, setCount] = useState(0);
// Новая функция создается при каждом рендере
const handleClick = () => setCount(count + 1);
return <Button onClick={handleClick} />;
}
const Button = React.memo(({ onClick }) => {
return <button onClick={onClick}>Click me</button>;
});
✅ Решение – useCallback
запоминает функцию и не создает ее заново, если зависимости не изменились:
const handleClick = useCallback(() => {
setCount(prev => prev + 1); // Используем prev, чтобы избежать зависимостей
}, []); // Пустой массив => функция создается один раз
useMemo для оптимизации ресурсоемких вычислений
Если у вас есть огромный список (10 000+ элементов), и вы фильтруете его на каждое нажатие клавиши, без useMemo не обойтись.
❌ Без useMemo фильтрация выполняется при каждом рендере:
const filteredList = hugeList.filter(item => item.includes(searchTerm));
✅ Используем useMemo для кэширования результата:
const filteredList = useMemo(() => {
return hugeList.filter(item => item.includes(searchTerm));
}, [hugeList, searchTerm]); // Пересчет только при изменении зависимостей
Отложенная и фоновая загрузка
На главной странице уже упомянутого медленного приложения находилась увесистая карусель, и этот компонент загружался даже для тех пользователей, которые покидали сайт почти сразу. Такие компоненты, разумеется, нужно загружать только тогда, когда они действительно нужны.
Используем React.lazy для динамической подгрузки:
const ProductCarousel = React.lazy(() => import('./ProductCarousel'));
function HomePage() {
return (
<div>
<HeroBanner />
<Suspense fallback={<Spinner />}>
<ProductCarousel /> {/* Загружается только при рендере */}
</Suspense>
</div>
);
}
Можно предварительно загрузить компонент в фоновом режиме, когда пользователь показывает низкую активность:
const ProductCarousel = React.lazy(() => import(
/* webpackPrefetch: true */ './ProductCarousel'
));
Небольшой минус – пользователь может на мгновение увидеть спиннер при медленном соединении (например, 3G).
Дополнительная оптимизация: Context API и работа с большими списками
Context API – незаметный убийца производительности
Фатальная ошибка – хранить в AppContext и данные пользователя, и тему оформления, и уведомления. При изменении темы, например, будут перерисовываться все компоненты, использующие контекст, даже если они используют только данные пользователя. Правильный подход – разделить контексты и передавать их отдельно:
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
<App />
</ThemeContext.Provider>
</UserContext.Provider>
Изменение theme
заставит ререндериться только компоненты, использующие ThemeContext
.
Оптимизация больших списков
При рендеринге списка из 10 000 элементов браузер создаeт 10 000 DOM-узлов. Это загружает процессор, замедляет прокрутку и может привести к зависанию приложения на слабых гаджетах. Решить проблему поможет библиотека react-window, которая рендерит только видимые элементы, а не весь список:
import { FixedSizeList as List } from 'react-window';
const Row = ({ index, style }) => (
<div style={style}>Item {data[index]}</div>
);
function BigList() {
return (
<List
height={400} // Высота области списка
itemCount={10000} // Всего 10 000 элементов
itemSize={50} // Высота одного элемента
width={300} // Ширина списка
>
{Row}
</List>
);
}
Результат: количество DOM-узлов сокращается с 10 000 до максимум 20 (в зоне видимости), CPU не перегружается, лаги исчезают, прокрутка становится плавной.
Реальный кейс: оптимизация поиска в приложении для бронирования путешествий
Платформа для бронирования путешествий медленно загружала результаты поиска. Основными проблемами были:
- 4-секундная задержка при загрузке результатов.
- Лаги при вводе текста в поиске из-за постоянных перерисовок.
Описанные выше способы диагностики помогли выяснить причины:
- Компонент SearchResults не был мемоизирован, поэтому перерисовывался при каждом нажатии клавиши.
- API-запросы отправлялись на каждый символ, перегружая сервер.
Решение:
1. Дебаунс (исключение лишних API-запросов). Добавили задержку 500 мс, чтобы API-запрос отправлялся только когда пользователь перестает печатать:
const SearchInput = () => {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500); // Ждем 500 мс перед запросом
useEffect(() => {
fetchResults(debouncedQuery); // Запрос только после паузы в вводе
}, [debouncedQuery]);
};
Результат:
- Исчезли лишние запросы при наборе текста, снизилась нагрузка на API.
- У пользователей появилось ощущение плавного ввода без лагов.
2. Мемоизация результатов (useMemo). Поскольку результаты поиска обрабатывались заново при каждом рендере, необходимо было использовать useMemo для кеширования данных:
const results = useMemo(() => processRawData(rawData), [rawData]);
Результат:
- Если значение
rawData
не изменилось, обработка не выполняется заново. - Снизилась нагрузка на CPU.
- Страница перестала тормозить.
Общий итог оптимизации – время загрузки снизилось с 4 с до 1,2 с, лаги при вводе текста исчезли, прокрутка стала плавной.
Частые проблемы с производительностью React-приложений
Почему компонент продолжает тормозить даже с учетом React.memo?
React.memo не избавляет от всех подводных камней, которые могут вызвать лишние рендеры. Проверьте эти моменты:
1. Передача новых объектов/массивов в пропсы (они создаются заново на каждом рендере):
<MyComponent style={{ color: 'red' }} /> // Новый объект при каждом рендере!
Используйте useMemo или выносите объекты за пределы компонента.
2. Потребители контекста (useContext) могут обновляться при каждом изменении. Не забудьте разделить контексты (UserContext, ThemeContext и т. д.), чтобы обновлялась только нужная часть UI.
Стоит ли усложнять код ради отложенной загрузки?
- Да, если компонент не виден сразу (располагается ниже первого экрана). Это уменьшает размер первого загружаемого бандла.
- Нет, если компонент важен для первого рендера (шапка, главное меню). В этом случае отложенная загрузка замедлит отображение страницы.
Как убедить команду, что пора оптимизировать скорость?
Продемонстрируйте им Lighthouse-отчеты перед и после оптимизации. Исследование Deloitte показывает, что ускорение сайта всего на 0,1 с повышает конверсии на 8,4-10,1%.
Подведем итоги
Оптимизация React-приложений – это не разовое действие, а процесс. Главное – не пытаться улучшить код вслепую:
- Сначала профилирование, потом оптимизация. Не гадайте, где находятся узкие места в вашем приложении. Используйте конкретные данные для выявления реальных проблем производительности.
- Используйте React DevTools. Это отличный инструмент для поиска фактических узких мест в вашем приложении, который даст вам точную информацию о том, что нужно оптимизировать.
- Не оборачивайте все в useMemo на всякий случай. Это может вызвать больше проблем, чем решит.
- Примите несовершенство. Точечно оптимизированное приложение, которое работает на 90% быстрее с небольшими компромиссами, лучше гипотетического идеального приложения, которое никогда не будет выпущено.
Комментарии