Счётчик Яндекс.Метрики отнял у меня 30 баллов Lighthouse. Выключение Вебвизора не помогло

Одна и та же страница, три варианта, медиана трёх мобильных прогонов Lighthouse 12:

Вариант

Performance

Best Practices

TBT

LCP

без счётчика

99

96

0 мс

1,84 с

штатный сниппет Метрики

70

75

2289 мс

2,37 с

штатный сниппет, webvisor: false

70

75

2196 мс

2,38 с

счётчик грузится по первому действию

99

96

0 мс

1,84 с

Тридцать баллов. Две с лишним секунды заблокированного главного потока. И выключение Вебвизора в параметрах init не даёт почти ничего, хотя именно этот совет выдаёт любой поиск.


Откуда взялась задача

Я делаю небольшой платный продукт и вожусь с его сайтом сам. За пару дней до этой истории вытащил главную с 80 до 98 баллов — перенёс шрифты с Google на свой домен и вшил критический CSS в документ. Дальше по плану была аналитика, без неё непонятно, куда вообще уходят люди.

Поставил счётчик ровно так, как его даёт интерфейс Метрики: сниппет в <head>, webvisor: true, clickmap: true, trackLinks: true, accurateTrackBounce: true. Прогнал Lighthouse.

Performance   98 → 68
Best Practices 100 → 75
TBT             0 → 2060 мс

Первая мысль была «шумит машина»: Lighthouse на ноутбуке под нагрузкой скачет на 5-10 баллов, это нормально. Мысль оказалась неверной. Хорошо, что я на ней не остановился — иначе так и жил бы с сайтом, который на телефоне думает две секунды, прежде чем отреагировать на палец.

Как отделить счётчик от всего остального

Гипотезу «виноват вот этот скрипт» проверяют опытом, где меняется ровно одна вещь.

Первый заход, самый дешёвый, прямо на боевой странице:

npx lighthouse@12 https://example.ru/ --blocked-url-patterns='*mc.yandex.ru*'

Флаг --blocked-url-patterns рубит запросы по маске, не трогая саму страницу. Баллы вернулись. Это уже почти доказательство, но у него есть слабое место: заблокированный запрос это ещё не «сниппета нет». Браузер всё равно резолвит домен и получает ошибку, а сам инлайновый код счётчика продолжает выполняться.

Поэтому второй заход — честный A/B на копии страницы. Скачал готовый HTML боевой страницы, переписал в нём относительные адреса на абсолютные (картинки, шрифты и демо тянутся с боевого домена, чтобы страница осталась той же самой), и сделал три файла, отличающихся ровно куском со счётчиком:

Дальше локальный http-сервер на случайном порту и по три прогона Lighthouse на каждый вариант с медианой. Весь стенд — сорок строк на Node, без puppeteer: lighthouse умеет ходить по обычному URL, а сервер нужен, только чтобы отдать три файла.

Медианы вы уже видели в начале. Разброс между прогонами по TBT приличный, от 2,2 до 4,0 секунды, но нижняя граница плохого варианта втрое выше верхней границы хорошего, так что спутать их невозможно.

Почему Lighthouse не показывает виновника

Вот что меня и сбило поначалу. В отчёте есть специальный аудит «Уменьшите влияние стороннего кода» (third-party-summary). Открываю его на плохом варианте:

third-party-summary: Third-party code blocked the main thread for 70 ms
   Yandex Metrica | main-thread 399 мс | blocking 73 мс | 115 КБ

Семьдесят миллисекунд. При TBT в четыре секунды. Если верить этому аудиту, счётчик вообще ни при чём.

Виновника видно в другом месте, в bootup-time и long-tasks:

bootup-time: 4.4 s
   Unattributable                 всего 4177 мс | скрипт 4050 мс
   b-stock.html                   всего  779 мс | скрипт    6 мс
   mc.yandex.ru/metrika/tag.js    всего  390 мс | скрипт  334 мс

long-tasks: 3 шт
   Unattributable                 4038 мс

Unattributable — это работа главного потока, которую Chrome не смог привязать ни к какому URL. В «сторонний код» она не попадает, потому что формально у неё нет источника: задачи, порождённые из уже выполненного кода, таймеров и внутренних фреймов, теряют связь со скриптом, который их завёл.

Правило отсюда работает на любом чужом скрипте, не только на Метрике. third-party-summary показывает десятки миллисекунд, а TBT секунды — первому не верить. Открывать bootup-time и long-tasks и смотреть на строку Unattributable. Дальше остаётся один надёжный инструмент — убрать подозреваемого и померить снова.

«Просто выключите Вебвизор» не работает

