DarkRiDDeR14 мин

Разбор первой загрузки: быстрый HTML, пустой экран и неверный фикс

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

Симптом: карточка товара открывается, серверный лог показывает короткий ответ HTML, но пользователь несколько секунд видит фон и каркас без товара. Первое поспешное решение — сжать hero-изображение. Оно может не изменить экран вообще, если изображение уже скачано и ждёт выполнения стартового JavaScript. Обратная ошибка тоже частая: вынести код в другой чанк, хотя картинка вообще не была обнаружена до запуска приложения. Цена такого поиска — серия случайных правок, которые нельзя объяснить следующему разработчику.

Ниже учебный разбор, а не trace реального сайта. Для контролируемого профиля мы задаём четыре независимые полосы: документ и сеть — 180 ms, JavaScript — 165 ms, CSS — 90 ms, hero после загрузки — 45 ms. Эти числа существуют только в fixture модуля и проверяют, что отчёт не смешивает владельцев. Они не складываются в «настоящие 480 ms», не описывают устройство пользователя и не дают права писать о production-результате. На их основе можно честно отрепетировать порядок расследования.

Фиксируем учебный сценарий и границу полезности

Полезным экраном в этом случае считаем три вещи: название товара, цену и hero-изображение рядом с кнопкой заказа. Spinner не считается результатом: он говорит только, что код начал работу. Это определение важно, потому что команда иначе может улучшить момент появления skeleton и объявить победу, хотя покупатель всё ещё не знает, что покупает. До любых изменений фиксируем тот же URL, тот же вариант страницы, состояние кэша и viewport.

В controlled fixture документ уже получил ответ, CSS имеет отдельный этап, JavaScript — отдельную работу, изображение — отдельные байты и decode. Так мы заранее не объявляем один слой главным. Если после настоящего Reload окажется, что hero вообще не на первом экране или содержимое текстовое, сценарий меняется: диагностика всегда начинается с фактического визуального критерия, а не с названия файла hero.webp.

Полоса fixtureДанные fixtureЧто можно утверждатьЧто утверждать нельзя
Документ и сеть180 ms, 18 000 bytesКлассификатор выделил ответ документа отдельным владельцемЧто origin конкретного сайта отвечает за 180 ms
JavaScript165 ms, 96 000 bytesОтдельно учтён parse и execute app.js в учебном профилеЧто любой bundle такого размера блокирует ровно 165 ms
CSS90 ms, 24 000 bytesCSS не потерян среди сетевых или JS-данныхЧто stylesheet в реальном браузере всегда блокирует весь этот интервал
Hero45 ms, 72 000 bytesЗагрузка и decode изображения имеют свой владелецЧто responseEnd равен моменту видимости изображения

Такая таблица полезна именно своей скромностью. Она не говорит, какая полоса длиннее на устройстве пользователя, и не добавляет несуществующий waterfall. Она даёт контракт для инструмента и для автора статьи: в дальнейших фразах сеть означает сетевые записи, JavaScript — работу main thread, CSS — готовность стилей, изображение — путь до paint. Если фактический trace позже покажет перекрытие, модель не сломается: полосы всё равно остаются разными владельцами.

Первый вопрос: какой ресурс открывает полезный экран

В настоящем проекте я бы начал не с главного чанка, а с DOM и screenshot. Есть ли title и price в исходном HTML? Есть ли у hero прямой src или URL появляется в состоянии приложения? Не скрывает ли контейнер класс is-loading до завершения bootstrap? Эти вопросы часто дают результат быстрее, чем сортировка Network по размеру: если полезный блок создаётся только после renderProduct(), его картинка физически не могла стартовать раньше выполнения этого кода.

После этого проверяем waterfall в двух направлениях. От документа вперёд: когда открылись CSS, app.js и hero. От полезного элемента назад: какой URL, стиль и код нужны именно ему. Если hero начинает запрос после app.js, фиксируем не «медленную сеть», а позднее обнаружение. Если hero стартует рано, но screenshot меняется поздно, сеть перестаёт быть первой гипотезой: смотрим main thread, decode и скрывающую логику.

