↩️ Назад

Категории

Оптимизация сайта до 90-100 баллов PageSpeed

21.10.2026 | из категории: SEO

Оптимизация сайта до 90-100 баллов PageSpeed
Разбор · скорость сайтов

Исправления, которые реально двигают PageSpeed

Три сайта: логопедический сад на Joomla на общем хостинге, клиника на той же Joomla с чужими виджетами и видео, самописный PHP-блог на VPS. Все три были «жёлтыми» или хуже. Ниже — приёмы, которые дали результат, почему они работали и сколько стоили. Без историй успеха: только числа, порядок работ и грабли.

Дата: 13–21 октября 2026  ·  Стек: Joomla 5/6 на shared-хостинге (FTP, без оболочки), самописный PHP-блог на VPS (nginx + PHP-FPM)  ·  Мерило: отчёты PageSpeed от владельца + свои замеры в безголовом браузере  ·  Итог: производительность 90–100, доступность 97–100, «агентный просмотр» 3/3
90–100
производительность на трёх сайтах (было 82, 85, 0,97)
1/3 → 3/3
«агентный просмотр» — новая категория Google
27,9 → 4,3 МБ
картинки трёх замеренных страниц, сумма
31,3 → 13,7
КБ стилей главной в сжатом виде, минус один запрос

01Правило нулевое: сначала замер

Открытый API Google PageSpeed без ключа отвечает 429 Quota exceeded, интерфейс pagespeed.web.dev рисуется скриптами и текстом не вытаскивается, а локальный Lighthouse расходится с отчётом Google в разы. Поэтому:

# вес всех ресурсов страницы без браузера: HTML + img/link/script
python weight.py https://site/
#   HTML 53.0 КБ, ресурсов 59, всего 1.53 МБ
#     160.0 КБ  /assets/dist/css/bootstrap.min.css
#      83.6 КБ  /map/js/jquery.min.js
#      76.3 КБ  /assets/dist/js/bootstrap.bundle.min.js
Грабля

Локальный Lighthouse и отчёт Google расходятся в разы: у копии клиники мой прогон давал 36–38 при 82 в отчёте владельца. Спорить о цифре бессмысленно — его отчёт и есть мерило.

Порядок работ

На всех трёх сайтах он оказался одинаковым: сначала убрать то, что грузится зря, потом то, что грузится дважды, и только в конце причёсывать балл. Обратный порядок не работает: при 11 МБ картинок микрооптимизации не двигают ничего.

02Картинки: главный вес страницы

Самая большая статья расхода на всех трёх сайтах — и самая неблагодарная: урезание даёт меньше всего баллов, но без него остальное не работает.

ПриёмЧто это дало
Уменьшить файлы до размера контейнера, а не «до красивых цифр» плитки 640×640 при показе 392×392 → 420×420: 780 → 410 КБ на 16 плиток
WebP вместо JPEG там, где разница на глаз не видна 4 карточки услуг: 756 → 90 КБ (разница 1,7–2,1 из 255)
Отдельные мелкие копии для превью в списках 16 карточек блога: 3,70 → 0,52 МБ; главная 621 → ~415 КБ
Два размера превью вместо одного «среднего» 470 картинок: 480×350 q82 — 15,2 МБ, 400×250 q78 — 9,3 МБ
Атрибуты width/height — только вместе с height:auto вёрстка не прыгает (CLS 0,00), но фото не растягиваются
Ленивая загрузка ниже первого экрана первым карточкам loading="lazy" не ставим — иначе LCP откладывается

Итог по весу: садик 11,3 → 2,9 МБ, клиника 12,86 → 0,83 МБ, блог 3,70 → 0,52 МБ.

Цена решения

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

03Дубли библиотек: причина в разметке

У садика в шапке грузились сразу два Bootstrap: 5.1.3 (в нём же Popper) и 4.1.0 со своим Popper, плюс две копии jQuery — 2.2.1 для карты и 3.6.3 «под четвёрку». Около 160 КБ кода, которого страница не использует.

Причина оказалась не в библиотеках, а в разметке: кнопка «Меню» на телефоне написана на новом синтаксисе data-bs-toggle="collapse", а выпадающие пункты — на старом data-toggle="dropdown", которого пятёрка не понимает. Ради трёх пунктов ехали BS4, Popper и вторая jQuery.

Проверено

