DarkRiDDeR13 мин

Производительность первой загрузки: как собрать профиль вместо слова «тяжёлая»

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

Проблема начинается с фразы «страница тяжёлая». В ней нет владельца задержки: сервер мог ответить быстро, но браузер ждёт таблицу стилей; картинка уже пришла, но её не дают нарисовать длинные скрипты; файл JavaScript маленький в gzip, зато его разбор занимает главный поток. Пока все эти случаи называют одним числом «load», команда сжимает не тот ресурс и получает тот же пустой первый экран. Цена ошибки — ещё один релиз без понятного результата и пользователи, которые уходят до полезного содержимого.

Для первой загрузки я не начинаю с набора оптимизаций. Сначала выбираю один сценарий и делаю профиль, в котором четыре владельца времени видны отдельно: документ и сеть, JavaScript на главном потоке, CSS до готового стиля, изображение до декодирования и отрисовки. Это не обещание, что четыре полосы складываются в одну честную сумму. Их работа может пересекаться. Цель профиля проще: назвать следующий проверяемый вопрос, а не угадать виновника по размеру бандла.

Что именно считаем медленной первой загрузкой

У экрана есть несколько разных моментов: браузер получил начало HTML, увидел структуру, получил стили, нарисовал первый полезный контент и смог обработать действие. Между ними нет одной универсальной границы. DOMContentLoaded говорит о завершении разбора документа и defer-скриптов, а не о том, что главный блок уже нарисован. Событие load может ждать второстепенные картинки, которые не помогают пользователю начать работу. Поэтому запись «load за две секунды» не отвечает, почему кнопка или заголовок появились поздно.

В августе 2019 для расследования достаточно открыть DevTools и увидеть водопад, main thread и скриншоты загрузки. При переиздании можно соотнести результат с более поздним словарём LCP: он описывает момент, когда крупное содержание в viewport отрисовано. Но не стоит подменять этим словарём старую проверку и тем более писать вымышленное значение метрики. Если конкретный trace не записан, в заметке остаётся гипотеза и маршрут её проверки, а не число с точностью до миллисекунды.

НаблюдениеВозможный владелецЧего оно не доказываетПервый запрос к данным
HTML быстро пришёл, экран пустойCSS, JavaScript или скрытый критический ресурсЧто origin медленныйСверить responseStart документа с первым screenshot и полосой main thread
Водопад длинныйОдин критический запрос, очередь приоритетов или несколько независимых ресурсовЧто самый большой файл всегда виноватНайти ресурс, без которого не появляется полезный блок
app.js небольшой после сжатияParse, compile и execute JavaScriptЧто код дёшев на слабом устройствеЗаписать trace и посмотреть scripting на main thread
Hero уже скачанDecode, style, layout или занятый main threadЧто изображение стало видимымСвязать URL ресурса со screenshot и событием paint

Профиль не требует сразу добавлять RUM или менять CDN. В первом проходе достаточно одной локальной страницы, одного пути и одной версии сборки. Он полезен именно потому, что ограничен: позже другой инженер может повторить условия и увидеть, какая полоса изменилась. Если смешать мобильный эмулятор, тёплый кэш, авторизованную сессию и три разных URL, сравнение превратится в набор впечатлений.

Фиксируем условия до нажатия Reload

Сначала записываю URL, действие пользователя и что считается полезным экраном. Не «страница открылась», а, например, «заголовок товара, цена и кнопка заказа видимы без прокрутки». Затем фиксирую, очищается ли кэш, есть ли Service Worker, какой viewport и какое ограничение CPU или сети выбрано. Эти параметры не делают лабораторный запуск похожим на каждого реального пользователя, но делают его повторяемым для сравнения двух веток.

Нельзя делать вывод о production только по одной локальной записи. Лабораторный профиль отвечает на вопрос «какой путь браузер прошёл в этих условиях». Полевая телеметрия отвечает на другой вопрос: «какой распределённый опыт получили пользователи». Для исправления конкретного регресса сначала нужен владелец из профиля; для приоритета работы нужна отдельная выборка. Смешивать эти доказательства — значит выдать диагностический опыт за статистику.