const hero = document.querySelector("[data-product-hero]");
const css = document.querySelector("link[href*=app.css]");

console.table({
  heroSrc: hero && hero.currentSrc,
  heroComplete: hero && hero.complete,
  heroNaturalWidth: hero && hero.naturalWidth,
  stylesheetLoaded: css && css.sheet !== null,
  productHidden: document.querySelector(".product.is-loading") !== null,
});

Этот фрагмент — локальная проверка состояния после загрузки, не автоматический benchmark. img.complete не доказывает, что изображение уже показано пользователю, а link.sheet не отвечает на вопрос о стоимости layout. Зато он быстро отделяет ситуацию «URL отсутствует или изображение не готово» от ситуации «ресурс уже доступен, ищем работу рендера». Если запускать его поздно вручную, фиксируйте это в заметке: console-проверка после события не воспроизводит точный момент первого paint.

Вторая проверка: не держит ли экран стартовый JavaScript

Представим, что waterfall показывает ранний hero и завершённый CSS, но полезный screenshot всё равно появляется после блока scripting. Тогда цель — не «разбить всё на чанки», а найти работу, которая происходит до renderProduct. Это может быть инициализация фильтров, формирование рекомендаций, синхронный разбор большой конфигурации или сторонний виджет. Любая из этих функций имеет другой безопасный момент запуска и другой риск для поведения страницы.

В Chrome DevTools выбираем участок main thread между окончанием критического запроса и появлением нужного screenshot. Затем смотрим Bottom-up или Call Tree, если source map позволяет увидеть исходные функции. Без source map не подставляем название модуля из фантазии: пишем путь скомпилированного ресурса и оставляем задачу на сопоставление. Отсутствие точного имени — не повод вернуться к догадке по весу бандла.

Факт после ReloadСледующая гипотезаМинимальный экспериментКритерий
Hero и CSS стартовали рано, перед экраном длинный scriptingBootstrap выполняет некритичную работуВременно убрать второстепенный виджет из initial pathСдвигается момент полезного screenshot и сокращается участок scripting
Hero стартовал после app.jsURL создаётся приложениемПоказать URL в HTML или проверить preload только для heroНа waterfall запрос hero начинается раньше при тех же условиях
Hero завершился, но экран меняется после style/layoutDOM или классы создают поздний renderСгруппировать записи DOM и убрать лишний ранний layoutУменьшается work между ресурсом и paint
CSS заканчивается поздноСтиль найден поздно или конкурирует за сетьПроверить порядок link и import-цепочкуCSS приходит до нужного визуального этапа без новых ошибок стиля

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

Третья проверка: CSS и изображение не завершаются в один момент

Даже в аккуратном водопаде нельзя считать responseEnd финишем визуальной работы. Браузер должен применить стиль, рассчитать геометрию, при необходимости декодировать изображение и отрисовать кадр. Если JavaScript несколько раз читает размеры и тут же меняет классы, он может создавать повторные layout между готовым hero и paint. В этом случае перекодировка картинки даст небольшой сетевой выигрыш, но проблема полезного экрана останется в main thread.

С другой стороны, картинка может быть действительно слишком поздней: URL находится в background-image внешнего CSS, viewport получает неподходящий большой вариант или первый элемент помечен lazy loading. Проверка должна назвать один из этих фактов. У URL в CSS нет автоматического права на preload: сначала убедитесь, что это тот элемент, который нужен без прокрутки. Когда это доказано, раннее объявление ресурса или изменение разметки становятся осмысленным действием, а не массовой настройкой.

Дерево диагностики первого экрана: от полезного screenshot к четырём ветвям — позднее обнаружение в сети, JavaScript на main thread, CSS и layout, изображение с decode и paint
Диагностика начинается от наблюдаемого полезного экрана. Каждая ветвь задаёт свой минимальный эксперимент и не выдаёт учебный fixture за production-трассу.

Проверяем изменение тем же маршрутом, а не одним размером файла

