Инженерия

Мы думали, что сайт быстрый. PageSpeed показал 55 — и проблема была не там, где все ищут

Мы были уверены, что сайт быстрый. Сервер отдаёт страницу за 0,4 секунды, кеш работает, картинки не по 4 мегабайта, тема писана с нуля без единого конструктора. Потом мы открыли PageSpeed Insights, переключили на «Мобильные устройства» — и увидели 58.

PageSpeed Insights до оптимизации: 58 баллов эффективности, TBT 1160 мс
Так выглядел отчёт до изменений. Замерено на дев-копии со старым кодом, чтобы не ждать машины времени (поэтому 66 в SEO — на дев-домене стоит noindex)

Самое интересное началось в списке проблем. Там не было ничего из того, на что обычно думают: ни медленного сервера, ни гигантских JPEG, ни «слишком много плагинов». Была одна строчка-фраза, которая объясняла всё:

Диагностика PageSpeed: минимизируйте работу основного потока 9,4 с, 13 длительных задач
9,4 секунды работы главного потока и 13 длительных задач — на странице, где нет ни одного тяжёлого виджета

За один рабочий день мы довели мобильные показатели до 97–100 / 100 / 100 / 100 и заодно получили 3/3 в новом блоке «Агентный веб-просмотр». Ниже — реальный код, который это сделал, что из оптимизаций не сработало, и почему последний блок скоро станет важнее остальных.

Сначала — честный дисклеймер

  • Это лабораторные замеры. Lighthouse гоняет страницу на эмулированном Moto G Power с ограниченным 4G. Реальные пользователи на быстром канале увидят лучше, на дешёвом телефоне в метро — хуже.
  • Показатель плавает. Тот же сайт в трёх прогонах подряд даёт 97, 99 и 100. Мы берём медиану трёх — и советуем так же делать всем, кто собирается спорить про «а у меня 89».
  • 100 — не самоцель. Цель — чтобы страница открывалась и начинала слушаться пальца за секунду. Балл — лишь удобный способ проверить, что ты этого достиг.

Где прятались 40 баллов

1. Бесконечная анимация строки логотипов — 950 мс работы процессора

Лента с логотипами клиентов ехала через requestAnimationFrame: каждый кадр JavaScript считал сдвиг и писал его в transform. Выглядело идеально, стоило — почти секунду работы главного потока в первые секунды загрузки. Вот как это было:

// было: JS считает позицию каждого кадра
const tick = (now) => {
  const dt = (now - last) / 1000;
  last = now;
  state.forEach(s => {
    s.offset += s.dir * s.speed * dt;
    if (s.offset <= -s.half) s.offset += s.half;
    s.track.style.transform = `translate3d(${s.offset.toFixed(2)}px,0,0)`;
  });
  rafId = requestAnimationFrame(tick);
};

А вот то же самое, но на композиторе — главный поток вообще не участвует. Треки и так дублируются, поэтому сдвиг на -50% зацикливается бесшовно:

/* стало: анимация живёт в CSS */
@keyframes cc-marq-l { from { transform: translate3d(0,0,0) }
                       to   { transform: translate3d(-50%,0,0) } }

.lm-track { animation: cc-marq-l var(--lm-dur, 70s) linear infinite }
.stack-row[data-dir="right"] .stack-track { animation: cc-marq-r 75s linear infinite }

@media (prefers-reduced-motion: reduce) { .lm-track, .stack-track { animation: none } }

JS остался ровно один — раз померить ширину трека и пересчитать постоянную скорость (45 px/с) в секунды анимации.

2. three.js ради одного прямоугольника

Анимированный градиент в шапке — это фрагментный шейдер на весь экран. Он тянул three.js: 1,3 МБ библиотеки (252 КБ сжатыми) ради того, чтобы нарисовать два треугольника и передать три переменные в видеокарту. Мы выбросили библиотеку и написали то же самое на «голом» WebGL:

const gl = canvas.getContext('webgl', { antialias:false, alpha:false, depth:false });

