ФАЗА 40. КАЧЕСТВО FRONTEND Производительность

Обязательно

Сначала профиль, затем точечная оптимизация

memo · Code splitting · Virtualization

Большинство рендеров дёшевы, мемоизация помогает только доказанному узкому месту, загрузка и длинные списки оптимизируются отдельно.

1. Рендер не равен дорогому DOM-обновлению

1 / 5

React вычисляет дерево и меняет только необходимое, поэтому фактическая цена зависит от работы компонентов и commit.

  • Render может быть повторным.
  • DOM не пересоздаётся целиком.
  • Профиль измеряет реальную ветку.
StateRender и reconciliationCommit

2. Profiler находит дорогую ветку

2 / 5

Замер показывает длительность, частоту и причину рендера, а не просто общее число обновлений приложения.

  • Сценарий воспроизводим.
  • Медленный компонент назван.
  • Оптимизация сравнивается после.
ДиагнозDurationПричина renderИзменение

3. memo и хуки стабилизируют вычисления

3 / 5

React.memo пропускает render при равных props, useMemo сохраняет вычисление, useCallback сохраняет функцию между рендерами.

  • Ссылки влияют на результат.
  • Мемоизация имеет цену.
  • Применение точечное.
useCallbackuseMemoReact.memo

4. Code splitting уменьшает стартовый объём

4 / 5

Динамический import выделяет маршрут или тяжёлый компонент в отдельный chunk, загрузка имеет fallback.

  • Бандл анализируется.
  • Дубликаты зависимостей видны.
  • Мелкие chunks не плодятся.
Первая загрузкаMain chunkRoute chunkVendor chunk

5. Виртуализация ограничивает DOM длинного списка

5 / 5

На экране остаются видимые строки и небольшой запас, но высота, фокус и доступность сохраняются.

  • Тысячи DOM-узлов не создаются.
  • Прокрутка сохраняет размеры.
  • Измерение подтверждает выигрыш.
Большой наборВидимое окноМаленький DOM

Производительность рендеринга

← назад к разделу

Медленный интерфейс раздражает — пользователь жмёт кнопку и видит зависание. Обычно разработчик в ответ начинает обвешивать компоненты useMemo и React.memo «на всякий случай», и в результате код становится сложнее, а интерфейс быстрее не становится. Разберём, как рендеринг устроен на самом деле и что реально влияет на скорость.

Как React рисует экран

Когда что-то меняется — состояние или пропсы, — React заново вызывает функцию компонента и строит новое виртуальное дерево. Потом сравнивает его со старым и обновляет только те узлы реального DOM, которые изменились.

Вот почему перерисовка компонента ≠ медленно. Сам JavaScript-вызов функции занимает микросекунды. Дорого — менять реальный DOM, а React старается делать это минимально.

Признак настоящей проблемы — видимая задержка: пользователь нажал, и интерфейс завис на полсекунды. Не «компонент рендерится 12 раз», а именно ощутимое торможение.

Как найти реальное узкое место

Прежде чем что-то оптимизировать, нужно убедиться, что проблема есть и понять где именно. Для этого есть два инструмента.

React DevTools Profiler — запускаете приложение, открываете вкладку Profiler, нажимаете «Record», воспроизводите медленное действие, останавливаете запись. Профайлер показывает, какой компонент сколько времени занял и почему перерендерился.

Lighthouse в браузере — показывает метрики загрузки: First Contentful Paint, Time to Interactive. Подсказывает, что мешает первой загрузке.

Правило простое: сначала измерь, потом оптимизируй. Иначе тратишь время на то, что не болит.

интерфейс тормозит React DevTools Profiler задержка при вводе или клике Lighthouse долгий первый экран анализатор бандла тяжёлый чанк

Инструмент выбирают по симптому, и только после замера решают, нужна ли мемоизация, code-splitting или замена библиотеки.

memo, useMemo, useCallback — три инструмента точечно

React.memo

Оборачивает компонент так, что он пропускает перерисовку, если пропсы не изменились по ссылке. Помогает, когда родительский компонент часто рендерится, а дочерний — дорогой и получает те же данные.

const ProductCard = React.memo(({ product }: { product: Product }) => {
  return <div>{product.name}</div>;
});

Без React.memo каждый рендер родителя перерисует ProductCard, даже если product не изменился.

useMemo

Кеширует результат вычисления между рендерами. Полезен, когда вычисление действительно тяжёлое — например, сортировка большого массива.

