Веб-версия PageSpeed показывает оценку и подсказки, но не отвечает на главный вопрос: что именно страница успела запросить до первой отрисовки. Этот ответ есть в полном отчёте Lighthouse — того же движка, на котором работает PageSpeed. Его можно запустить у себя одной командой, получить JSON и разобрать до конкретных файлов.
Это третья часть цикла «PageSpeed без мифов». В первой мы разобрали, что оценка — это модель, во второй — формулу, которой модель проверяется. Теперь — инструмент, который даёт для формулы цифры.
Зачем мерить у себя, а не на сайте PageSpeed
- Полный отчёт. Список всех запросов со временем старта, наблюдаемые и модельные метрики, фазы LCP — всё в одном JSON.
- Сколько угодно прогонов. Публичное API PageSpeed быстро упирается в лимит, а выводы, как мы разберём в четвёртой части, делаются только по серии прогонов.
- Тот же результат. Локальный прогон воспроизводит оценку PageSpeed: на нашем каталоге — 69 против 69, LCP 12,8 против 12,2 с.
- Можно проверить правку до выката. Сохранить HTML страницы, поправить, отдать локальным сервером и замерить.
Запуск
Нужны Node.js 18+ и установленный Chrome. Lighthouse ставить не обязательно — его подтянет npx:
npx lighthouse@12 https://example.ru/catalog/ \
--only-categories=performance \
--chrome-flags="--headless=new" \
--output=json --output-path=lh.json --quiet
По умолчанию это мобильная проверка с той же моделью, что у PageSpeed: эмулированный телефон, сеть 1,6 Мбит/с, процессор в 4 раза медленнее. Для десктопа добавьте --preset=desktop. Нужен ещё и HTML-отчёт для глаз — --output=json --output=html.
Перед прогоном откройте страницу один раз. Если на сайте кэш страниц, первый заход после сброса отдаётся медленно, и Lighthouse зафиксирует «регрессию», которой нет у живых посетителей.
Что лежит в JSON
| Где | Что |
|---|---|
categories.performance.score | оценка от 0 до 1 |
audits.metrics.details.items[0] | все метрики сразу: модельные (largestContentfulPaint, speedIndex…) и наблюдаемые (observedFirstContentfulPaint, observedLargestContentfulPaint…) |
audits["network-requests"] | каждый запрос: адрес, тип, вес, время старта |
audits["largest-contentful-paint-element"] | какой элемент стал LCP и фазы: ответ сервера, задержка загрузки, загрузка, задержка отрисовки |
Скрипт: что попало в окно первой отрисовки
Двадцать строк, которые превращают отчёт в список подозреваемых. Сохраните как lh-window.js рядом с отчётом:
// lh-window.js — что ушло в сеть до отрисовки крупнейшего элемента
// запуск: node lh-window.js lh.json
const d = require(require("path").resolve(process.argv[2] || "lh.json"));
const a = d.audits;
const m = a.metrics.details.items[0];
const net = a["network-requests"].details.items;
const lcp = m.observedLargestContentfulPaint;
const kb = b => Math.round((b || 0) / 1024);
console.log("оценка:", Math.round(d.categories.performance.score * 100));
console.log("LCP в отчёте:", (m.largestContentfulPaint / 1000).toFixed(1), "с");
console.log("наблюдаемые FCP / LCP:", m.observedFirstContentfulPaint, "/", lcp, "мс");
const before = net.filter(r => r.networkRequestTime < lcp);
const total = before.reduce((s, r) => s + (r.transferSize || 0), 0);
console.log(`в окне: ${before.length} из ${net.length} запросов, ${kb(total)} КБ`,
`≈ ${(kb(total) * 8 / 1600).toFixed(1)} с к LCP`);
const byType = {};
before.forEach(r => (byType[r.resourceType] = (byType[r.resourceType] || 0) + (r.transferSize || 0)));
Object.entries(byType).sort((x, y) => y[1] - x[1])
.forEach(([type, bytes]) => console.log(" ", type.padEnd(12), kb(bytes), "КБ"));
console.log("самые тяжёлые:");
before.sort((x, y) => y.transferSize - x.transferSize).slice(0, 10)
.forEach(r => console.log(" ", String(kb(r.transferSize)).padStart(5), "КБ", r.url.slice(0, 110)));
Так выглядит вывод для главной страницы этого сайта (адреса сокращены):
$ node lh-window.js lh.json
оценка: 100
LCP в отчёте: 1.9 с
наблюдаемые FCP / LCP: 717 / 717 мс
в окне: 15 из 15 запросов, 345 КБ ≈ 1.7 с к LCP
Image 257 КБ
Font 54 КБ
Script 15 КБ
Document 10 КБ
Stylesheet 8 КБ
Other 0 КБ
самые тяжёлые:
103 КБ …/ach-thumb/fcd85dc5afb040b_640x1280.avif
81 КБ …/ach-thumb/32169e064265b8b_640x1280.avif
73 КБ …/ach-thumb/24ba541f5384f27_640x1280.avif
19 КБ …/assets-dist/fonts/jetbrains-mono-700.6e87891dc72b9bdd
18 КБ …/assets-dist/fonts/jetbrains-mono-400.04f909ccef08bb75
17 КБ …/assets-dist/fonts/ibm-plex-sans-400.2460b553b1a06ac5c
10 КБ https://alekseychikun.ru/
8 КБ …/assets-dist/js/492.0fa1b1e1a04f56ca24f0.js
5 КБ …/assets-dist/css/987.f0a7399b12b25dcede62.css
4 КБ …/assets-dist/js/445.9def0e0b660ffb298d4f.js
Оценка 100, но и здесь скрипт кое-что нашёл. Три самые тяжёлые картинки (73–103 КБ) — это превью работ, которые стоят ниже первого экрана. Браузер начал качать их ещё до отрисовки: ленивая загрузка Chrome стартует с большим запасом до края экрана. По формуле это ≈ 1,3 с из 1,9 с модельного LCP — на оценку пока не повлияло, но на странице потяжелее такие картинки уже стоили бы баллов. Как это лечится, разберём в части про картинки.
Скрипт печатает оценку, обе версии LCP, сколько запросов и килобайт ушло до отрисовки (и сразу — сколько секунд это стоит по формуле из второй части), сумму по типам файлов и десятку самых тяжёлых. Обычно уже по разбивке по типам всё понятно: если в окне два мегабайта Font или Media — вот она, причина.
Как читать результат
- Сравните модельный и наблюдаемый LCP. Если модельный в разы больше — причина в том, что попало в окно, и скрипт её покажет. Если они близки и оба большие — страница действительно медленная: смотрите ответ сервера и то, что выполняется до отрисовки.
- Посчитайте вес в окне по формуле. Сходится с «задержкой» в фазах LCP — причина найдена.
- Пройдитесь по десятке тяжёлых. Для каждого файла вопрос один: нужен ли он первому экрану? Шрифт начертания, которого нет на первом экране, видео из карточки ниже сгиба, картинка скрытого меню — нет.
- Загляните в фазы LCP. Большая «задержка отрисовки» при быстрой загрузке — элемент спрятан (анимация, скрипт). Большая «задержка загрузки» — браузер поздно узнал о картинке (например, она в CSS-фоне).
Один прогон — не результат. Тот же билд изредка выдаёт оценку в разы хуже обычной из-за сбоя самой проверки. Запускайте минимум три раза и сравнивайте серии. Подробно — в следующей, четвёртой части цикла.
Чем этот скрипт отличается от вкладки «Сеть» в DevTools?
DevTools показывает загрузку на вашем быстром компьютере, где окно первой отрисовки совсем другое. Скрипт берёт запросы из прогона Lighthouse — того, по которому считалась оценка.
Почему фильтр по LCP, а не по первой отрисовке?
Модель считает критическим путём LCP всё, что запрошено до появления крупнейшего элемента. На большинстве страниц FCP и LCP близки; если между ними большой разрыв — посмотрите оба фильтра, это само по себе подсказка.
Нет времени разбираться с отчётами?
Пришлите адрес — прогоню серию, разберу окно первой отрисовки и пришлю список правок по важности.
// загружаю комментарии…