const compile = (type, src) => {
  const sh = gl.createShader(type);
  gl.shaderSource(sh, src); gl.compileShader(sh);
  if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(sh));
  return sh;
};

const program = gl.createProgram();
gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG));   // тот же шейдер, что и был
gl.linkProgram(program);
gl.useProgram(program);

// один треугольник, накрывающий весь экран
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW);

Вместе с циклом анимации, ресайзом и паузой за экраном — около 3 КБ кода вместо 1,3 МБ. Визуально градиент не изменился ни на пиксель.

3. Карта клиентов, которую никто ещё не видел

Секция с картой мира подгружала d3-geo, topojson и файл геометрии стран на 740 КБ — сразу на старте, хотя сама карта лежит в самом низу страницы. Теперь всё это приезжает, когда до секции остаётся 400 px:

const section = document.querySelector('.clients-map');

const io = new IntersectionObserver((entries) => {
  if (entries.some(e => e.isIntersecting)) {
    io.disconnect();
    boot();                       // только здесь грузим d3 + topojson + geojson
  }
}, { rootMargin: '400px 0px' });

io.observe(section);

4. Google Fonts как третья сторона

Шрифт Manrope приезжал с fonts.googleapis.com, а сам файл — с fonts.gstatic.com: два лишних DNS-запроса и два TLS-рукопожатия на критическом пути. Забрали к себе, два подмножества вместо шести:

/* assets/css/fonts.css — свой, а не Google */
@font-face{
  font-family:'Manrope'; font-style:normal; font-weight:400 800; font-display:swap;
  src:url('../fonts/manrope-cyrillic.woff2') format('woff2');
  unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}

5. WooCommerce на страницах, где нет магазина

WooCommerce подключает свои стили и скрипты везде — на главной, в блоге, на странице «О нас». Снимаем там, где магазина нет:

function catcode_needs_woo_assets(): bool {
    if (!function_exists('is_woocommerce')) return false;
    if (is_woocommerce() || is_cart() || is_checkout() || is_account_page()) return true;
    if (is_singular(['module', 'product'])) return true;
    if (function_exists('WC') && WC()->cart && !WC()->cart->is_empty()) return true;
    // страница с shortcode/блоком магазина — тоже оставляем
    return false;
}

6. Картинки — да, тоже

Карточки кейсов отдавались полноразмерными JPEG на 1440 px даже на телефон. Теперь у каждого JPEG и PNG в медиатеке есть WebP-двойник, а тема сама заворачивает разметку в <picture> с фолбеком на оригинал:

add_filter('wp_get_attachment_image', function ($html) {
    $srcset = catcode_webp_srcset($html);   // '' если хоть для одного размера нет .webp
    if ($srcset === '') return $html;

    return sprintf('<picture><source type="image/webp" srcset="%s"%s>%s</picture>',
        esc_attr($srcset), $sizes, $html);
});

Что получилось

PageSpeed Insights после оптимизации: 97 эффективность, 100 доступность, 100 оптимальные методы, 100 SEO, 3/3 агентный веб-просмотр
Тот же сайт после изменений: FCP 1,1 с, LCP 1,5 с, TBT 100 мс, CLS 0
Диагностика PageSpeed после оптимизации: две длительные задачи вместо тринадцати
Было 13 длительных задач и 9,4 с работы главного потока — осталось 2 задачи

Что НЕ помогло

Половина советов из типовых чеклистов в нашем случае не дала ничего — и это не менее полезно знать, чем то, что сработало.

  • WebP сам по себе. Картинки на главной лежат ниже первого экрана и грузятся лениво. Они раздули вес страницы, но на балл почти не влияли — прирост пошёл от JavaScript, а не от них.
  • defer на всех скриптах. Он у нас уже стоял — все скрипты темы были отложены до оптимизации. Это не спасает, если отложенный скрипт потом рисует каждый кадр.
  • Preload «на всякий случай». Preload всего подряд просто переставляет очередь запросов. Реально помогли ровно два: шрифт и логотип в шапке.
  • Уменьшение разрешения шейдера. Мы честно попробовали оставить живую WebGL-анимацию на мобильном, снизив рендер до 0,4× и 30 fps, потом до 0,25× и 20 fps. TBT упал с 594 до 273 мс и дальше не двигался: в Lighthouse мобильный рендер идёт без видеокарты, программно. Компромисс — на телефоне сначала показывается стоп-кадр этого же шейдера (17 КБ WebP), а анимация стартует после первого касания или скролла.
  • Мелкие микрооптимизации. Снятие WooCommerce-ассетов, уборка лишнего CSS с главной, отложенный gtag — всё вместе дало примерно 2–3 балла. Значение они имеют только тогда, когда крупные проблемы уже закрыты.

