1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SPA のルート変更を検知する: History API のモンキーパッチと Symbol による多重パッチ防止

1
Posted at

背景

SPA では <a> クリックや router.push() などの操作が history.pushState() / history.replaceState() を介して URL を更新します。フルページリロードは発生しないため、ナビゲーションを観測したい計測コード・分析タグ・サードパーティスクリプトは独自の検知機構を持つ必要があります。

ブラウザは戻る・進むボタン操作に対しては popstate イベントを発火しますが、pushState / replaceState の呼び出しに対してはイベントを発火しません。これは HTML Living Standard で明示的に定められた挙動です。

監視すべき 4 種類のイベント

現代の SPA フレームワーク(React Router、Vue Router、Angular Router、Next.js App Router、Nuxt など)は、いずれも以下のいずれかでルート変更を実装しています。検知側は 4 つすべてを拾う必要があります。

種類 発火源
history.pushState SPA の通常遷移 router.push('/about')
history.replaceState URL のみの書き換え router.replace('/?lang=ja')
popstate ブラウザの戻る・進む history.back() / 戻るボタン
hashchange フラグメント変更 location.hash = '#section1'

pushState / replaceState はメソッド呼び出しなのでモンキーパッチで横取りし、popstate / hashchangeaddEventListener で購読します。この 4 つを拾えば、主要 SPA フレームワークのルート変更にひと通り対応できます。

History API の基礎

History インターフェースは以下のメソッドで履歴スタックを操作します。

history.pushState(state, title, url);    // 履歴エントリを追加
history.replaceState(state, title, url); // 現在のエントリを置換
history.back();                          // 戻る
history.forward();                       // 進む
history.go(n);                           // n 個分の前後移動

popstate イベントが発火するのはユーザー操作(戻る・進むボタン、history.back() など)のときのみです。pushState / replaceState の呼び出しでは発火しません。

モンキーパッチによる検知の最小実装

history.pushStatehistory.replaceState を関数として置き換え、元のメソッドを呼んだ後にカスタムイベントを発火させます。popstate / hashchangewindow.addEventListener で購読し、すべて同じ routechange イベントに集約します。

type RouteChangeDetail = {
  type: 'pushState' | 'replaceState' | 'popstate' | 'hashchange';
  url: string;
};

function dispatchRouteChange(type: RouteChangeDetail['type']): void {
  window.dispatchEvent(
    new CustomEvent<RouteChangeDetail>('routechange', {
      detail: { type, url: location.href },
    }),
  );
}

利用側はメソッド名を意識せず routechange を購読するだけで済みます。

window.addEventListener('routechange', (event) => {
  const detail = (event as CustomEvent<RouteChangeDetail>).detail;
  console.log(detail.type, detail.url);
});

Symbol による多重パッチ防止

設計上いちばん重要な部分です。検知をインストールする関数(以下では watchRouteChange と呼びます)は、複数のモジュール・複数の初期化パスから呼ばれる可能性があります。素朴に書くと、呼ばれるたびに history.pushState を上書きするため、同じカスタムイベントが 2 回・3 回と発火します。計測タグなら同じページビューが多重カウントされ、ルーティングフックなら同じハンドラが多重実行されます。

これを防ぐ定番のパターンが、Symbol を「共有状態への入口」として使う方法です。

const ROUTE_CHANGE_STATE = Symbol.for('routechange.state');
const PATCHED = Symbol.for('routechange.patched');

type Patched<T> = T & { [PATCHED]?: true };
type RouteChangeState = {
  listeners: number;
  onPopState: () => void;
  onHashChange: () => void;
};
type RouteChangeWindow = Window & {
  [ROUTE_CHANGE_STATE]?: RouteChangeState;
};

export function watchRouteChange(): () => void {
  const win = window as RouteChangeWindow;
  const hist = win.history;

  // グローバルな routechange bus は 1 回だけインストールする
  if (!win[ROUTE_CHANGE_STATE]) {
    const state: RouteChangeState = {
      listeners: 0,
      onPopState: () => dispatchRouteChange('popstate'),
      onHashChange: () => dispatchRouteChange('hashchange'),
    };
    win.addEventListener('popstate', state.onPopState);
    win.addEventListener('hashchange', state.onHashChange);
    win[ROUTE_CHANGE_STATE] = state;
  }

  const state = win[ROUTE_CHANGE_STATE]!;
  state.listeners += 1;

  // pushState / replaceState は既にパッチ済みなら上書きしない
  if (!(hist.pushState as Patched<typeof hist.pushState>)[PATCHED]) {
    const original = hist.pushState.bind(hist);
    const patched: Patched<typeof hist.pushState> = function (state, title, url) {
      original(state, title, url);
      dispatchRouteChange('pushState');
    };
    patched[PATCHED] = true;
    hist.pushState = patched;
  }

  if (!(hist.replaceState as Patched<typeof hist.replaceState>)[PATCHED]) {
    const original = hist.replaceState.bind(hist);
    const patched: Patched<typeof hist.replaceState> = function (state, title, url) {
      original(state, title, url);
      dispatchRouteChange('replaceState');
    };
    patched[PATCHED] = true;
    hist.replaceState = patched;
  }

  return () => {
    state.listeners = Math.max(state.listeners - 1, 0);
    if (state.listeners === 0) {
      win.removeEventListener('popstate', state.onPopState);
      win.removeEventListener('hashchange', state.onHashChange);
      delete win[ROUTE_CHANGE_STATE];
    }
    // pushState / replaceState は他のリスナーがいる可能性があるので戻さない
  };
}