Совет, который выдаёт любой поиск по «метрика тормозит»: отключите Вебвизор, он же пишет каждое движение мыши.

Проверяем. Ровно тот же файл, меняется одно слово в параметрах init:

ym(XXXXXXXX, "init", {
     clickmap: true,
     trackLinks: true,
     accurateTrackBounce: true,
     webvisor: false          // <- единственное отличие
});

Результат: 70 баллов против 70, TBT 2196 мс против 2289 мс. Разница в пределах разброса между прогонами.

Отдельно проверил зеркальный вариант — вебвизор оставил, а clickmap, trackLinks и accurateTrackBounce выключил. Тоже 69 баллов и 2300 мс.

То-есть дело не в конкретной опции. Список сетевых запросов в обоих случаях одинаковый:

mc.yandex.ru/metrika/tag.js            94 КБ
mc.yandex.ru/metrika/tag_phono.js      13 КБ
mc.yandex.ru/watch/XXXXXXXX             2 КБ
mc.yandex.ru/watch/XXXXXXXX/1           1 КБ
mc.yandex.ru/ytm-config/XXXXXXXX        0 КБ
mc.yandex.ru/metrika/match.html         2 КБ
mc.yandex.ru/metrika/advert.gif         0 КБ
hdrc.yandex.net/                        0 КБ

Восемь запросов, 113 КБ и две с лишним секунды разбора и исполнения. Отдельного «модуля вебвизора», который можно было бы не загружать, снаружи не видно — всё едет одним tag.js. Что именно внутри съедает время, со стороны сказать нельзя: вся работа лежит в Unattributable.

Оговорюсь: не исключаю, что часть поведения задаётся настройками счётчика на стороне сервиса помимо параметров init — тогда флаг в коде и не должен ничего менять. Проверить это снаружи я не могу, а у меня вебвизор в интерфейсе включён.

Что я сделал

Дальше развилка. Отказаться от аналитики нельзя: сайт молодой, и вопрос «на какой секунде человек уходит» для меня важнее лишних баллов. Держать 70 из 100 на мобильных тоже нельзя.

Выход простой. Счётчик не нужен в первую секунду жизни страницы, он нужен к тому моменту, когда человек начал что-то делать. Значит грузим tag.js по первому действию — касанию, клику, клавише, колесу, прокрутке.

<script>
// Очередь ym объявляем сразу: код на странице может вызвать ym() до того,
// как приедет tag.js, и эти вызовы не должны потеряться.
window.ym = window.ym || function () { (window.ym.a = window.ym.a || []).push(arguments) };
window.ym.l = 1 * new Date();
ym(XXXXXXXX, 'init', {
  clickmap: true, trackLinks: true, accurateTrackBounce: true, webvisor: true
});

(function (w, d) {
  var armed = 1;

  // Поднять настоящий тег. once + capture: слушатели снимаются сами,
  // и мы ловим событие даже если его остановят по дороге.
  var boot = w.__ymBoot = function () {
    if (!armed) { return; }
    armed = 0;
    var s = d.createElement('script');
    s.async = 1;
    s.src = 'https://mc.yandex.ru/metrika/tag.js';
    d.head.appendChild(s);
  };

  ['pointerdown', 'keydown', 'touchstart', 'wheel', 'scroll'].forEach(function (t) {
    w.addEventListener(t, boot, { once: true, passive: true, capture: true });
  });
})(window, document);
</script>

Всё. На Lighthouse, который со страницей не взаимодействует, tag.js не загружается вовсе — 99 баллов и TBT 0. На живом человеке он приезжает через десятки миллисекунд после первого касания, и дальше Метрика работает, как обычно: вебвизор пишет, карта кликов рисуется.

Чем за это платят

У приёма есть настоящая цена, и про неё в советах «грузите аналитику лениво» обычно молчат.

Посетитель, который ушёл, не тронув страницу, в Метрику не попадёт. Открыл, посмотрел две секунды, закрыл — и он не существует. Ломается ровно та метрика, ради которой такие визиты и считают: показатель отказов. Он станет красивым и бесполезным.

Лечится дешёвым запасным путём: если человек уходит, а тег так и не понадобился, отправляем пиксель. Один GET на mc.yandex.ru/watch/<id> это полноценный хит, просто без вебвизора и карты кликов.

var bail = function () {
  if (!armed) { return; }          // тег уже поднялся, хит посчитает он сам
  armed = 0;
  (new Image()).src = 'https://mc.yandex.ru/watch/XXXXXXXX';
};

w.addEventListener('pagehide', bail);
d.addEventListener('visibilitychange', function () {
  if (d.visibilityState === 'hidden') { bail(); }
});