Допустим, trace подтвердил, что блок рекомендаций синхронно строится до карточки. После переноса его за первую отрисовку нельзя останавливаться на уменьшении initial chunk. Повторяем Reload в тех же условиях и смотрим четыре вещи: появился ли полезный screenshot раньше, исчезла ли конкретная работа scripting, не стартовал ли hero позже из-за нового порядка, не сломалась ли карточка без рекомендаций. Только такой набор позволяет отличить улучшение критического пути от перемещения задержки в соседнюю полосу.

Если изменение касается CSS, дополнительно проверяем отсутствие скачка layout и доступность контента без внешнего файла. Если касается изображения — его фактические размеры, корректный alt и fallback. Если касается загрузки модуля — состояние ошибки и медленную сеть. Производительность первой загрузки не освобождает от корректности: пустой, но быстрый экран не считается результатом. В 2019 это особенно важно для постепенных улучшений поверх существующего интерфейса, а не для демонстрации красивого измерения.

performance.mark("product: shell-visible");
showProductShell();

loadRecommendationsLater().catch(() => {
  // Карточка уже полезна; ошибка второстепенного блока не скрывает цену и заказ.
  showRecommendationFallback();
});

performance.mark("product: recommendations-scheduled");
const navigation = performance.getEntriesByType("navigation")[0];
const shellVisible = performance.getEntriesByName("product: shell-visible")[0];
console.log("shell after navigation", Math.round(
  shellVisible.startTime - navigation.startTime,
));

Название initial-shell здесь важно: измеряется момент, когда показали каркас продукта, а не вся страница и не подтверждённая пользовательская метрика. Для trace можно использовать performance.mark как ориентир рядом с Network и main thread. Но значение mark не становится доказательством, что контент полезен, пока это не подтверждено заранее определённым DOM и screenshot-критерием. Так команда не подменяет результат измерением удобной, но пустой стадии.

Маршрут выпуска без ложного production-отчёта

Перед выпуском инженер может написать честный итог: «В лабораторном Reload при таких-то условиях полезный блок появился раньше после переноса второстепенного модуля; отдельно проверены Network, main thread и fallback». Он не должен писать «все пользователи получили минус 300 ms», если полевая выборка не собиралась. Для поля нужны отдельные согласованные метрики, сегменты устройств и правила приватности. Лабораторный результат остаётся основанием для merge конкретной правки, а не заменой аналитики.

Если профиль не дал однозначной причины, это тоже нормальный итог. Сохраняем trace, условия и исключённые гипотезы: hero стартует рано, CSS готов до первого screenshot, но source map отсутствует для долгого scripting. Следующая задача тогда конкретна — восстановить карту исходников или изолировать функцию, а не повторять сжатие изображений. Неопределённость уменьшается фактами, а не более ярким заголовком оптимизации.

  1. Назвать полезный первый экран и записать его DOM-признаки до открытия DevTools.
  2. Снять один повторяемый Reload с фиксированными условиями, сохранив Network, screenshot и main-thread trace.
  3. Проверить, когда стартовали HTML, CSS, app.js и нужное изображение, не используя размер файла как приговор.
  4. Найти раннюю разорванную границу: позднее обнаружение, long scripting, style/layout или decode и paint.
  5. Сделать один обратимый эксперимент и проверить конкретный ожидаемый сдвиг, а также fallback и визуальную корректность.
  6. Сформулировать лабораторный вывод с его пределами; production-числа добавлять только после отдельного сбора полевых данных.

Итог: быстрый ответ — это только начало расследования

Учебный fixture показывает важный навык: даже когда каждая полоса имеет число, не надо строить из них фальшивую общую скорость. Первая загрузка становится полезной после зависимой работы HTML, сети, CSS, JavaScript, изображения и paint. У каждой части есть собственный инструмент проверки и собственный способ сломаться.

Поэтому хороший разбор начинается с видимого критерия, идёт назад по зависимостям и заканчивается одним воспроизводимым изменением. В нём есть точные ограничения: fixture не является browser trace, trace не является полевой статистикой, а маленький бандл не равен быстрому экрану. Такая дисциплина делает следующую оптимизацию короче, потому что она отвечает на конкретный вопрос, а не на жалобу «страница тяжёлая».

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

  • 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 года