Почему ваш React тормозит
Итак, вы написали React-приложение. Оно красивое, функциональное, и... медленное. Кнопки реагируют с задержкой, скролл дёргается, а при вводе текста каждая буква появляется через полсекунды. Знакомо?
Не переживайте - это одна из самых частых проблем, и решается она довольно просто. В этой статье разберём конкретные причины тормозов и конкретные решения. Без магии и умных слов - только практика.
Почему React может тормозить
React работает по простому принципу: когда данные меняются, он перерисовывает компоненты. Проблема в том, что иногда он перерисовывает слишком много компонентов, или делает это слишком часто. Представьте, что каждый раз когда вы меняете одну настройку в телефоне, он перезагружает ВСЕ приложения. Примерно это и происходит в неоптимизированном React-приложении.
Основные причины:
- Компонент перерисовывается, хотя его данные не изменились
- Тяжёлые вычисления выполняются при каждой перерисовке
- На странице рендерятся тысячи элементов, хотя видно только 20
- Весь код приложения загружается сразу, хотя нужна только одна страница
Золотое правило: сначала измерь
Преждевременная оптимизация - корень всех зол.
Прежде чем что-то оптимизировать, убедитесь что проблема реально существует. Не надо оборачивать каждый компонент в memo "на всякий случай" - это захламляет код и иногда делает только хуже.
Как найти проблему:
// Откройте браузер Chrome/Firefox
// Нажмите F12 (инструменты разработчика)
// Установите расширение "React Developer Tools"
// Перейдите на вкладку "Profiler"
// Нажмите кнопку записи (красный кружок)
// Сделайте действие, которое тормозит
// Остановите запись
// Посмотрите, какие компоненты рендерились и сколько времени заняли
// Можно также добавить в код простой способ отслеживания:
function MyComponent({ data }) {
// Этот console.log покажет КАЖДЫЙ рендер компонента
// Если видите его слишком часто - есть проблема
console.log('MyComponent отрендерился!');
return <div>{data.name}</div>;
}
React.memo - "не перерисовывай, если ничего не изменилось"
Самый частый случай: родительский компонент обновляется, и React перерисовывает ВСЕ дочерние компоненты - даже те, чьи данные не менялись. React.memo это исправляет.
Аналогия: представьте что вы - художник, и вам заказали нарисовать портрет. Вы его нарисовали. На следующий день заказчик приходит и говорит: "Нарисуй ещё раз, но ничего не меняй". Без memo вы бы каждый раз рисовали заново. С memo вы говорите: "Портрет уже готов, зачем рисовать снова?"
import { memo } from 'react';
// БЕЗ memo - рендерится каждый раз когда родитель обновляется
function UserCard({ name, avatar }) {
console.log('UserCard рендерится!'); // Будет появляться ОЧЕНЬ часто
return (
<div className="card">
<img src={avatar} />
<h3>{name}</h3>
</div>
);
}
// С memo - рендерится ТОЛЬКО если name или avatar изменились
const UserCard = memo(function UserCard({ name, avatar }) {
console.log('UserCard рендерится!'); // Теперь только при реальных изменениях
return (
<div className="card">
<img src={avatar} />
<h3>{name}</h3>
</div>
);
});
// Пример: список из 100 пользователей
// Когда один пользователь меняет аватар:
// Без memo: перерисовываются все 100 карточек
// С memo: перерисовывается только 1 карточка
useMemo - "не считай заново, если данные не изменились"
Допустим, у вас список из 10000 товаров, и вы их фильтруете и сортируете. Без useMemo эта фильтрация будет выполняться при каждом рендере - даже если список не менялся.
Аналогия: вы отсортировали книги на полке по алфавиту. Кто-то включил свет в комнате. Без useMemo вы бы снова пересортировали все книги. С useMemo вы проверяете: "Книги менялись? Нет? Тогда не трогаю."
import { useMemo, useState } from 'react';
function ProductList({ products }) {
const [search, setSearch] = useState('');
const [sortBy, setSortBy] = useState('price');
// БЕЗ useMemo:
// Каждый раз когда компонент рендерится (например, при вводе текста),
// фильтрация и сортировка 10000 товаров выполняется ЗАНОВО
// Это занимает ~100-200мс и вызывает подвисание при вводе
const filtered = products
.filter(p => p.name.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) => a[sortBy] - b[sortBy]);
// С useMemo:
// Фильтрация выполняется только когда products, search или sortBy изменились
// При других рендерах используется сохранённый результат
const filtered = useMemo(() => {
console.log('Фильтруем товары...'); // Увидите только при реальных изменениях
return products
.filter(p => p.name.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) => a[sortBy] - b[sortBy]);
}, [products, search, sortBy]);
// ^^^ Список зависимостей: пересчитай, только если ЭТИ значения изменились
return (
<div>
{/* При вводе текста рендер происходит, но фильтрация - нет (если products не менялись) */}
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Поиск..."
/>
<p>Найдено: {filtered.length} товаров</p>
{filtered.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}
useCallback - "не создавай функцию заново"
Эта штука нужна, чтобы React.memo работал правильно. Дело в том, что при каждом рендере все функции внутри компонента создаются заново. Для React это значит, что "пропсы изменились" (новая функция - это новый объект), и memo перестаёт работать.
import { useCallback, useState, memo } from 'react';
function TodoApp() {
const [todos, setTodos] = useState([]);
// БЕЗ useCallback:
// Эта функция создаётся заново при КАЖДОМ рендере TodoApp
// А значит TodoItem (обёрнутый в memo) всё равно перерендерится,
// потому что "новая функция" для него - это "новый проп"
const handleDelete = (id) => {
setTodos(prev => prev.filter(t => t.id !== id));
};
// С useCallback:
// Функция создаётся ОДИН раз и переиспользуется
// TodoItem с memo теперь работает правильно!
const handleDelete = useCallback((id) => {
// prev - текущий список задач
// filter создаёт новый массив БЕЗ удалённой задачи
setTodos(prev => prev.filter(t => t.id !== id));
}, []); // [] - пустые зависимости = функция создаётся один раз
const handleToggle = useCallback((id) => {
setTodos(prev => prev.map(t =>
// Если это нужная задача - меняем done на противоположное
// Если нет - оставляем как есть
t.id === id ? { ...t, done: !t.done } : t
));
}, []);
return (
<div>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onDelete={handleDelete} // Ссылка на функцию не меняется!
onToggle={handleToggle} // И эта тоже!
/>
))}
</div>
);
}
// memo + useCallback = идеальная пара
// Теперь TodoItem перерендерится ТОЛЬКО если todo изменился
const TodoItem = memo(function TodoItem({ todo, onDelete, onToggle }) {
console.log('TodoItem рендер:', todo.text); // Только для изменённых задач
return (
<div>
<span
onClick={() => onToggle(todo.id)}
style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
>
{todo.text}
</span>
<button onClick={() => onDelete(todo.id)}>Удалить</button>
</div>
);
});
Виртуализация - "показывай только то, что видно"
У вас список из 10000 элементов, но на экране помещается только 15. Зачем рендерить остальные 9985? Виртуализация рендерит только видимые элементы и немного "про запас" для плавного скролла.
Аналогия: представьте бесконечную ленту Instagram. Они же не загружают все 10 миллионов постов сразу - только те, что сейчас на экране. Тот же принцип.
// Используем библиотеку @tanstack/react-virtual
// npm install @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
function BigList({ items }) { // items - массив из 10000 элементов
// Ссылка на контейнер со скроллом
const scrollRef = useRef(null);
// Настраиваем виртуализацию
const virtualizer = useVirtualizer({
count: items.length, // Сколько всего элементов
getScrollElement: () => scrollRef.current, // Контейнер со скроллом
estimateSize: () => 60, // Примерная высота одного элемента (px)
overscan: 5, // Рендерим 5 элементов "про запас"
}); // за пределами видимой области
return (
<div
ref={scrollRef}
style={{ height: '500px', overflow: 'auto' }} // Контейнер фиксированной высоты
>
{/* Внутренний контейнер - его высота = высота ВСЕХ элементов */}
{/* Это нужно чтобы полоса прокрутки была правильного размера */}
<div style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative'
}}>
{/* Рендерим ТОЛЬКО видимые элементы (15-20 штук вместо 10000) */}
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: `${virtualItem.start}px`, // Позиция элемента
height: `${virtualItem.size}px`,
width: '100%'
}}
>
<UserCard user={items[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}
// Результат:
// Без виртуализации: рендеринг 10000 элементов = ~2000мс (2 секунды!)
// С виртуализацией: рендеринг ~20 элементов = ~5мс
// Разница в 400 раз. Не шутка.
Code Splitting - "загружай код только когда нужен"
Зачем пользователю, который зашёл на главную страницу, загружать код админ-панели? Или код страницы настроек? lazy и Suspense позволяют загружать код "по требованию":
import { lazy, Suspense } from 'react';
// ВМЕСТО обычного импорта (загрузится ВСЁ сразу):
// import AdminPanel from './AdminPanel';
// import Settings from './Settings';
// Используем lazy - код загрузится только когда компонент понадобится
const AdminPanel = lazy(() => import('./AdminPanel'));
const Settings = lazy(() => import('./Settings'));
const Analytics = lazy(() => import('./Analytics'));
function App() {
return (
<Routes>
{/* Главная - загружается сразу */}
<Route path="/" element={<HomePage />} />
{/* Админка - загрузится только когда пользователь перейдёт на /admin */}
<Route path="/admin" element={
<Suspense fallback={<div>Загрузка...</div>}>
<AdminPanel />
</Suspense>
{/* Suspense показывает "Загрузка..." пока код скачивается */}
} />
{/* Настройки - аналогично */}
<Route path="/settings" element={
<Suspense fallback={<div>Загрузка...</div>}>
<Settings />
</Suspense>
} />
</Routes>
);
}
// Результат: вместо одного файла в 2МБ
// пользователь загружает 500КБ для главной,
// а остальное - только если перейдёт на другие страницы
Советы по работе с состоянием
// ПЛОХО: одно гигантское состояние
// При изменении ЛЮБОГО поля перерисовывается ВСЁ приложение
const [state, setState] = useState({
user: null, // Данные пользователя
theme: 'dark', // Тема оформления
todos: [], // Список задач
notifications: [], // Уведомления
sidebarOpen: false, // Боковая панель
searchQuery: '', // Поиск
});
// Поменяли тему? Перерисовался список задач. Зачем?!
// ХОРОШО: разделяем по смыслу
// Каждый useState - независимый
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('dark');
const [todos, setTodos] = useState([]);
const [notifications, setNotifications] = useState([]);
// Поменяли тему? Перерисовались только компоненты, использующие theme
// Список задач даже не заметил
Шпаргалка по оптимизации
- React.memo - если компонент рендерится часто, но его данные меняются редко
- useMemo - для тяжёлых вычислений (фильтрация, сортировка больших массивов)
- useCallback - для функций, которые передаёте в memo-компоненты
- Виртуализация - для списков более 100 элементов
- Code splitting - для страниц, которые пользователь может не посетить
- Разделение state - не храните всё в одном useState
Итого
Оптимизация React - это не rocket science. Это набор простых приёмов, каждый из которых решает конкретную проблему. Главное правило: сначала найдите проблему (React DevTools Profiler), а потом применяйте решение. Не наоборот.
И помните: если ваше приложение тормозит после всех оптимизаций - возможно, дело не в React, а в том, что маркетологи попросили добавить 47 трекинг-скриптов, 3 чат-виджета и анимированный баннер в формате 4K. Бывает.