function collectLoadingEntries() {
  const navigation = performance.getEntriesByType("navigation")[0];
  const resources = performance.getEntriesByType("resource").map((entry) => ({
    name: new URL(entry.name).pathname,
    type: entry.initiatorType,
    duration: Math.round(entry.duration),
    transferSize: entry.transferSize,
    encodedBodySize: entry.encodedBodySize,
  }));

  return {
    navigation: navigation && {
      responseStart: Math.round(navigation.responseStart),
      domInteractive: Math.round(navigation.domInteractive),
      domContentLoadedEnd: Math.round(navigation.domContentLoadedEventEnd),
    },
    resources,
  };
}

console.table(collectLoadingEntries().resources);

Этот код не измеряет CSSOM или время выполнения скрипта: он берёт только записи navigation и resource. Это намеренное ограничение. Из него можно увидеть тип ресурса, длительность и размеры там, где браузер имеет право раскрыть их. Для ресурсов с другого origin подробные поля могут быть нулевыми без Timing-Allow-Origin. Нулевое DNS или connect время также не доказывает, что сети не было: соединение могло быть переиспользовано или значение ограничено политикой доступа.

Сохраняйте результат с версией сборки и условиями, но не отправляйте в общий лог полный URL с пользовательскими параметрами. Для локальной диагностики обычно хватает пути, типа инициатора и округлённых времён. Если нужен отчёт для команды, приложите screenshot с моментом появления полезного блока и короткое пояснение: какая гипотеза проверялась, что действительно измерено и что пока неизвестно.

Разводим сеть, JavaScript, CSS и изображение

У документа и ресурса есть свой водопад: redirect, DNS, connect, запрос и ответ. Это сетевой слой, но даже его нельзя сократить до transferSize. Два одинаковых файла получают разную задержку из-за origin, очереди, приоритета, повторного соединения или кэша. Если критическая картинка обнаруживается только после выполнения скрипта, её поздний старт выглядит сетевой проблемой, хотя сначала надо проверить путь обнаружения в HTML и CSS.

JavaScript проверяю не размером файла, а временем на main thread после его прихода. В trace ищу длинные фрагменты scripting и связываю их с конкретным ресурсом или функцией через source map, если она доступна. CSS проверяю отдельно: таблица стилей может блокировать расчёт стиля и первый рендер, а большой DOM может удлинить style и layout. У изображения два шага: загрузка байтов и декодирование с отрисовкой. Сжатие файла полезно только если оно попало в доказанный критический участок.

Профиль первой загрузки с четырьмя самостоятельными дорожками: документ и сеть, JavaScript на main thread, CSS до готового стиля и изображение до decode и paint
Одна фраза «тяжёлая страница» раскладывается на четыре владельца времени. Дорожки могут пересекаться, поэтому их нельзя бездумно суммировать.

Контролируемый fixture проверяет классификацию, а не скорость сайта

Чтобы не спорить о том, как отчёт группирует данные, в этом модуле есть детерминированный fixture. В нём четыре записи: response документа, работа JavaScript, построение CSSOM и decode hero-изображения. Скрипт складывает байты и длительности по владельцу и проверяет, что в результате действительно четыре независимые группы. Fixture не открывает браузер, не делает HTTP-запрос и не измеряет этот сайт. Его доказательство узкое: классификатор не потерял CSS внутри JavaScript и не выдал картинку за сеть.

// Из каталога web/:
// node scripts/upgrade-2019-08.mjs --run-fixture

{
  "owners": {
    "network": { "milliseconds": 180, "bytes": 18000 },
    "javascript": { "milliseconds": 165, "bytes": 96000 },
    "css": { "milliseconds": 90, "bytes": 24000 },
    "image": { "milliseconds": 45, "bytes": 72000 }
  },
  "totalBytes": 210000
}

Числа fixture не являются бюджетом и не являются результатом trace. Они выбраны так, чтобы тест ловил ошибку группировки. Например, если код отнесёт decode картинки к JavaScript, у результата исчезнет владелец image, и fixture упадёт. Для реальной страницы после этого всё равно нужен отдельный Reload в DevTools: только он покажет, перекрывались ли операции, какой URL был критическим и где браузер действительно потратил время.

Собираем короткий отчёт, пригодный для следующего запуска

Полезный отчёт помещается в несколько строк. Первая строка — условия: путь, кэш, viewport, throttle, хэш сборки. Вторая — наблюдение: «HTML получил ответ до первого screenshot, а полезный блок появился после scripting». Третья — конкретный владелец и ссылка на дорожку: «main thread: модуль checkout.js, участок parse плюс execute». Четвёртая — одна гипотеза изменения и критерий проверки. Если отчёт не называет владельца, он не помогает выбрать работу.