ポイントは 3 つです。

1. Symbol.for で共有状態とパッチ済みマーカーを共有する
ライブラリが複数バンドルに含まれた場合(モノレポ・マイクロフロントエンド・ベンダー化)、それぞれのバンドルが独自の Symbol() を作ると状態やマーカーを共有できません。Symbol.for('routechange.state')Symbol.for('routechange.patched') を使うと同じキーから同じ Symbol を取得できるため、別バンドル間でも 1 つの共有状態と 1 つのパッチ済みマーカーを参照できます。

2. popstate / hashchange もグローバルに 1 回だけ登録する
history.pushState と同じく、popstate / hashchange のリスナーも毎回追加してはいけません。複数回登録すると、戻る・進むやハッシュ変更で routechange が二重三重に発火するためです。そこでウィンドウに参照カウントを持たせ、最初の呼び出しだけリスナーを登録し、以後は利用者数だけを増やします。

3. メソッドのアンインストールはしない
返却するクリーンアップ関数では参照カウントを 1 つ減らし、最後の利用者がいなくなったときだけ popstate / hashchange のリスナーを外します。一方、history.pushState / history.replaceState の関数は元に戻しません。理由は、別の利用者がまだそのパッチに依存している可能性があるためです。Symbol マーカーで多重パッチを抑止しているので、戻さなくても害はありません。

Symbol を使わない場合の落とし穴

「モジュールスコープのフラグでよいのでは」と考えるかもしれませんが、これは複数バンドル間で共有されません。

// ダメな例: バンドルが分かれると共有されない
let installed = false;
export function watchRouteChange() {
  if (installed) return;
  installed = true;
  // ...
}

このフラグはモジュールごとに独立しているため、同じライブラリが複数のバンドルに含まれた瞬間、それぞれが「自分はまだ入れてない」と判断して二重パッチが起きます。windowhistory のような共有されたオブジェクトに状態を刻むことが、多重インストール防止の本質です。

その他の設計ポイント

元メソッドの保存は bind してから

history.pushState.bind(history) のように this を束縛したうえで保存します。history.pushState は内部で this が History インスタンスであることを前提とした実装になっており、パッチ後に直接呼ぶと Illegal invocation で失敗するブラウザがあります。

URL の取得

pushState 引数の url は相対パスや null のことがあります。location.href を読むほうが正規化された絶対 URL を得られて確実です。

イベント発火のタイミング

カスタムイベントは元メソッド呼び出しの直後に同期的に発火します。フレームワーク側のレンダリングが完了する前にハンドラが走る点に注意します。DOM 更新後に処理したい場合は queueMicrotaskrequestAnimationFrame で遅延させます。

同一 URL への遷移

同じ URL に対する pushState でもイベントは発火します。重複処理を避けたければ、購読側で前回の URL と比較して差分があるときだけ処理します。

iframe と別オリジン

window.history のパッチは現在のウィンドウにのみ作用します。iframe 内のナビゲーションは個別にパッチが必要で、別オリジンの iframe は同一オリジンポリシーで触れません。

代替手段: Navigation API

Navigation API は SPA 向けに設計された新しい仕様で、navigation.addEventListener('navigate', ...) で同一ドキュメント内のナビゲーションをイベントとして取得できます。

navigation.addEventListener('navigate', (event) => {
  console.log(event.destination.url);
});

2026 年 1 月以降は最新ブラウザ群で利用可能になってきています。ただし古い端末・古いブラウザでは未対応があり得るため、本番投入時は MDN の Browser compatibility を確認します。利用可能なら優先し、フォールバックとしてモンキーパッチを使う段階戦略がとれます。

if ('navigation' in window) {
  // Navigation API ルート
} else {
  // History API モンキーパッチルート
}

まとめ

  • 監視すべきは pushState / replaceState / popstate / hashchange の 4 種類。これで主要 SPA フレームワークのルート変更を網羅できる
  • pushState / replaceState はメソッドなのでモンキーパッチ、popstate / hashchangeaddEventListener で拾い、すべて同じカスタムイベントに集約する
  • 多重パッチ防止には Symbol.for(...) をパッチ済みマーカーとして関数自体に取り付ける。複数バンドルにまたがっても安全
  • 元メソッドは bind(history) してから保存する
  • アンインストールではリスナーだけ外し、history.pushState の関数は戻さない
  • Navigation API が普及した環境では優先し、未対応環境ではモンキーパッチでフォールバックする

参考資料

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?