Оптимизация фронтенда на собеседовании
«Приложение тормозит. Ваши действия?» Кандидат, который сразу начинает про 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— точечный инструмент после профайлера, а не первый ход.