ブログに戻る

可視性ステートの遷移:信頼できるビーコンなしでページビューを計測する

pagehide、visibilitychange、Beacon API はブラウザーごとに挙動が異なります。Cookie も永続識別子も使わずに、セッションの終了を確実に記録する方法を解説します。

ページが閲覧されたことを記録するのは簡単です。難しいのは、ユーザーが実際にいつ離脱したかを——タブの切り替え、バックグラウンド化、強制終了をまたいで確実に——記録することで、これはウェブアナリティクスでもかなり厄介な問題のひとつです。滞在時間・直帰・スクロール深度といったエンゲージメント指標は、きれいなセッション終了シグナルを捉えられるかどうかにかかっているため、これは重要です。取りこぼせばデータが欠け、取りすぎれば数字が膨らみます。

Monoid はこれを Cookie も localStorage も永続識別子も使わずに解いています。この制約はむしろ話を単純にします。ページ読み込みをまたいでセッションを突き合わせる必要が一切ないので、気にすべきはページのライフサイクルごとに正確な最終ビーコンを 1 本だけ送ることだけです。以下、ブラウザーのプリミティブがどう振る舞い、どこに罠があるのかを見ていきます。

なぜ unload は誤ったイベントなのか

かつての定番は unloadbeforeunload を購読して同期 XHR を撃つ方法でした。今やこれは積極的に有害です。unload イベントはモバイルで信頼できません。ユーザーがタブをバックグラウンドに送り、OS がそれを回収すると、unload はしばしば一度も発火しません。さらに悪いことに、unload ハンドラーを登録すると、いくつかのブラウザーでページが戻る/進むキャッシュ(bfcache)の対象外となり、ナビゲーション性能を損ないます。

web.dev の Page Lifecycle API のガイダンスは明快です。visibilitychangehidden への遷移を、観測できる最後の信頼できるイベントとして扱いなさい、と。モバイルで unloadpagehide が発火することを当てにしてはいけません。

visibilitychangepagehide の組み合わせ

堅牢なパターンは、visibilitychange を購読して document.visibilityState === 'hidden' を確認し、加えて pagehide を副次的なシグナルとして使うことです。ページが hidden に遷移したら、送るべきものをフラッシュします。MDN の Page Visibility API のドキュメントは、タブがバックグラウンドに回るかブラウザーが最小化されたときに visibilitychange が発火することを裏づけています——「ユーザーが見るのをやめた」に最も近い代理シグナルです。

やっかいなのは、hidden への visibilitychange が 1 セッション中に複数回発火しうる点です(タブを離れる、戻る、また離れる)。したがってフラッシュは冪等にするか、デバウンスするか、前回からの差分だけを送る必要があります。Monoid はユーザー単位の状態を一切保持しないため、自己完結した単一のペイロードを送り、重複排除はリクエスト単位でエッジに任せています。

消えゆくページからデータを送る: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 にフォールバックしてください。

最小限の、Cookie を使わない実装

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 はセッションを縫い合わせないので、各ビーコンは完結した使い捨ての観測です。守るべきページ横断の識別子が存在しないため、ビーコンを 1 本落としても失うのはデータポイント 1 個であって、壊れたユーザープロファイルではありません。visibilitychange と Beacon の組み合わせは、正確な滞在時間の計測をもたらしつつ、ページを高速かつ bfcache に優しく保ちます——それ自体が、あなたが計測しようとしているかもしれない Core Web Vitals を改善します。

人ではなく、去っていくことを測りましょう。必要なものはすべて、ブラウザーがすでに与えてくれています。

Sources

Comments

Loading comments…