Оптимизация фронтенда на собеседовании

«Приложение тормозит. Ваши действия?» Кандидат, который сразу начинает про useMemo и React.memo, уже проиграл. Правильный ответ начинается с другого слова.

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

Вопрос-крючок

Интервьюер описывает ситуацию: страница отчётов открывается 6 секунд, при скролле подтормаживает, лендинг «прыгает» при загрузке. Дальше он молчит и смотрит, куда вы броситесь.

Ловушка в том, что три симптома — три разные болезни. «Долго открывается» лечится размером бандла и сетью. «Подтормаживает при скролле» — количеством DOM-узлов и работой в главном потоке. «Прыгает» — это CLS, и лечится он атрибутами width/height у картинок. Мемоизация не поможет ни в одном из трёх случаев. Поэтому единственный сильный ответ — «давайте посмотрим на цифры», и дальше показать, что вы знаете, куда смотреть.

Шаг 0. Измерять, а не гадать

Назовите инструменты и что именно каждый показывает — это половина оценки за вопрос.

ИнструментЧто показывает
Lighthouseлабораторный прогон: LCP, CLS, вес ресурсов, быстрый чек-лист
DevTools → Performanceчто реально ест главный поток: длинные задачи, reflow, ваши функции
DevTools → Networkводопад загрузки: что грузится, в каком порядке, сколько весит
DevTools → Coverageсколько процентов скачанного JS/CSS вообще не использовано
React DevTools Profilerкакие компоненты рендерятся и почему
Анализатор бандлакто именно раздул сборку (rollup-plugin-visualizer, source-map-explorer)
RUM / web-vitalsцифры реальных пользователей, а не вашего MacBook

Обязательно разделите лабораторию и поле: Lighthouse на быстром ноутбуке покажет «всё зелёное», а у пользователя на среднем Android и 4G — совсем другая картина. Метрики, которыми меряют, называются Core Web Vitals: LCP (когда отрисовался самый крупный элемент, цель ≤ 2,5 с), INP (насколько быстро интерфейс отвечает на клик, ≤ 200 мс) и CLS (насколько «прыгает» вёрстка, ≤ 0,1).

Отчёт анализатора бандла обычно и есть готовый список виноватых:

dist/assets/vendor-3f1a2b.js   612 kB   (gzip 198 kB)
  moment + все локали          290 kB   ← нужен только формат даты
  lodash (импорт целиком)      ~70 kB   ← используется 3 функции
  chart.js                     180 kB   ← нужен на одной странице из двенадцати
dist/assets/index-9c4d1e.js    184 kB   (gzip  58 kB)

Три строки — три готовых решения: заменить moment на Intl.DateTimeFormat или date-fns, импортировать из lodash-es точечно, а chart.js вынести в отдельный чанк.

Бандл и code splitting

Самый частый диагноз «долго открывается» — пользователь качает весь код приложения, чтобы увидеть форму логина. Лечится разбиением по маршрутам.

import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';

// Отдельный чанк: код отчётов скачается, только когда на них зайдут
const ReportsPage = lazy(() => import('./pages/ReportsPage'));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/reports" element={<ReportsPage />} />
      </Routes>
    </Suspense>
  );
}

Дальше идут вещи, которые дают выигрыш без единой строки бизнес-логики:

  • Тree shaking работает только с ES-модулями. import _ from 'lodash' тянет всё; import debounce from 'lodash-es/debounce' — только нужное.
  • Тяжёлое — по требованию. Редактор кода, графики, PDF-экспорт грузим через await import('...') в момент клика, а не при старте.
  • Считайте вес в gzip/brotli, а не в килобайтах исходника — интервьюер это оценит.

Ленивая загрузка и картинки

Самый дешёвый выигрыш во всём фронтенде — правильный тег <img>.

<!-- LCP-картинка: грузим в приоритете, лениво НЕ делаем -->
<img src="/hero-800.avif" width="800" height="450" fetchpriority="high" alt="Обложка">

<!-- Всё, что ниже сгиба: откладываем -->
<img src="/card-400.avif"
     srcset="/card-400.avif 400w, /card-800.avif 800w"
     sizes="(max-width: 600px) 100vw, 400px"
     width="400" height="300"
     loading="lazy" decoding="async" alt="Карточка товара">

Что здесь важно проговорить вслух: width и height — не про размер на экране, а про пропорции: браузер заранее резервирует место, и вёрстка перестаёт прыгать (это и есть починка CLS). srcset не даёт грузить картинку 1600px на телефон. Формат AVIF/WebP экономит 30–50% против JPEG. А loading="lazy" на главной картинке — ошибка: вы своими руками замедлите LCP.

Виртуализация длинных списков