const sorted = useMemo(
  () => products.slice().sort((a, b) => a.price - b.price),
  [products],
);

Пересчитывает только когда изменился products. При каждом рендере сравнение зависимостей занимает время — поэтому на дешёвых вычислениях useMemo стоит дороже, чем просто посчитать заново.

useCallback

Стабилизирует ссылку на функцию между рендерами. Нужен, когда функцию передают в React.memo-компонент или в массив зависимостей другого хука.

const handleClick = useCallback(() => {
  dispatch({ type: "ADD", id });
}, [id, dispatch]);

Без useCallback при каждом рендере создаётся новая функция с новой ссылкой — React.memo воспринимает это как изменение пропса и перерисовывает потомка.

Когда эти инструменты вредят

На простых компонентах мемоизация приносит больше вреда, чем пользы:

  • useMemo на коротком вычислении медленнее самого вычисления;
  • useCallback без React.memo-потомка — просто лишний код;
  • обмемоизированный код труднее читать и понимать.

Эвристика: не добавляй мемоизацию, пока профайлер не показал, что без неё медленно. С React Compiler ручная мемоизация и вовсе становится исключением: он расставляет её сам, а вам остаётся измерять (подробнее в статье о хуках).

Code-splitting — не грузить лишнее сразу

Вторая причина медленного старта — браузер скачивает весь код приложения целиком, хотя пользователю нужна только стартовая страница.

React.lazy в паре с Suspense решает это: тяжёлый раздел загружается только когда пользователь туда переходит.

const Dashboard = React.lazy(() => import("./Dashboard"));
const ReportEditor = React.lazy(() => import("./ReportEditor"));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/reports/edit" element={<ReportEditor />} />
      </Routes>
    </Suspense>
  );
}

Первый экран грузит только свой код. Тяжёлый редактор или графики подтягиваются по требованию. Это напрямую ускоряет первую загрузку — метрику, которую пользователь чувствует.

Размер бандла

Даже с code-splitting каждый чанк может быть раздутым. Самая частая причина — библиотека, подключённая целиком ради одной функции.

Чтобы понять, что весит, используют анализаторы бандла: webpack-bundle-analyzer или vite-bundle-visualizer. Они строят интерактивную карту и сразу видно, что занимает место.

Частые находки:

  • moment.js или date-fns целиком вместо именованного импорта нужных функций;
  • иконочная библиотека, из которой используется 3 иконки;
  • дублирование зависимостей разных версий.

После измерения — конкретные действия: именованные импорты вместо дефолтных, замена тяжёлой библиотеки лёгкой альтернативой, удаление неиспользуемых зависимостей.

Дополнительно: при первом чтении можно пропустить

Глубже: длинные списки и виртуализациярасширенное

Список на пять тысяч строк тормозит не из-за перерисовок, а из-за пяти тысяч узлов DOM: даже идеально мемоизированный компонент создаёт их все, и браузер считает раскладку для каждого. Виртуализация рисует только строки, попавшие в окно прокрутки, с небольшим запасом сверху и снизу, а остальное заменяет пустым блоком нужной высоты. Библиотеки: TanStack Virtual, react-window.

const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
  count: rows.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 40,
});

<div ref={parentRef} style={{ height: 600, overflow: "auto" }}>
  <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
    {virtualizer.getVirtualItems().map((item) => (
      <Row key={item.key} row={rows[item.index]}
           style={{ position: "absolute", top: item.start, height: item.size }} />
    ))}
  </div>
</div>

Признак, что виртуализация нужна: в DOM сотни однотипных узлов и прокрутка дёргается. Признак, что не нужна: список на сто строк, где она только ломает поиск по странице и усложняет доступность.

Коротко

  • Перерисовка React-компонента сама по себе дешёвая — дорого менять реальный DOM, а React это минимизирует.
  • Реальная проблема видна глазом: ощутимая задержка при вводе или прокрутке.
  • Инструменты для поиска: React DevTools Profiler — для перерисовок, Lighthouse — для загрузки.
  • React.memo — пропускает перерисовку, если пропсы не изменились.
  • useMemo — кеширует результат дорогого вычисления.
  • useCallback — стабилизирует ссылку на функцию для memo-потомков.
  • Мемоизацию добавляют точечно по данным профайлера, не превентивно.
  • React.lazy + Suspense — code-splitting по маршрутам, быстрее первая загрузка.
  • Анализатор бандла покажет, что весит лишнего; часто виновата одна тяжёлая библиотека.

Что почитать дальше