Сторонние скрипты и виджеты
Аналитика, чат, пиксель, карта, ютуб-плеер и «всего один тег от маркетинга» — вместе это часто больше кода, чем всё ваше приложение. И исполняется он в том же потоке.
INP (Interaction to Next Paint) — время от действия пользователя до момента, когда браузер отрисовал ответ на него. Хорошим считается значение до 200 мс. Long task — непрерывная работа главного потока дольше 50 мс; всё это время клики просто ждут.
Почему чужой код бьёт именно по отзывчивости
Главный поток у браузера один. В нём парсится HTML, считается layout, выполняется ваш JS — и точно так же в нём выполняется чужой. Виджет чата на 500 КБ, который стартует по load, займёт поток на 300–600 мс на среднем телефоне: распаковка, парсинг, компиляция, инициализация. Пользователь в этот момент уже видит кнопку и уже по ней тыкает. Клик встаёт в очередь и ждёт. Ваш INP — 600 мс, и ни одна строчка вашего кода в этом не виновата.
Дальше — эффекты второго порядка, о которых обычно забывают. Аналитика вешает делегированный обработчик на document и слушает каждый клик на странице: теперь любое нажатие проходит через чужой код. Рекламный тег подтягивает ещё пять доменов, каждый со своим DNS и TLS, и они отъедают полосу у вашей LCP-картинки. А тег-менеджер превращается в чёрный ящик, куда маркетинг добавляет скрипты, о которых разработчики узнают из отчёта Lighthouse.
Шаг первый: инвентаризация
Прежде чем что-то оптимизировать, посмотрите, что у вас вообще стоит. В Lighthouse есть аудит «Reduce the impact of third-party code» с таблицей по доменам, а в панели Performance можно сгруппировать main-thread работу по источнику. Результат стоит зафиксировать в виде обычной таблицы — она же станет основой бюджета.
Скрипт Домен Вес (gzip) Main thread Владелец Решение
---------------------------------------------------------------------------------------------
GA4 googletagmanager.com 48 КБ 120 мс маркетинг оставить, defer
Яндекс.Метрика mc.yandex.ru 58 КБ 180 мс маркетинг оставить, по idle
Виджет чата chat.example.com 510 КБ 540 мс поддержка по клику на кнопку
Hotjar static.hotjar.com 130 КБ 210 мс UX удалить (не смотрели с марта)
YouTube iframe youtube.com 1100 КБ 900 мс контент facade
A/B-тест optimizely.com 95 КБ 160 мс продукт удалить, эксперимент закончен
---------------------------------------------------------------------------------------------
ИТОГО 1941 КБ 2110 мс
Такая таблица делает половину работы сама: обычно выясняется, что два-три тега давно никому не нужны, а живые ими никто и не пользуется. Просто удалить — самая эффективная оптимизация сторонних скриптов.
Шаг второй: async, defer и их предел
<!-- Блокирует парсер: качается и выполняется прямо здесь. Так — никогда -->
<script src="/js/analytics.js"></script>
<!-- Качается параллельно, выполняется сразу как скачался, может прервать парсинг -->
<script src="/js/analytics.js" async></script>
<!-- Качается параллельно, выполняется после парсинга HTML, в порядке объявления -->
<script src="/js/analytics.js" defer></script>
Для сторонних тегов почти всегда правильный ответ — defer: он не рвёт парсинг и сохраняет порядок. async годится независимым скриптам, которым всё равно, когда стартовать.
Но вот в чём принципиальное ограничение: ни async, ни defer не уменьшают стоимость исполнения. Они только двигают её во времени. 540 мс работы главного потока останутся 540 мс работы главного потока, просто теперь они случатся чуть позже — ровно тогда, когда пользователь начал взаимодействовать со страницей. Иногда от этого становится даже хуже.
Настоящий выигрыш даёт другой ход: не грузить скрипт вообще, пока он реально не понадобился.
Шаг третий: загрузка по требованию
Чат нужен не всем и не сразу. Грузим его при первом признаке, что пользователь собирается что-то делать, — либо когда браузер простаивает:
function loadScript(src) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src;
s.async = true;
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
}
let chatRequested = false;
function loadChatOnce() {
if (chatRequested) return;
chatRequested = true;
loadScript('https://chat.example.com/widget.js');
}
// Первый признак намерения: пользователь двигает, скроллит или печатает
['pointerdown', 'keydown', 'touchstart', 'scroll'].forEach((type) => {
window.addEventListener(type, loadChatOnce, { once: true, passive: true });
});
// Запасной путь: как только главный поток освободился
if ('requestIdleCallback' in window) {
requestIdleCallback(loadChatOnce, { timeout: 10000 });
}
Флаг chatRequested обязателен: событий четыре, сработать может любое, а скрипт нужно вставить один раз. Опция once: true снимает обработчик после первого срабатывания, passive: true обещает браузеру, что мы не будем отменять скролл, — это важно для плавности.
Результат на практике: чат перестаёт участвовать в загрузке страницы вообще. LCP и INP меряются до того, как он появится, а появляется он через 200 мс после первого касания — пользователь не замечает разницы.
Facade: превью вместо плеера
Обычный <iframe> с YouTube тянет около мегабайта JS и десяток запросов — ещё до того, как кто-нибудь нажал play. На странице с тремя видео это гарантированный провал по всем метрикам.
Facade — это лёгкая подделка: статичная картинка-превью и кнопка. Настоящий плеер вставляется по клику, то есть только для тех, кто действительно собрался смотреть (а это обычно меньше пятой части посетителей).
<div class="yt-facade" data-id="dQw4w9WgXcQ">
<img src="https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg"
width="640" height="360" loading="lazy"
alt="Превью: обзор новой версии редактора">
<button type="button" class="yt-play" aria-label="Воспроизвести видео">Смотреть</button>
</div>
document.querySelectorAll('.yt-facade').forEach((box) => {
const button = box.querySelector('.yt-play');
button.addEventListener('click', () => {
const iframe = document.createElement('iframe');
iframe.src = `https://www.youtube-nocookie.com/embed/${box.dataset.id}?autoplay=1`;
iframe.width = 640;
iframe.height = 360;
iframe.title = 'Видео';
iframe.allow = 'autoplay; encrypted-media; picture-in-picture';
iframe.setAttribute('allowfullscreen', '');
box.replaceChildren(iframe);
});
});
Параметр autoplay=1 здесь не самоуправство: пользователь уже нажал кнопку, мы просто не заставляем его нажимать вторую. Домен youtube-nocookie.com не ставит трекинговые куки до начала просмотра. Если писать самому лень, есть готовый веб-компонент lite-youtube-embed от Paul Irish — он делает ровно это.
Тот же приём работает шире, чем кажется:
- Карты. Статичная картинка карты со ссылкой «Открыть в картах» вместо интерактивного SDK на 400 КБ. Девять из десяти посетителей просто хотели увидеть, где вы находитесь.
- Комментарии. Disqus и подобные грузим через
IntersectionObserver, когда блок приблизился к экрану, — до низа статьи доскроллит меньшинство. - Соцкнопки. Официальные виджеты «поделиться» — это чужие iframe с трекингом. Обычная ссылка на share-эндпоинт весит ноль.
Бюджет на сторонние скрипты
Оптимизация без правила — разовая акция: через полгода теги нарастут обратно. Поэтому бюджет фиксируют письменно и проверяют автоматически.
БЮДЖЕТ НА СТОРОННИЙ КОД (страница товара)
1. Суммарный вес чужого JS ..................... <= 120 КБ gzip
2. Суммарное время главного потока .............. <= 250 мс (Moto G, throttle 4x)
3. Число сторонних доменов ...................... <= 4
4. Блокирующих скриптов в <head> ................ 0
ПРАВИЛА
- У каждого тега есть владелец и дата пересмотра. Нет владельца — тег удаляется.
- Новый тег добавляется только вместе с ответом: что произойдёт, если он не загрузится?
- Виджеты (чат, видео, карта) грузятся по взаимодействию, а не при загрузке страницы.
- Раз в квартал — ревизия: что не смотрели три месяца, то выключаем.
- Превышение бюджета роняет сборку в CI (Lighthouse CI, assertion на third-party-summary).
Пункт про владельца выглядит бюрократией, но именно он работает лучше всех остальных. Скрипты попадают на сайт легко и не уходят никогда — просто потому, что никто не решается их выключить.
Как это работает
Разберём путь одного клика. Пользователь жмёт кнопку. Браузер должен выполнить обработчик и отрисовать следующий кадр — сумма этих двух вещей и есть INP. Но перед обработчиком в очереди главного потока может стоять задача: компиляция чужого бандла, инициализация SDK, отправка события аналитики. Пока она не закончится, обработчик не запустится. Задача идёт 300 мс — INP не меньше 300 мс. Ваш код при этом идеален.
Отсюда три способа спасения, в порядке эффективности. Убрать скрипт (нулевая стоимость). Отложить его так, чтобы он не пересекался с активностью пользователя (стоимость есть, но она никому не мешает). Разбить работу на куски, чтобы между ними браузер успевал обработать ввод — для своего кода это scheduler.yield() или хотя бы await new Promise(r => setTimeout(r, 0)), но чужой код вы так порезать не можете, и это ещё один довод в пользу первых двух вариантов.
Существует и радикальный путь — Partytown: сторонние скрипты уезжают в web worker, то есть в отдельный поток, и главный поток не трогают вовсе. Работает, но не бесплатно: обращения к DOM проксируются синхронно через SharedArrayBuffer, часть виджетов ломается, а настройка нетривиальна. Хорошо подходит аналитике, плохо — всему, что рисует интерфейс.
И маленькая деталь, которая почти ничего не стоит: домены, с которых вы точно будете качать, стоит прогреть заранее, чтобы DNS и TLS не отъели время в самый неподходящий момент.
<link rel="preconnect" href="https://mc.yandex.ru">
<link rel="dns-prefetch" href="https://mc.yandex.ru">
Только не увлекайтесь: preconnect держит соединение открытым, и десяток таких строк сам по себе становится проблемой. Два-три домена — потолок.
Частые ошибки
- Сторонний тег вставлен синхронным
<script>в<head>— он блокирует парсер и отодвигает вообще всё. - «Мы поставили defer, теперь всё хорошо». Не хорошо: 500 мс исполнения никуда не делись, просто переехали ближе к первому клику пользователя.
- Чат-виджет грузится на всех страницах, включая те, где кнопки чата нет.
- Три iframe с YouTube на лендинге — три мегабайта чужого JS для тех, кто вообще не собирался смотреть видео.
- Тег-менеджер без ревью: любой человек с доступом может уронить производительность прода мимо всех код-ревью и CI.
- Виджет тянет ещё десять доменов, и
preconnectпоставлен на первый из них — толку ноль. - Замеряют только LCP. INP от сторонних скриптов страдает сильнее всего, но его не смотрят, потому что в лабораторных тестах его толком нет — нужны полевые данные (CrUX, web-vitals).
Итоги
- Чужой JS исполняется в вашем главном потоке и портит ваш INP — виноваты будете вы.
- Сначала инвентаризация и удаление: часть тегов давно никому не нужна.
asyncиdeferдвигают стоимость во времени, но не убирают её.- Виджеты грузите по взаимодействию или по
requestIdleCallback, а не на старте страницы. - Facade вместо тяжёлых эмбедов: превью и кнопка, настоящий плеер — только тем, кто нажал.
- Бюджет с владельцем у каждого тега и проверкой в CI — единственное, что удерживает результат надолго.