可視性ステートの遷移:信頼できるビーコンなしでページビューを計測する
pagehide、visibilitychange、Beacon API はブラウザーごとに挙動が異なります。Cookie も永続識別子も使わずに、セッションの終了を確実に記録する方法を解説します。
ページが閲覧されたことを記録するのは簡単です。難しいのは、ユーザーが実際にいつ離脱したかを——タブの切り替え、バックグラウンド化、強制終了をまたいで確実に——記録することで、これはウェブアナリティクスでもかなり厄介な問題のひとつです。滞在時間・直帰・スクロール深度といったエンゲージメント指標は、きれいなセッション終了シグナルを捉えられるかどうかにかかっているため、これは重要です。取りこぼせばデータが欠け、取りすぎれば数字が膨らみます。
Monoid はこれを Cookie も localStorage も永続識別子も使わずに解いています。この制約はむしろ話を単純にします。ページ読み込みをまたいでセッションを突き合わせる必要が一切ないので、気にすべきはページのライフサイクルごとに正確な最終サンプルを 1 本だけ送ることだけです。以下、ブラウザーのプリミティブがどう振る舞い、どこに罠があり、そのうちどれを Monoid 自身のトラッカーが実際に使っているのかを見ていきます。
なぜ unload は誤ったイベントなのか
かつての定番は unload や beforeunload を購読して同期 XHR を撃つ方法でした。今やこれは積極的に有害です。unload イベントはモバイルで信頼できません。ユーザーがタブをバックグラウンドに送り、OS がそれを回収すると、unload はしばしば一度も発火しません。さらに悪いことに、unload ハンドラーを登録すると、いくつかのブラウザーでページが戻る/進むキャッシュ(bfcache)の対象外となり、ナビゲーション性能を損ないます。
web.dev の Page Lifecycle API のガイダンスは明快です。visibilitychange の hidden への遷移を、観測できる最後の信頼できるイベントとして扱いなさい、と。モバイルで unload や pagehide が発火することを当てにしてはいけません。
visibilitychange と pagehide の組み合わせ
堅牢なパターンは、visibilitychange を購読して document.visibilityState === 'hidden' を確認し、加えて pagehide を副次的なシグナルとして使うことです。ページが hidden に遷移したら、送るべきものをフラッシュします。MDN の Page Visibility API のドキュメントは、タブがバックグラウンドに回るかブラウザーが最小化されたときに visibilitychange が発火することを裏づけています——「ユーザーが見るのをやめた」に最も近い代理シグナルです。
やっかいなのは、hidden への visibilitychange が 1 セッション中に複数回発火しうる点です(タブを離れる、戻る、また離れる)。したがってフラッシュは冪等にするか、デバウンスするか、あるいは自前のタイマーをリセットして、毎回の発火が積み上がる合計ではなく新しい・重複しない区間を報告するようにする必要があります。Monoid は最後の方式を採っています。滞在時間サンプルの裏にあるタイマーは visibilitychange の遷移ごとにリセットされるため、同じページのライフサイクル内で後から発生した 2 回目の hidden イベントは、直前のフラッシュ以降の時間だけを報告します。Monoid は visibilitychange を購読していますが(既存の beforeunload フォールバックと併用)、別途 pagehide リスナーは追加していません——重要な経路では両者の重なりが十分大きく、2 本目のリスナーを足しても、下記の二重計上の問題を再び持ち込むだけで、目立って多くのケースをカバーできるわけではないためです。
消えゆくページからデータを送る:navigator.sendBeacon
visibilitychange の最中に非同期の fetch を撃って完了を期待することはできません——ブラウザーが先にページを破棄しうるからです。Beacon API の仕様はまさにこのために存在します。navigator.sendBeacon(url, data) は小さな POST をキューに入れ、ドキュメントが失われた後でもブラウザーが送信を試みることを保証します。メインスレッドをブロックすることも、ナビゲーションを遅らせることもありません。
Beacon には守るべき制限があります。
- ペイロードサイズ。 仕様は、実装依存の上限(一般に 64 KB)を超えるビーコンをユーザーエージェントが拒否することを許しています。ペイロードは極小に保ちましょう——Monoid のものは数百バイトです。
- メソッド。 ビーコンは常に POST です。エッジのエンドポイントが GET を前提にしているなら、合わせて直してください。
- レスポンスなし。 何も読み返せません。撃ちっぱなしです。
現代的なフォールバックは fetch(url, { keepalive: true }) です。Fetch 標準は、これをリクエストがドキュメントより長生きすることを許す仕組みとして定義しています。keepalive は Beacon にないヘッダーやメソッドを使えますが、サイズ上限は同じです。定石ではまず Beacon、だめなら keepalive 付き fetch にフォールバックするとされますが、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 の適格性が保たれます。読み込みをまたいで保存される ID もありません。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 が発火しても、1 本の実際の区間と、無害でほぼゼロのもう 1 本を報告するだけで、二重計上にはなりません。
なぜこれがプライバシーファーストのアナリティクスに適うのか
Monoid はセッションを縫い合わせないので、各リクエストは完結した使い捨ての観測です。守るべきページ横断の識別子が存在しないため、サンプルを 1 本落とすか重複させても失うのはデータポイント 1 個であって、壊れたユーザープロファイルではありません。Monoid が実際に配布している visibilitychange と beforeunload の組み合わせは、正確な滞在時間の計測をもたらしつつ、ページを高速かつ bfcache に優しく保ちます——それ自体が、あなたが計測しようとしているかもしれない Core Web Vitals を改善します。
人ではなく、去っていくことを測りましょう。必要なものはすべて、ブラウザーがすでに与えてくれています。
Comments
Loading comments…