Из предыдущих частей цикла мы знаем: оценку PageSpeed портит не вес страницы, а то, что запрошено до первой отрисовки. В седьмой части мы прошли шесть мест, откуда этот вес берётся. У большинства из них одно лечение: тяжёлое ниже сгиба должно начинать грузиться после того, как первый экран нарисован. Удобнее всего сделать для этого одни «ворота» на всю страницу — промис, который открывается в нужный момент, и через который проходит всё тяжёлое.
Это восьмая часть цикла «PageSpeed без мифов».
Когда открывать ворота
открыть, когда: первый экран нарисован И страница загрузилась (load)
досрочно: посетитель начал действовать (прокрутка, касание, клавиша)
потолок: 4 секунды — что бы ни случилось
Досрочное открытие важно для людей: если человек сразу листает вниз, блоки должны грузиться немедленно, а не ждать формальностей. Потолок страхует от страниц, где событие отрисовки по какой-то причине не пришло.
Две грабли, каждая стоила нам отдельной итерации
1. Ждать оба события, а не первое
Кажется, что достаточно дождаться load. Но на быстрой странице load наступает раньше отрисовки: в нашем замере load — 454 мс, а первый экран нарисован на 1379 мс. Ворота по одному load открывались почти на секунду раньше, и тяжёлое снова уезжало в окно.
Мы видели это и на другом сайте: ворота там открывались по первому из событий. Мобильная главная проседала до 87. После перехода на «оба события» вместе с паузой перед инициализацией скриптов (о ней — следующая часть) — 95–97 на главной, 95–98 на каталоге, 96–98 на товарной.
2. Запись о крупнейшем элементе приходит несколько раз
PerformanceObserver с типом largest-contentful-paint присылает запись каждый раз, когда появляется элемент крупнее предыдущего: сначала логотип в шапке, потом баннер. Если открыть ворота по первой записи, блоки стартуют рано: у нас — на 360 мс при настоящей отрисовке на 687 мс. Решение — считать экран нарисованным, когда записи перестали приходить полсекунды.
Код ворот
// afterFirstPaint.js — один промис на страницу: «первый экран нарисован и страница загрузилась».
// Им пользуется всё тяжёлое ниже сгиба: ленивые блоки, видео, карты, догрузка картинок.
let gate = null;
export default function afterFirstPaint() {
if (gate) return gate;
gate = new Promise(resolve => {
let opened = false;
let painted = false;
let loaded = document.readyState === "complete";
let quiet;
const open = () => {
if (!opened) { opened = true; resolve(); }
};
const check = () => painted && loaded && open();
// Запись о крупнейшем элементе приходит несколько раз (сначала логотип, потом баннер):
// экран считаем нарисованным, когда новых записей нет полсекунды.
try {
new PerformanceObserver(() => {
clearTimeout(quiet);
quiet = setTimeout(() => { painted = true; check(); }, 500);
}).observe({ type: "largest-contentful-paint", buffered: true });
} catch (e) {
painted = true; // браузер без LCP-событий — полагаемся на load
}
if (!loaded) addEventListener("load", () => { loaded = true; check(); }, { once: true });
// Человек начал действовать — открываем сразу; и потолок, чтобы ничего не зависло.
["scroll", "pointerdown", "touchstart", "keydown"].forEach(e =>
addEventListener(e, open, { once: true, passive: true }));
setTimeout(open, 4000);
check();
});
return gate;
}
Использование — одна строка перед любой тяжёлой загрузкой:
import afterFirstPaint from "./afterFirstPaint";
// ленивый блок: наблюдатель включается только после ворот
afterFirstPaint().then(() => {
const io = new IntersectionObserver(entries => {
entries.forEach(e => { if (e.isIntersecting) { io.unobserve(e.target); loadBlock(e.target); } });
}, { rootMargin: "1000px 0px" });
document.querySelectorAll("[data-lazy-block]").forEach(el => io.observe(el));
});
Чтобы человек не увидел пустоту
Отложить загрузку — половина дела. Вторая половина — чтобы посетитель, листающий страницу, не натыкался на пустые места:
- Место под блоки резервируется заранее — через
min-heightилиaspect-ratio. Иначе при появлении блока страница прыгнет (и это будет уже CLS). - Наблюдатель берёт большой запас —
rootMargin: 1000px: блок начинает грузиться задолго до того, как доедет до экрана. - После ворот все оставшиеся блоки догружаются фоном, по два за раз, — быстрый скроллер не догонит пустоту. Эта догрузка важна ещё и для поисковиков, об этом — в одиннадцатой части.
- Сорванный запрос повторяется до трёх раз: мобильная сеть рвётся, и блок не должен остаться пустым навсегда.
Результат
| Страница | До | После |
|---|---|---|
| каталог | 69, LCP 12,8 с | 100, LCP 1,5 с |
| товарная | 77–83, LCP 4,4–6,0 с | 96–97, LCP 2,5–2,8 с |
| главная | 98 | 99–100 |
| первая отрисовка (живая) | 1379 мс | 836–875 мс |
Под троттлингом, как у PageSpeed, порядок событий теперь правильный: LCP на 1588 мс, первый запрос ленивых блоков — на 1596 мс, то есть строго после отрисовки.
Это не обман проверки. Ворота работают одинаково для любого посетителя и для PageSpeed. Никто не получает урезанную страницу — все получают её в правильном порядке.
Почему не requestIdleCallback?
Он срабатывает, когда браузер свободен, — а это бывает и до первой отрисовки, особенно на быстром компьютере. Нам нужно не «когда свободно», а «после того как экран нарисован».
А картинки с loading=»lazy» через ворота пропускать?
Нативная ленивая загрузка ворот не видит, и в некоторых случаях (картинки без размеров, скрытые элементы) срабатывает раньше времени. Для таких картинок удобнее держать адрес в data-src и подставлять его после ворот — об этом в части про отложенные адреса.
Хотите так же?
Пришлите адрес — посмотрю, что на вашей странице стоит пропустить через ворота, и сколько это даст в оценке.
// загружаю комментарии…