Переход состояния видимости: измеряем просмотры страниц без надёжных beacon-запросов
pagehide, visibilitychange и Beacon API ведут себя по-разному в разных браузерах. Вот как надёжно зафиксировать конец сессии без cookie и постоянных идентификаторов.
Записать, что страницу просмотрели, легко. Записать, когда пользователь на самом деле ушёл, — надёжно, с учётом переключения вкладок, ухода в фон и жёстких закрытий — одна из самых каверзных задач веб-аналитики. Это важно, потому что метрики вовлечённости (время на странице, отказы, глубина прокрутки) зависят от чистого сигнала о завершении сессии. Ошибётесь — и вы либо теряете данные, либо раздуваете их.
Monoid решает это без cookie, localStorage и любых постоянных идентификаторов. Это ограничение на самом деле всё упрощает: нам никогда не нужно сшивать сессию между загрузками страниц, поэтому важно лишь одно — отправить один точный финальный образец за жизненный цикл страницы. Вот как ведут себя браузерные примитивы, где расставлены ловушки и какой из этих приёмов реально использует собственный трекер Monoid.
Почему unload — неподходящее событие
Исторически подписывались на unload или beforeunload и отправляли синхронный XHR. Сегодня это откровенно вредно. Событие unload ненадёжно на мобильных устройствах: когда вкладку уводят в фон и ОС забирает её ресурсы, unload часто не срабатывает вовсе. Хуже того, регистрация обработчика unload лишает страницу права на кэш переходов назад/вперёд (bfcache) в нескольких браузерах, ухудшая производительность навигации.
Руководство по Page Lifecycle API на web.dev говорит прямо: считайте переход visibilitychange в hidden последним надёжным событием, которое вы увидите. Не рассчитывайте на срабатывание unload или pagehide на мобильных.
Связка visibilitychange + pagehide
Устойчивый приём — слушать visibilitychange и проверять document.visibilityState === 'hidden', а pagehide использовать как вспомогательный сигнал. Как только страница переходит в скрытое состояние, вы сбрасываете всё, что нужно отправить. Документация MDN по Page Visibility API подтверждает: visibilitychange срабатывает, когда вкладка уходит в фон или браузер сворачивают, — ближайшее приближение к «человек перестал смотреть».
Сложность в том, что переход в скрытое состояние может произойти несколько раз за сессию (ушли со вкладки, вернулись, ушли снова). Значит, сброс нужно сделать идемпотентным, применить debounce, либо сбрасывать собственный таймер, чтобы каждое срабатывание сообщало новый, не пересекающийся с прошлым сегмент, а не растущий итог. Monoid идёт по последнему пути: таймер за образцом времени на странице сбрасывается при каждом переходе visibilitychange, поэтому повторное событие hidden позже в том же жизненном цикле страницы сообщает только время с последнего сброса. Monoid слушает visibilitychange (наряду с уже существующим запасным вариантом beforeunload), но не добавляет отдельный обработчик pagehide — они достаточно перекрываются на важных сценариях, чтобы второй обработчик лишь вернул проблему двойного счёта, не покрыв заметно больше случаев.
Отправка данных с умирающей страницы: navigator.sendBeacon
Нельзя запустить асинхронный fetch во время visibilitychange и ждать его завершения — браузер вправе снести страницу раньше. Именно для этого существует спецификация Beacon API. navigator.sendBeacon(url, data) ставит в очередь небольшой POST, попытку отправки которого браузер гарантирует даже после исчезновения документа, не блокируя основной поток и не задерживая навигацию.
У Beacon есть ограничения, которые стоит уважать:
- Размер тела. Спецификация разрешает user agent отклонять beacon-запросы сверх лимита, определяемого реализацией (обычно 64 КБ). Держите тело крошечным — у Monoid это несколько сотен байт.
- Метод. Beacon всегда POST. Если ваш edge-эндпоинт ждёт GET, адаптируйте его.
- Нет ответа. Прочитать ничего обратно нельзя. Отправил и забыл.
Современный запасной вариант — fetch(url, { keepalive: true }): стандарт Fetch определяет его как разрешение запросу пережить документ. keepalive даёт заголовки и методы, которых у Beacon нет, но потолок размера у них общий. Общепринятая мудрость гласит: сначала Beacon, затем откат на fetch с keepalive — Monoid на деле делает наоборот, по причинам, которые объясняет следующий раздел.
Минимальная реализация без cookie
Вот эталонный паттерн, объединяющий всё сказанное выше — сначала sendBeacon, fetch keepalive как запасной вариант, срабатывающий и от visibilitychange, и от pagehide, с защитой от повторной двойной отправки:
let sent = false;
function flush() {
if (sent) return;
sent = true;
const payload = JSON.stringify({
path: location.pathname,
// никаких идентификаторов, cookie и отпечатков
visibleMs: Math.round(performance.now())
});
const ok = navigator.sendBeacon('/collect', payload);
if (!ok) {
fetch('/collect', { method: 'POST', body: payload, keepalive: true });
}
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
window.addEventListener('pagehide', flush);
Обратите внимание на то, чего здесь нет: обработчика unload, поэтому право на bfcache сохраняется. Нет сохранённого между загрузками идентификатора. Значение visibleMs берётся из performance.now() — монотонных часов, которым не нужны ни отметка астрономического времени, ни постоянное состояние.
Собственный трекер Monoid идёт по смежному, но иному пути: только fetch keepalive (без sendBeacon — он не умеет выразить mode: 'cors', который нужен при установке на другом источнике), без защиты sent, и с таймером, который сбрасывается при каждом переходе видимости, а не срабатывает один раз и замолкает (проверка согласия и Do Not Track ниже опущена для ясности):
function dur() {
fetch('/duration', {
method: 'POST',
body: JSON.stringify({ site_id: siteId, duration_ms: Date.now() - t }),
headers: { 'Content-Type': 'application/json' },
keepalive: true,
mode: 'cors',
});
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') dur();
t = Date.now();
});
window.addEventListener('beforeunload', dur);
Сброс t при каждом переходе делает ту же работу, что и защита sent выше, но без флага: каждый вызов сообщает только сегмент с момента последнего сброса часов, поэтому срабатывание visibilitychange, за которым следует beforeunload при том же закрытии вкладки, сообщает один реальный сегмент и один безобидный почти-нулевой — вместо двойного счёта.
Почему это подходит аналитике privacy-first
Поскольку Monoid никогда не сшивает сессии, каждый запрос — цельное одноразовое наблюдение. Нет межстраничного идентификатора, который нужно защищать, поэтому потерянный или задвоенный образец стоит вам одной точки данных, а не испорченного профиля пользователя. Связка visibilitychange плюс beforeunload, которую Monoid реально поставляет, даёт точное измерение времени на странице и одновременно оставляет страницу быстрой и дружественной к bfcache — а это само по себе улучшает те самые Core Web Vitals, которые вы, возможно, пытаетесь измерить.
Измеряйте уход, а не человека. Браузер уже даёт вам всё необходимое.
Comments
Loading comments…