Агентный веб-просмотр — новый блок, на который стоит смотреть

В мае 2026 Google перевёл категорию Agentic Browsing («Агентный веб-просмотр») из экспериментальной в основную конфигурацию Lighthouse, и вскоре она появилась в PageSpeed Insights. Оценка здесь не 0–100, а соотношение пройденных проверок: 3/3, 2/3 и так далее.

Вопрос, на который отвечает этот блок: может ли AI-агент прочитать страницу, понять её элементы управления и выполнить задачу, не угадывая? Вот как выглядят те три проверки в реальном отчёте:

Блок агентного веб-просмотра в PageSpeed Insights: дерево доступности, CLS 0, файл llms.txt
Дерево доступности, Cumulative Layout Shift и llms.txt — три проверки, которые проходит обычный сайт
  • Дерево доступности. Есть ли у интерактивных элементов программные названия, корректные роли и правильная вложенность. То же, что нужно скринридеру — теперь нужно и агенту.
  • Стабильность макета (CLS). Чтобы агент не кликнул туда, где кнопка была полсекунды назад.
  • Файл llms.txt в корне домена — машиночитаемое markdown-описание сайта: что это за проект, какие ключевые страницы, где документация.

Есть ещё четвёртая проверка — WebMCP, разметка форм и действий сайта так, чтобы агент мог ими пользоваться напрямую. Она появляется только у тех, кто этот экспериментальный протокол уже внедрил, поэтому у обычного сайта максимум — 3/3.

Наши 3/3 — не случайность: llms.txt мы выложили ещё весной, вместе со структурированными данными для страниц модулей, а доступность держим на 100 с первого дня. Что важно понимать: это не фактор ранжирования, и категория прямо помечена как развивающаяся. Но направление читается однозначно — качество сайта официально перестало быть вопросом только человеческого опыта. Половина ваших будущих «посетителей» — это ассистенты, которые приходят за ответом вместо пользователя.

Главный вывод

Наибольший прирост нам дали не мелкие оптимизации, а отказ от тяжёлых JS-анимаций в первом экране. Одна строка логотипов на requestAnimationFrame и одна библиотека на 1,3 МБ стоили больше баллов, чем все картинки, шрифты и сторонние скрипты вместе взятые.

Практический вывод для любого сайта: прежде чем сжимать картинки, откройте вкладку Performance и посмотрите, кто рисует каждый кадр. Дороже всего на странице — не то, что весит, а то, что работает непрерывно.

Что из этого забрать себе

  • Не верьте одному прогону PageSpeed — берите медиану трёх, иначе спорите со случайным числом.
  • Ищите requestAnimationFrame и библиотеки, подключённые ради одной функции.
  • Всё, что ниже первого экрана, должно грузиться при приближении к нему, а не на старте.
  • Шрифты и скрипты с чужих доменов — это рукопожатия, за которые платит первый показ текста.
  • Проверьте свой llms.txt и дерево доступности сейчас, пока блок «Агентный веб-просмотр» ещё никто не читает внимательно.

Если у вас на мобильном меньше 70 и непонятно, с чего начинать — напишите нам. Посмотрим ваш сайт и скажем, какие три вещи дадут наибольший прирост именно в вашем случае, без общих советов про «сожмите картинки».

Поделиться

Готовы обсудить релиз?

Отвечаем в течение 60 минут в рабочее время — с оценкой бюджета, ориентировочным графиком и составом команды под вашу задачу.

Оставить запрос