Категории
Оптимизация сайта до 90-100 баллов PageSpeed
21.10.2026 | из категории: SEO
Исправления, которые реально двигают PageSpeed
Три сайта: логопедический сад на Joomla на общем хостинге, клиника на той же Joomla с чужими виджетами и видео, самописный PHP-блог на VPS. Все три были «жёлтыми» или хуже. Ниже — приёмы, которые дали результат, почему они работали и сколько стоили. Без историй успеха: только числа, порядок работ и грабли.
01Правило нулевое: сначала замер
Открытый API Google PageSpeed без ключа отвечает 429 Quota exceeded, интерфейс
pagespeed.web.dev рисуется скриптами и текстом не вытаскивается, а локальный
Lighthouse расходится с отчётом Google в разы. Поэтому:
- Мерило — отчёт владельца. Просить раскрытые пункты: там файлы, их вес и вклад в миллисекундах. Без раскрытия правки идут наугад.
- Свой замер — чтобы найти причину и увидеть «до/после», а не чтобы объявить правильный балл: вес всех ресурсов по HTTP, время запросов, размер картинки на экране против отданных пикселей, коды 4xx, ошибки консоли, CLS.
- Искать в живой странице, а не в шаблоне. Классы, картинки и старый синтаксис часто лежат в тексте блоков в базе CMS.
# вес всех ресурсов страницы без браузера: 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
- Обратный случай —
.webp: его nginx в списке типов не держит, файл отдаёт Apache — значит правило в.htaccessработает и закрывает пункт сразу. - Шрифты часто не покрыты ничем: правило
ExpiresByType font/woff2сняло 271 КБ у садика. - Свой nginx, два подводных камня:
add_header Cache-Control "public"не добавляет заголовок, а ЗАМЕНЯЕТ его —max-ageпропадает, и хранилище считается нулевым; писать сразуpublic, max-age=31536000, immutable. И проверять, что.webp/.avifесть вlocation ~ \.(js|css|png|jpg|…)$.
У блога пункт кэша показывал 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; чинится размерами картинок и предзагрузкой первого экрана. |
Заодно появляются файлы, которые читают агенты, а не люди:
/llms.txt— короткая карта сайта со ссылками (6,2 КБ у садика);/llms-full.txt— расширенное описание и полный список страниц (до 150 КБ у блога — это файл для машин);/agent.md— инструкция: что за сайт, как читать, можно ли цитировать, где цены, почему нельзя заполнять формы за человека. Отдаётся какtext/markdown.
Есть ещё разметка WebMCP — она прямо на форме объявляет инструмент: toolname,
tooldescription, у полей toolparamdescription. Браузеры без поддержки
атрибуты игнорируют. Ставить её стоит только с согласия владельца: это разрешает агенту заполнять
заявку, а заявка — это согласие на обработку персональных данных конкретного человека.
Мы её не ставили.
11Сводка по трём сайтам
| Показатель | logosad.ru | doctorneiro.ru | iotprof.smrtp.ru |
|---|---|---|---|
| Производительность | 85 → 90+ (было 82 на старте) | 82 → 97 (телефон и ПК) | 0,97 → 0,98 |
| Доступность | 84 → правим (имена, контраст) | 84 → 97 | 0,83 → 1,0 |
| Агентный просмотр | 1/3 → 3/3 | 1/3 → 3/3 | 0,5 → 1,0 |
| Вес страницы | картинки 11,3 → 2,9 МБ | 1008 → 290 КБ | список 3,70 → 0,52 МБ |
| Запросы | 42 → 43 (но CSS вдвое легче) | 64 → 55 | — |
| LCP | 2,2–3,5 с (разброс замеров) | 4,5 → 1,6–2,2 с | 2,2 → 1,8 с |
| Ошибки в консоли | нет | нет | нет |
Балл живёт отдельно от списка замечаний. После отложенной загрузки владелец сказал «раньше лучше
было» — хотя пункты отчёта закрылись. Поэтому: говорить заранее, что список замечаний и балл
двигаются врозь; держать откат на каждую правку, которая может задеть первый экран
(lazy, defer, перенос подключаемых файлов); при жалобе «стало хуже»
сначала перечитать отчёт, а не откатывать всё подряд.
12Что не вышло и что осталось
Не вышло
- Поднять версию PHP файлами. Проверил пять вариантов обработчика в
.htaccess(AddHandler application/x-httpd-php84,…php8.4,…php8_4,AddType,…alt-php84___lsphp) — хостинг игнорирует все, версия осталась прежней. Меняется только в панели, и у каждого домёна своя. - Замерить локальным Lighthouse как у Google. 36–38 против 82 в отчёте владельца на одном и том же коде. Пришлось строить свой стенд и мерить вес, запросы и LCP им.
- Убрать аналитику совсем. Владелец просил «удали лишние скрипты» — удалять нельзя, её ведут нарочно. Сделали отсрочку и честно сказали, кого не досчитаются.
Осталось и ждёт решения
- Контраст в блоке с розово-жёлтым градиентом у садика: заголовок
#3d3c92и текст#1d1d36на градиенте#d6569b → #D8E61C. Это видимый вид — менять только с согласия. - 16 фото галереи в JPEG по 27–37 КБ — в WebP будут в 2–3 раза легче, вид не изменится.
- jQuery 83,6 КБ из папки карты грузится на всех страницах, хотя нужен для одного блока в контактах.
- Срок хранения
css/js/svg/woff2на shared-хостинге — только заявкой в поддержку:.htaccessих не закрывает, отдаёт nginx. - Три счётчика Метрики у клиники, чат и виджеты отзывов — свести и решить, что оставить.
- Ролик 189 МБ — фасад снимает проблему захода, но не ожидание после нажатия. Пересжатие — замена содержимого.
13Чек-лист, если делать у себя
- Получить раскрытые пункты отчёта: файлы, их вес и вклад в миллисекундах.
- Замерить вес страницы самому: HTML + все
img/link/script. - Сжать картинки до размера контейнера;
width/height— только сheight:auto. - Мелкие копии для превью в списках: два размера, автогенерация, оригинал остаётся.
- Найти дубли библиотек; сперва переписать разметку на нужный синтаксис, потом снимать файлы.
- Спрятанные iframe и видео:
about:blank+data-атрибут, фасад с кнопкой. - Проверить, что у шрифтов и статики есть срок хранения — HEAD-запросом по каждому типу.
- Найти виновника LCP в данных отчёта и предзагрузить картинку первого экрана под условием «главная».
- Проставить имена всем интерактивным элементам, проверить контраст расчётом, а не на глаз.
- Сделать
llms.txtсо ссылками,agent.mdиllms-full.txt. - После каждой правки — замер «до/после» и откат под рукой.
- Проверять вид геометрией элементов, а не снимком: анимации дают ложные различия.