Не добавляйте в него слово «ускорили», пока не повторили профиль при тех же условиях. Например, перенос второстепенного виджета за событие пользователя может уменьшить scripting до полезного блока, но одновременно увеличить network idle. Это хороший обмен, если экран стал полезен раньше; это плохой аргумент, если измерен только размер одного чанка. Важно заранее зафиксировать, какой момент загрузки должен сдвинуться и какой вторичный эффект допустим.

Фрагмент отчётаПлохая записьПроверяемая запись
СимптомДолго грузитсяКарточка появляется после длинного scripting, хотя ответ HTML уже получен
ПричинаМного JavaScriptВ trace участок main thread привязан к модулю фильтров; это гипотеза до source-map проверки
ДействиеОптимизировать бандлНе загружать модуль подсказок до первого взаимодействия и оставить проверку fallback
КритерийСтало лучшеПовторить тот же Reload и сравнить момент полезного блока и длительность участка scripting

Меняем один критический путь за раз

Когда владелец назван, действие становится обычной инженерной работой. Для сети это может быть устранение лишнего redirect, раннее обнаружение ресурса или перенос критического файла на подходящий origin. Для JavaScript — разделение entry, удаление неиспользуемой ветки, откладывание виджета или уменьшение синхронной инициализации. Для CSS — критичный минимум, порядок подключения и уменьшение селекторов или DOM там, где trace показал style и layout. Для изображения — правильный размер, формат, ранний URL и отказ от lazy loading именно у первого значимого изображения.

Ни одна из этих мер не универсальна. preload помогает только ресурсу, который действительно нужен первому экрану; лишние preload конкурируют за сеть. Code splitting помогает, если код перестал быть частью начального пути; если модуль сразу нужен для рендера, дополнительный запрос может ухудшить ситуацию. Оптимизация картинки не поможет, если она уже скачана и ждёт занятый main thread. Поэтому после каждого изменения возвращаемся к тому же профилю, а не переносим удачную технику на все ресурсы подряд.

  1. Сформулировать полезный первый экран и зафиксировать URL, кэш, viewport, throttle и хэш сборки.
  2. Записать один Reload в DevTools с Network, Performance и screenshot, не называя результат production-метрикой.
  3. Разметить в отчёте документ, критический ресурс, участки scripting, style или layout и момент появления полезного блока.
  4. Выбрать одного владельца и одно изменение, которое должно сдвинуть конкретную полосу, а не весь мир сразу.
  5. Повторить тот же сценарий, сравнить только заявленный критерий и записать побочный эффект.
  6. Лишь после устойчивого лабораторного результата решать, нужна ли полевая метрика или выпускная проверка на реальных устройствах.

Итог: профиль превращает жалобу в маршрут

Первая загрузка не становится понятной от одного Lighthouse-числа, веса JavaScript или события load. Нужна короткая карта: что браузер получил, что обнаружил поздно, что заняло главный поток и что не успело появиться на экране. Такая карта не требует большой платформы наблюдаемости, но запрещает прятать разные причины под слово «тяжёлая».

В этом упражнении нет заявленного trace конкретного сайта и нет обещания универсального порога. Есть воспроизводимый fixture для классификации и маршрут для настоящего профиля в браузере. Следующая статья разберёт, почему документ, CSS, JavaScript и изображение образуют зависимую критическую цепочку даже тогда, когда отдельные запросы выглядят быстрыми.

Проверяемые источники

  • MDN: Navigation Timing — объект navigation entry и границы загрузки документа, DOM и обработчиков события загрузки
  • MDN: PerformanceResourceTiming — состав времён и размеров отдельных ресурсов, включая transferSize, encodedBodySize и ограничения кросс-доменных записей
  • MDN: Performance data — типы записей Performance API и смысл developer marks и measures
  • Chrome DevTools: Inspect network activity — разделение запросов по типам, фильтры и проверка водопада без догадки по одному общему времени загрузки
  • Chrome DevTools: Performance features reference — как trace показывает работу loading, scripting, rendering и painting, а также custom marks
  • web.dev: Web Vitals — современная рамка пользовательских метрик; в переиздании она помогает не смешивать скорость отображения с одним сетевым числом
  • web.dev: Optimize Largest Contentful Paint — разделяет TTFB, задержку старта критического ресурса, его загрузку и задержку отрисовки; это позднейшая терминология для проверки гипотезы, а не выданная за отчёт 2019 года