9 мин. чтения

Оптимизация производительности React-приложений.

Почему React-приложение тормозит и как это исправить. Разбираем memo, useMemo, useCallback, виртуализацию и другие техники - простым языком с примерами.

Почему ваш 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. Бывает.

Все статьи
Оптимизация производительности React-приложений | Enot Software