Из предыдущих частей цикла мы знаем: оценку 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 с
главная9899–100
первая отрисовка (живая)1379 мс836–875 мс
рабочий сайт, вместе с правкой шрифтов; ничего не удалено и не спрятано

Под троттлингом, как у PageSpeed, порядок событий теперь правильный: LCP на 1588 мс, первый запрос ленивых блоков — на 1596 мс, то есть строго после отрисовки.

Это не обман проверки. Ворота работают одинаково для любого посетителя и для PageSpeed. Никто не получает урезанную страницу — все получают её в правильном порядке.

Почему не requestIdleCallback?

Он срабатывает, когда браузер свободен, — а это бывает и до первой отрисовки, особенно на быстром компьютере. Нам нужно не «когда свободно», а «после того как экран нарисован».

А картинки с loading=»lazy» через ворота пропускать?

Нативная ленивая загрузка ворот не видит, и в некоторых случаях (картинки без размеров, скрытые элементы) срабатывает раньше времени. Для таких картинок удобнее держать адрес в data-src и подставлять его после ворот — об этом в части про отложенные адреса.

Хотите так же?

Пришлите адрес — посмотрю, что на вашей странице стоит пропустить через ворота, и сколько это даст в оценке.

· полезно?
АЧ
Алексей Чикун веб-разработчик, команда TRIOHEAD

Делаю сайты и сервисы целиком: от вёрстки до сервера. Пишу о том, что проверил на своих проектах.

Комментарии

// загружаю комментарии…

Комментарии публикуются после проверки. Ссылки и HTML не принимаются.