Я снял лишние подключения первым заходом — и меню перестало открываться. Правильный порядок: сравнить версии файлов, найти старый синтаксис в тексте блоков базы, переписать его на новый, и только потом убирать строки подключений. Проверка — кликом по каждому пункту: у подменю должен появиться класс show, display стать block, а aria-expanded — true. Наличие элементов в разметке ничего не доказывает.

04Стили: один файл вместо двух

В шапке остались два блокирующих файла: полный Bootstrap (24,1 КБ сжатыми) и all.css (7,8 КБ) — 31,3 КБ и два запроса в критическом пути.

Что сделали: собрали список ВСЕХ имён классов и id со всех 34 страниц карты сайта, из содержимого правленых блоков и из шаблона, добавили те классы, которые Bootstrap навешивает из скрипта (show, collapsing, modal-open, dropdown-menu, was-validated…), и выкинули правила, чьих имён на сайте нет. Затем слили урезанный Bootstrap со стилями сайта в один файл.

# было: два блокирующих файла
#   bootstrap.min.css  24,1 КБ сжатыми
#   all.css             7,8 КБ сжатыми
#                            ---------
#                            31,3 КБ, два запроса в критическом пути

# стало: один файл
#   main-critical.css              13,7 КБ сжатыми
#   bootstrap внутри: 163,8 КБ -> 28,0 КБ (селекторов 2050 -> 304)
Грабля

Первый заход урезки дал 387 сдвинутых элементов: проверка «все имена селектора есть на сайте» применялась к строке целиком, и правило .container,.container-fluid{…} вылетало вместе с .container — контейнер растягивался во всю ширину. Список селекторов надо резать по запятым и проверять каждый по отдельности, а селекторы без классов и id (элементные, *, :root, @keyframes) — оставлять всегда.

Как доказать, что вид не изменился

Геометрией, а не снимком: снимаем две копии страницы (с прежними стилями и с новым файлом) и сравниваем getBoundingClientRect всех элементов. У садика вышло 465 элементов и 0 расхождений больше 1 пикселя. Сравнение снимков для этого не годится: анимированный логотип и лента дают до 1,2% различий на одном и том же коде.

Цена решения

Слитый файл — это копия all.css. Дальше правки в исходном файле сами на сайт не попадут: нужно пересобирать слияние. Это надо назвать вслух сразу, иначе через месяц кто-то правит all.css и не понимает, почему ничего не меняется.

05Шрифты: 278 КБ → 58 КБ и честная цена

У садика на каждое начертание грузились старые файлы: woff 139 КБ и ttf 322 КБ. Причём в @font-face первыми стояли несуществующие файлы, а живые — ниже. Заменили на woff2 с нарезкой по языкам (кириллица 10,7 КБ + латиница 18,3 КБ) и unicode-range, объявив их под теми же именами семейств — вид текста остался прежним, вес упал в разы.

Отдельно и важнее: у шрифтов не было срока хранения вовсе. Правило ExpiresByType font/woff в .htaccess закрыло пункт отчёта на 271 КБ.

Цена решения

Шрифт стоит баллов сам по себе, даже когда он лёгкий. Замер на одном и том же сайте: с запасным Arial (без подмены начертания) — 93; лёгкий фирменный шрифт с font-display: swap — 85; он же с optional — снова ~93 (первый заход идёт запасным начертанием, дальше шрифт из кэша). То есть swap отодвигает LCP, и это не «настройка», а выбор между видом и баллом.

Грабля

Шрифт нельзя проверять в зеркале страницы: браузер блокирует файл шрифта с чужого источника, и в отчёте видна «ошибка», хотя на живом сайте всё грузится. Я поверил замеру, удалил два объявления @font-face — и владелец увидел запасной шрифт вместо своего. Проверять так: файл отдаётся по HTTP с кодом 200 и правильным началом (wOFF), и смотреть порядок источников в src — браузер берёт первый подходящий и живой.

06Чужие виджеты и скрытые iframe

У клиники 1110 КБ неиспользуемого JS оказались чужими на все сто процентов. Главное открытие: спрятанный iframe грузится всё равно. Викторина лежала в окне display:none, которое показывается через минуту, — и её 650 КБ приезжали при каждом заходе.

ИсточникВесПроцессорЧто сделано
Викторина (iframe в скрытом окне)650 КБ424 мсsrc="about:blank", настоящий адрес в data-атрибуте, подставляется при показе
Плеер Rutube в тексте главной660 КБ996 мсФасад: обложка из кадра самого ролика + кнопка, плеер появляется по клику
Сборщик Яндекса228 КБ—Уходит вместе с плеером
Значки-рейтинги отзывов45 КБ—loading="lazy", скрипт отзывов — defer
Метрика (три счётчика на странице)272 КБ467 мсЗа оградой согласия на cookie; свести к одному — решение владельца