Почему pagehide и visibilitychange, а beforeunload не годится: на мобильных вкладку часто не «закрывают», а сворачивают, и beforeunload там просто не приходит. visibilitychange со статусом hidden — единственное событие, на которое можно положиться и на iOS, и на Android. Картинка вместо fetch — потому что запрос должен пережить выгрузку страницы, а Image браузеры отпускают в полёт (для чего-то серьёзнее есть navigator.sendBeacon).

Тот же armed = 0 в обеих функциях гарантирует, что двойного хита не будет: сработает либо загрузка тега, либо пиксель.

И не забудьте noscript — он тоже про посетителей, которых иначе не видно:

<noscript><div><img src="https://mc.yandex.ru/watch/XXXXXXXX" style="position:absolute;left:-9999px" alt=""></div></noscript>

Вторая ловушка: цели, которые срабатывают до первого клика

Отложенная загрузка ломает ещё одну вещь — цели, которые достигаются не по клику.

Пример из жизни: цель «регистрация». Человек жмёт «Создать аккаунт», сервер заводит запись и редиректом отправляет его в кабинет. Вызывать ym(..., 'reachGoal', 'signup') на странице, которая сейчас исчезнет, бессмысленно; на следующей странице событие уже прошло, а тег ещё не поднят.

Решение из двух частей. Сервер кладёт цель в сессию:

function ym_goal(string $name): void {
    $_SESSION['ym_goals'][] = $name;   // отправится на первой же странице после редиректа
}

А шаблон при выводе счётчика разбирает очередь и, если в ней что-то есть, поднимает тег принудительно:

$queued = '';
foreach ($_SESSION['ym_goals'] ?? [] as $g) {
    $queued .= sprintf("ym(%d,'reachGoal',%s);", $id, json_encode($g));
}
unset($_SESSION['ym_goals']);
if ($queued !== '') {
    // цель важнее двух десятых балла: тег нужен прямо сейчас
    $queued = 'window.__ymBoot();' . $queued;
}

Тот же приём работает для целей, которые надо отправить немедленно на текущей странице (например, «оплата прошла» на странице благодарности): сначала window.__ymBoot(), потом reachGoal. Ради этого boot и повешен на window.

Для целей-переходов ничего придумывать не нужно, клик по ссылке сам и есть первое действие:

d.addEventListener('click', function (e) {
  var a = e.target && e.target.closest && e.target.closest('a[href]');
  if (!a) { return; }
  if (/\/(pay|checkout)\.php/.test(a.getAttribute('href') || '')) {
    boot();
    ym(XXXXXXXX, 'reachGoal', 'checkout_start');
  }
}, true);

Вызов уйдёт в очередь ym.a и выполнится, как только tag.js доедет.

Про юридическую сторону, раз уж вебвизор

Вебвизор 2.0 записывает действия на странице: движения, клики, ввод в поля. Это персональные данные, и по 152-ФЗ о нём нужно сказать в политике конфиденциальности — что собираете, зачем, кто оператор, как отказаться. Ссылка на политику Яндекса и на страницу отказа от Метрики это обязательный минимум. Поля с паролями и платёжными данными вебвизор по умолчанию маскирует, но проверить это на своих формах стоит руками.

Итог

Performance     70 → 99
Best Practices  75 → 96
TBT           2289 → 0 мс
LCP           2,37 → 1,84 с

Данные при этом на месте: цели срабатывают, вебвизор пишет сессии тех, кто действительно что-то делал, отказы считаются пикселем.

Два места, на которых я потерял время. Первое — third-party-summary, которому нельзя верить: работа чужого скрипта уходит в Unattributable, и аудит покажет вам 70 мс вместо четырёх секунд. Второе — совет «выключите вебвизор», самый популярный ответ в поиске, в моём случае не давший ничего. Полминуты работы стенда стоят дороже часа чтения форумов.

Ленивая аналитика это сделка. Вы отдаёте часть визитов и получаете обратно баллы и отзывчивость. Отдавать надо осознанно и с запасным путём, иначе однажды обнаружите, что показатель отказов у вас 4%, и обрадуетесь.

Стенд простой: скачать страницу, сделать N копий, отличающихся одним куском, поднять локальный сервер и прогнать Lighthouse по три раза с медианой. Он одинаково хорошо ловит и счётчики, и виджеты чатов, и «лёгкие» баннеры. Повторяется за полчаса на любом чужом скрипте, который вы подозреваете.

@globalldev
01.09.2026 16:08 UTC
Первоисточник

Комментарии

@Ilusha
01.09.2026 17:33 UTC
0

С gtag, clarity, sentry та же история.

Можете еще посмотреть в сторону partytown. Не панацея, но позволяет разгрузить main thread.