«В таблице 10 000 строк, скролл дёргается» — вопрос-классика. Причина не в React: в DOM физически лежат десятки тысяч узлов, и браузер на каждый кадр пересчитывает их геометрию. Решение — рендерить только то, что видно, и подменять содержимое при скролле.

const total = 10000;      // всего строк
const rowHeight = 32;     // высота строки, px
const viewport = 640;     // высота видимой области, px
const overscan = 3;       // запас сверху и снизу, чтобы не мигало
const scrollTop = 4000;   // пользователь пролистал на 4000px

const first = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
const count = Math.ceil(viewport / rowHeight) + overscan * 2;
const last = Math.min(total - 1, first + count);

console.log('Всего строк:', total);
console.log('Рендерим строки с', first, 'по', last);
console.log('Узлов в DOM:', last - first + 1, 'вместо', total);

Результат:

Всего строк: 10000
Рендерим строки с 122 по 148
Узлов в DOM: 27 вместо 10000

Двадцать семь узлов вместо десяти тысяч — вот и весь секрет. Ровно это делают react-window и @tanstack/react-virtual: контейнеру задаётся полная высота (total * rowHeight), чтобы скроллбар был честным, а внутри живёт горстка строк, сдвинутых transform. На собеседовании достаточно объяснить принцип и не забыть про два подвоха: строки переменной высоты требуют измерения, а поиск по странице (Ctrl+F) в виртуальном списке не найдёт то, чего нет в DOM.

Как это работает под капотом

Весь JavaScript, стили и layout выполняются в одном главном потоке. Пока он занят, страница не отвечает на клики — отсюда плохой INP. Задача дольше 50 мс считается «длинной» и видна в Performance полосатым красным блоком. Значит, у вас три рычага: меньше кода (бандл), меньше работы (виртуализация, дебаунс, вынос тяжёлых вычислений в Web Worker) и работа позже (ленивая загрузка).

Ещё одна вещь, о которой стоит сказать: изменение геометрии (width, top, вставка узла) запускает reflow — пересчёт раскладки. Анимации на transform и opacity обходятся композитингом на GPU и не трогают layout — поэтому они плавные, а анимация left дёргается. Это тот самый мостик, которым связывают вопрос про CSS и вопрос про производительность.

И только теперь уместно вспомнить про React.memo, useMemo и useCallback. Это точечные инструменты против лишних рендеров конкретного тяжёлого поддерева — их применяют после профайлера, который показал проблему. Обвешать мемоизацией всё приложение — значит потратить память и усложнить код, не ускорив загрузку ни на миллисекунду.

Частые ошибки на собеседовании

  • Начинать с мемоизации. Она не лечит ни размер бандла, ни картинки, ни CLS.
  • Сыпать названиями без причин. «Я бы добавил SSR и CDN» без вопроса «а что именно тормозит?» звучит как список из статьи.
  • Не различать метрики. LCP — про загрузку, INP — про отзывчивость, CLS — про стабильность. Их лечат разными способами.
  • Ставить loading="lazy" на главную картинку и ухудшать LCP.
  • Не знать, чем измерять. Если кандидат не назвал ни Performance-панель, ни анализатор бандла, всё остальное — теория.
  • Забывать про поле. «У меня локально быстро» — не аргумент: у пользователя средний телефон и мобильная сеть.
  • Оптимизировать до профилирования. Классический признак джуна с амбициями.

Итоги-шпаргалка

  • Первая фраза ответа: «Сначала измерю» — Lighthouse, Performance, Network, Coverage, React Profiler, анализатор бандла, RUM.
  • Три метрики: LCP (≤ 2,5 с), INP (≤ 200 мс), CLS (≤ 0,1) — и разные лекарства для каждой.
  • Бандл: code splitting по маршрутам через lazy + Suspense, точечные импорты, тяжёлые библиотеки — по требованию.
  • Картинки: AVIF/WebP, srcset/sizes, обязательные width/height против CLS, loading="lazy" — всему, кроме LCP-элемента.
  • Длинные списки: виртуализация — десятки узлов вместо десятков тысяч.
  • Главный поток один: меньше кода, меньше работы, работа позже.
  • React.memo и useMemo — точечный инструмент после профайлера, а не первый ход.
Проверьте себя
1. На собеседовании спрашивают: «Страница отчётов открывается 6 секунд. Ваши действия?» Какой ответ сильнее всех?
AОбернуть все компоненты в React.memo и добавить useCallback
BСразу подключить SSR и CDN
CСначала измерить: посмотреть водопад в Network и отчёт анализатора бандла, найти настоящую причину
DУвеличить max-age у всех ресурсов до года
2. Список из 10 000 строк дёргается при скролле. Что даст наибольший эффект?
AВиртуализация: рендерить только видимые строки плюс небольшой запас
BОбернуть каждую строку в React.memo
CЗаменить map на for и убрать лишние переменные
DДобавить key={index} каждой строке