Фасад для видео — приём, который стоит копировать. Вместо чёрного плеера: картинка-обложка width:100%;height:100%;object-fit:cover в контейнере с aspect-ratio, круглая кнопка по центру, обработчики click и keydown (Enter/пробел), role="button" и aria-label.

<!-- проверка фасада числами, ДО и ПОСЛЕ нажатия -->
ДО:    плеер display:none, запросов к чужому хосту — 0, размер блока 877×493
ПОСЛЕ: плеер display:block, запросов 48, размер блока тот же 877×493
       (вёрстка не прыгает — это и есть доказательство, что приём безопасен)

Файл ролика при этом не трогали: 1920×1080, поток 10 Мбит/с, вес 189 МБ. Фасад снимает проблему при заходе, но нажавший «играть» всё равно ждёт — пересжатие ролика это уже замена содержимого и отдельное решение владельца.

Счётчики: отложить, а не удалять

Просьба «удали лишние скрипты типа метрики, пусть грузятся через 5 секунд» решается отсрочкой: помощник запускает счётчик через 5 секунд ИЛИ при первом действии посетителя (прокрутка, клик, клавиша, касание) — что раньше. До этого момента к чужим хостам нет ни одного запроса.

Цена решения

Посетитель, который ушёл раньше таймера и ничего не нажал, в статистике не появится. Если это критично — таймер 2–3 секунды либо запуск только по действию. Это выбор владельца, а не техническая мелочь.

07Кэш: где хостинг мешает

Пункт «выбирайте эффективный период хранения кеша» — самый крупный почти всегда. На shared-хостинге правило Expires в .htaccess покрывает картинки, но не css/js/svg: их отдаёт nginx напрямую, мимо Apache. Проверять надо каждый файл из раскрытого списка отдельным HEAD-запросом, а не «по расширениям на глаз».

curl -sI https://site/assets/dist/css/all.css | grep -i "cache-control\|expires"
#  было: (пусто)  ->  стало: Cache-Control: max-age=2592000
Проверено

У блога пункт кэша показывал 463 КБ «экономии» на 16 файлах превью с cacheLifetimeMs=0. После правки add_header и добавления webp в список расширений: cache-insight = 1, производительность 0,97 → 0,98, LCP 2,2 → 1,8 с.

08LCP: виновника ищут замером

Отчёт называет только цифру («LCP 4,5 с»). Кто именно её держит — видно в данных Lighthouse, но не там, где ищешь по памяти: элемент и разбивка лежат в lcp-breakdown-insight (timeToFirstByte / resourceLoadDelay / resourceLoadDuration плюс узел с селектором), а проверки — в lcp-discovery-insight.

У клиники LCP держал первый слайд баннерной картинки. Картинка была объявлена фоном (background-image) внутри <style> посреди тела страницы — Chrome узнавал о ней только после шапки и половины разметки: запрос стартовал на 2,9 с при отрисовке текста на 1,1 с.

<?php if ($isHomepage): ?>
  <link rel="preload" as="image" href="/images/banners/banner-mobile.webp"
        media="(max-width:768px)" fetchpriority="high">
  <link rel="preload" as="image" href="/images/banners/banner.webp"
        media="(min-width:769px)" fetchpriority="high">
<?php endif; ?>

Две строки — и LCP 4,5 → 1,6–2,2 с. Условие «это главная» обязательно: без него все страницы сайта начнут качать картинку, которой у них нет.

Грабля

В старом Lighthouse аудит largest-contentful-paint-element приходит без подробностей (details = None), а в новой версии часть идентификаторов аудитов переименована: uses-long-cache-ttl, render-blocking-resources, modern-image-formats отсутствуют. Код разбора молча печатает пустоту и выглядит как «всё хорошо». Сначала список ключей со score < 1, потом разбор.

09Доступность без изменения вида

Доступность набирается тремя-четырьмя правками, которых посетитель не видит. У блога было 0,83 → стало 1,0 на главной и 0,96 → 1,0 на статьях; у клиники 84 → 97; у садика 84 → правим сейчас, и там главной причиной были элементы без имени.

