Оптимизация производительности
Обзор
Сначала измерь, потом оптимизируй. Работа над производительностью без измерений — это гадание, а гадание ведёт к преждевременной оптимизации, которая добавляет сложность, не улучшая того, что важно. Сначала профилируй, найди настоящее узкое место, устрани его, измерь снова. Оптимизируй только то, значимость чего доказана измерениями.
Когда применять
- В спеке есть требования к производительности (бюджеты времени загрузки, SLA по времени ответа)
- Пользователи или мониторинг сообщают о медленной работе
- Показатели Core Web Vitals ниже порогов
- Подозреваешь, что изменение внесло регрессию
- Строишь функциональность, работающую с большими объёмами данных или высоким трафиком
Когда НЕ применять: не оптимизируй, пока нет доказательств проблемы. Преждевременная оптимизация добавляет сложность, которая стоит дороже, чем даёт производительности.
Целевые значения Core Web Vitals
| Метрика | Хорошо | Нужно улучшить | Плохо |
|---|---|---|---|
| LCP (отрисовка крупнейшего элемента) | ≤ 2,5 с | ≤ 4,0 с | > 4,0 с |
| INP (отклик на взаимодействие) | ≤ 200 мс | ≤ 500 мс | > 500 мс |
| CLS (накопленный сдвиг вёрстки) | ≤ 0,1 | ≤ 0,25 | > 0,25 |
Рабочий процесс оптимизации
1. ИЗМЕРИТЬ → Установить базовую линию на реальных данных
2. НАЙТИ → Найти настоящее узкое место (а не предполагаемое)
3. ПОЧИНИТЬ → Устранить конкретное узкое место
4. ПРОВЕРИТЬ → Измерить снова; оставить или откатить
5. ЗАСТРАХОВАТЬ → Добавить мониторинг или тесты, чтобы не было регрессии
Шаг 1: измерить
Два взаимодополняющих подхода — используй оба:
- Синтетика (Lighthouse, вкладка Performance в DevTools): контролируемые условия, воспроизводимость. Лучше всего для отлова регрессий в CI и для изоляции конкретных проблем.
- RUM (библиотека web-vitals, CrUX): данные реальных пользователей в реальных условиях. Необходимы, чтобы подтвердить, что исправление действительно улучшило пользовательский опыт.
Фронтенд:
# Синтетика: Lighthouse в Chrome DevTools (или в CI)
# Chrome DevTools → вкладка Performance → Record
# Chrome DevTools MCP → трассировка производительности
# RUM: библиотека Web Vitals в коде
import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP(console.log);
onINP(console.log);
onCLS(console.log);
Бэкенд:
# Логирование времени ответа
# Мониторинг производительности приложения (APM)
# Логирование запросов к БД с таймингами
# Простое измерение времени
console.time('db-query');
const result = await db.query(...);
console.timeEnd('db-query');
С чего начинать измерения
Пусть симптом подскажет, что мерить первым:
Что тормозит?
├── Первая загрузка страницы
│ ├── Большой бандл? → Измерь размер бандла, проверь разбиение кода
│ ├── Медленный ответ сервера? → Измерь TTFB в водопаде сети DevTools
│ │ ├── Долгий DNS? → Добавь dns-prefetch / preconnect для известных источников
│ │ ├── Долгий TCP/TLS? → Включи HTTP/2, проверь выкатку на край сети, keep-alive
│ │ └── Долгое ожидание (сервер)? → Профилируй бэкенд, проверь запросы и кеширование
│ └── Ресурсы, блокирующие отрисовку? → Проверь водопад сети на блокирующие CSS/JS
├── Взаимодействие ощущается вязким
│ ├── Интерфейс подвисает при клике? → Профилируй основной поток, ищи длинные задачи (>50 мс)
│ ├── Задержка ввода в форме? → Проверь перерисовки, накладные расходы управляемых компонентов
│ └── Рывки анимации? → Проверь избыточные перерасчёты вёрстки, принудительные reflow
├── Страница после перехода
│ ├── Загрузка данных? → Измерь время ответа API, проверь на водопады запросов
│ └── Отрисовка на клиенте? → Профилируй время отрисовки компонентов, проверь на N+1 запросы
└── Бэкенд / API
├── Тормозит один эндпоинт? → Профилируй запросы к БД, проверь индексы
├── Тормозят все эндпоинты? → Проверь пул соединений, память, CPU
└── Периодические подтормаживания? → Проверь конкуренцию за блокировки, паузы сборщика мусора, внешние зависимости
Шаг 2: найти узкое место
Типовые узкие места по категориям:
Фронтенд:
| Симптом | Вероятная причина | Что смотреть |
|---|---|---|
| Медленный LCP | Крупные изображения, ресурсы, блокирующие отрисовку, медленный сервер | Водопад сети, размеры изображений |
| Высокий CLS | Изображения без размеров, поздно подгружаемый контент, сдвиги шрифтов | Атрибуцию сдвигов вёрстки |
| Плохой INP | Тяжёлый JavaScript в основном потоке, большие обновления DOM | Длинные задачи в трассировке производительности |
| Медленная первая загрузка | Большой бандл, много сетевых запросов | Размер бандла, разбиение кода |
Бэкенд:
| Симптом | Вероятная причина | Что смотреть |
|---|---|---|
| Медленные ответы API | Запросы N+1, отсутствующие индексы, неоптимизированные запросы | Лог запросов к БД |
| Рост потребления памяти | Утёкшие ссылки, неограниченные кеши, крупные тела запросов | Анализ снимков кучи |
| Всплески CPU | Синхронные тяжёлые вычисления, откаты в регулярных выражениях | Профилирование CPU |
| Высокие задержки | Отсутствие кеширования, избыточные вычисления, сетевые переходы | Трассировку запросов через весь стек |
Шаг 3: устранить типовые антипаттерны
Запросы N+1 (бэкенд)
// ПЛОХО: N+1 — по одному запросу на владельца каждой задачи
const tasks = await db.tasks.findMany();
for (const task of tasks) {
task.owner = await db.users.findUnique({ where: { id: task.ownerId } });
}
// ХОРОШО: один запрос с соединением/включением
const tasks = await db.tasks.findMany({
include: { owner: true },
});
Неограниченная выборка данных
// ПЛОХО: выбираем все записи
const allTasks = await db.tasks.findMany();
// ХОРОШО: постранично с ограничениями
const tasks = await db.tasks.findMany({
take: 20,
skip: (page - 1) * 20,
orderBy: { createdAt: 'desc' },
});
Неоптимизированные изображения (фронтенд)
<!-- ПЛОХО: нет размеров, нет оптимизации формата -->
<img src="/hero.jpg" />
<!-- ХОРОШО: главное изображение (LCP) — арт-дирекция + переключение разрешений, высокий приоритет -->
<!--
Совмещены две техники:
- Арт-дирекция (media): разная обрезка/композиция на разных контрольных точках
- Переключение разрешений (srcset + sizes): нужный размер файла под плотность экрана
-->
<picture>
<!-- Мобильные: портретная обрезка (8:10) -->
<source
media="(max-width: 767px)"
srcset="/hero-mobile-400.avif 400w, /hero-mobile-800.avif 800w"
sizes="100vw"
width="800"
height="1000"
type="image/avif"
/>
<source
media="(max-width: 767px)"
srcset="/hero-mobile-400.webp 400w, /hero-mobile-800.webp 800w"
sizes="100vw"
width="800"
height="1000"
type="image/webp"
/>
<!-- Десктоп: ландшафтная обрезка (2:1) -->
<source
srcset="/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-1600.avif 1600w"
sizes="(max-width: 1200px) 100vw, 1200px"
width="1200"
height="600"
type="image/avif"
/>
<source
srcset="/hero-800.webp 800w, /hero-1200.webp 1200w, /hero-1600.webp 1600w"
sizes="(max-width: 1200px) 100vw, 1200px"
width="1200"
height="600"
type="image/webp"
/>
<img
src="/hero-desktop.jpg"
width="1200"
height="600"
fetchpriority="high"
alt="Hero image description"
/>
</picture>
<!-- ХОРОШО: изображение ниже первого экрана — отложенная загрузка + асинхронное декодирование -->
<img
src="/content.webp"
width="800"
height="400"
loading="lazy"
decoding="async"
alt="Content image description"
/>
Лишние перерисовки (React)
// ПЛОХО: создаёт новый объект на каждой отрисовке, заставляя детей перерисовываться
function TaskList() {
return <TaskFilters options={{ sortBy: 'date', order: 'desc' }} />;
}
// ХОРОШО: стабильная ссылка
const DEFAULT_OPTIONS = { sortBy: 'date', order: 'desc' } as const;
function TaskList() {
return <TaskFilters options={DEFAULT_OPTIONS} />;
}
// Используй React.memo для дорогих компонентов
const TaskItem = React.memo(function TaskItem({ task }: Props) {
return <div>{/* дорогая отрисовка */}</div>;
});
// Используй useMemo для дорогих вычислений
function TaskStats({ tasks }: Props) {
const stats = useMemo(() => calculateStats(tasks), [tasks]);
return <div>{stats.completed} / {stats.total}</div>;
}
Большой размер бандла
// Современные сборщики (Vite, webpack 5+) сами обрабатывают именованные импорты с отсечением
// неиспользуемого кода, при условии что зависимость поставляется в ESM и помечена
// `sideEffects: false` в package.json.
// Профилируй, прежде чем менять стиль импортов, — настоящий выигрыш даёт разбиение и отложенная загрузка.
// ХОРОШО: динамический импорт для тяжёлой, редко используемой функциональности
const ChartLibrary = lazy(() => import('./ChartLibrary'));
// ХОРОШО: разбиение кода по маршрутам, обёрнутое в Suspense
const SettingsPage = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<Spinner />}>
<SettingsPage />
</Suspense>
);
}
Отсутствие кеширования (бэкенд)
// Кешируй часто читаемые, редко меняющиеся данные
const CACHE_TTL = 5 * 60 * 1000; // 5 минут
let cachedConfig: AppConfig | null = null;
let cacheExpiry = 0;
async function getAppConfig(): Promise<AppConfig> {
if (cachedConfig && Date.now() < cacheExpiry) {
return cachedConfig;
}
cachedConfig = await db.config.findFirst();
cacheExpiry = Date.now() + CACHE_TTL;
return cachedConfig;
}
// Заголовки HTTP-кеширования для статики
app.use('/static', express.static('public', {
maxAge: '1y', // Кешировать на год
immutable: true, // Никогда не перепроверять (используй хеш содержимого в именах файлов)
}));
// Cache-Control для ответов API
res.set('Cache-Control', 'public, max-age=300'); // 5 минут
Шаг 4: проверить (оставить или откатить)
Исправление остаётся гипотезой, пока ты не измерил заново. Этот шаг решает, выживет ли оно.
Измеряй повторно так же, как измерял базовую линию: та же команда, те же условия, тот же фиксированный бюджет (время по часам, число выборок или число запросов). Базовая линия на холодном кеше против результата на прогретом измеряет кеш, а не твоё изменение.
Меняй по одной вещи за раз. Три оптимизации, приехавшие вместе, дают одно число, и приписать его чему-то конкретному нельзя. Если они обязаны ехать вместе, сначала измерь каждую в изоляции.
Побеждай шум, а не только среднее. Повтори измерение и сравни разницу с разбросом между прогонами. Выигрыш 3% при разбросе ±5% — это не выигрыш, это другая выборка.
Затем решай строго:
| Результат против базовой линии | Действие |
|---|---|
| Порог пройден, тесты зелёные | Оставить. Закоммитить, указав в сообщении цифры «до/после». |
| В пределах шума (измеримых изменений нет) | Откатить. |
| Хуже | Откатить. |
| Лучше, но покраснел тест | Откатить. Это регрессия в одежде победы. |
«Нейтрально» — это откат, а не сохранение. Именно этот шаг команды пропускают: изменение уже написано, выбрасывать жалко, поэтому оно приезжает неизмеренным, и кодовая база обрастает сложностью, которая ничего не купила. Код, который ты оставляешь, ты сопровождаешь вечно. Пусть он себя окупает.
Корректность стоит на воротах перед метрикой. Набор тестов остаётся зелёным, и число сдвигается. «Оптимизация», выигравшая за счёт выброшенной работы, которая продукту была нужна (пропущенная валидация, кеширование того, что обязано быть свежим, удалённый await, который был несущим), — это регрессия, а не победа.
Записывай каждую попытку, включая откаченные
Откаченная работа не оставляет следа в истории git — именно поэтому та же мёртвая идея всплывает снова в следующем квартале. Веди короткий журнал, чтобы отброшенная идея оставалась отброшенной:
| Идея | База → Результат | Вердикт | Почему |
|---|---|---|---|
| Мемоизировать компонент строки | INP 240 мс → 235 мс | откачено | В пределах шума (±15 мс). Строки не были узким местом. |
| Виртуализировать список | INP 240 мс → 90 мс | оставлено | Длинные задачи исчезли из трассировки. |
| Preconnect к источнику API | LCP 2,8 с → 2,8 с | откачено | Он и так на том же источнике. |
Подойдёт и раздел в описании пулл-реквеста, и файл PERF.md в репозитории. Важно, чтобы следующий человек (или следующий агент) прочитал это до того, как предложит эксперимент, и не запустил заново тот, что уже провалился.
Бюджет производительности
Задай бюджеты и следи за их соблюдением:
Бандл JavaScript: < 200 КБ в gzip (первая загрузка)
CSS: < 50 КБ в gzip
Изображения: < 200 КБ на изображение (выше первого сгиба)
Шрифты: < 100 КБ суммарно
Время ответа API: < 200 мс (p95)
Время до интерактивности: < 3,5 с на 4G
Оценка Lighthouse Performance: ≥ 90
Следи за соблюдением в CI:
# Проверка размера бандла
npx bundlesize --config bundlesize.config.json
# Lighthouse CI
npx lhci autorun
См. также
Подробные чеклисты производительности, команды оптимизации и справочник антипаттернов — см. ../../references/performance-checklist.md.
Типовые самооправдания
| Самооправдание | Как на самом деле |
|---|---|
| «Оптимизируем потом» | Долг по производительности нарастает. Очевидные антипаттерны чини сейчас, микрооптимизации откладывай. |
| «На моей машине быстро» | Твоя машина — не машина пользователя. Профилируй на представительном железе и сетях. |
| «Эта оптимизация очевидна» | Если ты не измерял, ты не знаешь. Сначала профилируй. |
| «Пользователи не заметят 100 мс» | Исследования показывают, что задержки в 100 мс влияют на конверсию. Пользователи замечают больше, чем кажется. |
| «Фреймворк сам разберётся с производительностью» | Фреймворки предотвращают часть проблем, но не чинят запросы N+1 и раздутые бандлы. |
| «Помогло немного, но хуже же не стало» | Нейтральные изменения — это откат. Ты платишь за их сопровождение вечно и не получил ничего взамен. |
| «Мы это уже написали, чего теперь выбрасывать» | Невозвратные издержки. Измерению всё равно, сколько времени заняло написание. |
| «Улучшение очевидно, перемерять незачем» | Тогда перемерять дёшево, и это докажет. Неизмеренные победы — это то, как приезжает нейтральная сложность. |
Тревожные признаки
- Оптимизация без данных профилирования, которые её обосновывают
- Паттерны запросов N+1 при выборке данных
- Эндпоинты со списками без пагинации
- Изображения без размеров, без отложенной загрузки и без адаптивных размеров
- Размер бандла растёт без всякого контроля
- В продакшне нет мониторинга производительности
React.memoиuseMemoповсюду (злоупотребление так же плохо, как недоиспользование)- Оптимизации оставлены без повторного измерения, которое их оправдывает
- Несколько оптимизаций объединены в одно измерение, и ни одно изменение нельзя приписать результату
- «Победа», которая потребовала изменить, пропустить или удалить тест
- Одна и та же провалившаяся оптимизация пробуется повторно, потому что никто не записал первую попытку
Проверка
После любого изменения, связанного с производительностью:
- Есть измерения «до» и «после» (конкретные числа)
- Результат измерен тем же способом, что и базовая линия (та же команда, те же условия)
- Улучшение превышает разброс между прогонами, а не только среднее
- Изменения, не побившие базовую линию, откачены, а не оставлены как нейтральные
- Попытки записаны — и оставленные, и откаченные, — чтобы мёртвая идея не запускалась заново
- Конкретное узкое место найдено и устранено
- Core Web Vitals в пределах порогов «Хорошо»
- Размер бандла существенно не вырос
- В новом коде выборки данных нет запросов N+1
- Бюджет производительности проходит в CI (если настроен)
- Существующие тесты по-прежнему проходят (оптимизация не сломала поведение)