ПроблемаПравка
Нет ориентира «основное содержимое»role="main" на существующем контейнере — тег <main> не обязателен (если CSS цепляет класс, а не тег)
Заголовки идут через уровеньh3 после h1 → h2 с классом, возвращающим прежний размер: вид тот же, порядок верный
Поля формы без подписиaria-label или визуально скрытая подпись. Расхождение for и id — самая частая причина: <label for="fox-m90-name"> при <input id="name">
Кнопки-стрелки и иконки без текстаaria-label="Предыдущее фото", role="button"
Низкий контрастСчитать коэффициент в коде ДО выбора цвета: #007cba на #383838 = 2,56:1 (норма 4,5) → #6cb6ff = 5,46:1
Скрытые поля-ловушки для спамаaria-hidden="true" tabindex="-1" — иначе браузер считает их полями формы без подписи
Проверено

Гонять аудит надо по НЕСКОЛЬКИМ типам страниц: у блога главная дала 100, а статья — 96, потому что контраст бейджа категории есть только на страницах статьи. Владелец видел «93» и это было про них. И проверять после правки снимком с телефона: заголовок не увеличился, список на месте, карточки целые.

10Агентный просмотр: файлы для ИИ

В отчёте Google появилась категория без балла — доля пройденных проверок («1/3»). Считаются три: прыжки вёрстки, имена и роли интерактивных элементов (agent-accessibility-tree) и llms.txt. У всех трёх сайтов было 1/3.

ПроверкаКак закрывается
llms.txt Файл должен содержать хотя бы одну Markdown-ссылку. У садика файл был целый, а ссылок — ноль, поэтому и падал: «File does not appear to contain any links». Теперь 30 ссылок на разделы, каждая проверена на 200.
Имена элементов Те же aria-label, что и в доступности: поля формы, стрелки галереи, галочки согласия. У садика было 5 элементов вообще без имени.
Прыжки вёрстки CLS. У садика 0,03 → 0; чинится размерами картинок и предзагрузкой первого экрана.

Заодно появляются файлы, которые читают агенты, а не люди:

Цена решения

Есть ещё разметка WebMCP — она прямо на форме объявляет инструмент: toolname, tooldescription, у полей toolparamdescription. Браузеры без поддержки атрибуты игнорируют. Ставить её стоит только с согласия владельца: это разрешает агенту заполнять заявку, а заявка — это согласие на обработку персональных данных конкретного человека. Мы её не ставили.

11Сводка по трём сайтам

Показательlogosad.rudoctorneiro.ruiotprof.smrtp.ru
Производительность85 → 90+ (было 82 на старте)82 → 97 (телефон и ПК)0,97 → 0,98
Доступность84 → правим (имена, контраст)84 → 970,83 → 1,0
Агентный просмотр1/3 → 3/31/3 → 3/30,5 → 1,0
Вес страницыкартинки 11,3 → 2,9 МБ1008 → 290 КБсписок 3,70 → 0,52 МБ
Запросы42 → 43 (но CSS вдвое легче)64 → 55—
LCP2,2–3,5 с (разброс замеров)4,5 → 1,6–2,2 с2,2 → 1,8 с
Ошибки в консолинетнетнет
Цена решения

Балл живёт отдельно от списка замечаний. После отложенной загрузки владелец сказал «раньше лучше было» — хотя пункты отчёта закрылись. Поэтому: говорить заранее, что список замечаний и балл двигаются врозь; держать откат на каждую правку, которая может задеть первый экран (lazy, defer, перенос подключаемых файлов); при жалобе «стало хуже» сначала перечитать отчёт, а не откатывать всё подряд.

12Что не вышло и что осталось

Не вышло

Осталось и ждёт решения

13Чек-лист, если делать у себя

Откуда числа. Лабораторные данные отчётов PageSpeed (мобильная стратегия, Lighthouse 13.5, эмуляция Moto G Power, 4G с низкой скоростью) — из отчётов, которые присылал владелец. Вес ресурсов, время запросов, состав консоли, CLS и геометрия элементов — собственные замеры: HTTP-скачивание с распаковкой gzip и безголовый Chrome на зеркале живых страниц. Снимки и покрытие CSS — из инструментов разработчика. Даты: 13–21 октября 2026.

Баллы PageSpeed меняются от прогона к прогону (у одного и того же кода разброс был 89–98), а «оценка в поле» по реальным посетителям обновляется через сутки-двое после правок.




Категории:

Категории

← Назад к списку