広く利用されている History API は、最も基本的なユースケースにおいてさえ利用が困難です。このことは、カスタムビルドのWebアプリケーションや共有コンポーネントライブラリにとって障害となっています。
History APIの使用を必要とする主要なユースケースには、以下のようなものがあります。
- シングルページアプリケーション(SPA)向けのWebアプリルーター: クライアントサイドルーターはナビゲーションを捕捉し、必要な読み込みとレンダリングを同じウィンドウ内で実行し、最終的にアドレスバーのURLと履歴スタックを更新します。
- ポップアップやダイアログなどのUIオーバーレイコンポーネント: 通常、意味のあるアドレスバーの変更は伴いませんが、オーバーレイは「戻る」ボタンを使用してキャンセル可能である必要があります。例えば、リッチな日付選択ポップアップでユーザーが「戻る」ボタンをクリックした際、ポップアップが閉じるのではなく、ブラウザが前のページに戻ってしまうのは悪いUX(ユーザー体験)です。
- 一時的な
history.state: bfcache(バック・フォワード・キャッシュ)からの高速なリロードを行うための有用なストレージとなり得ます。 - 昔ながらの
<a href="#...">によるフラグメントナビゲーション。
しかし、既存のAPIには以下のような重大な欠点があります。
history.stateの信頼性が低い: Webアプリの予期しないタイミングで消失する可能性があります。例えば、フラグメントナビゲーションやiframe内でのプッシュ発生時などです。history.stateがスタックとして機能しない: 本来、スタックのプロパティは、明示的に上書きされない限り新しい状態(state)にも再帰的に適用されるべきです。しかしhistory.stateは、ナビゲーションのたびに状態を完全に置き換えてしまいます。popstateイベントが理にかなっていない: このイベントはポップ(戻る/進む)だけでなく、フラグメントナビゲーション(これは「プッシュ」操作です)でも発火します。また、window.history.go(-2)などを実行した際の中間のポップ処理を行うことが不可能です。- 履歴イベントがキャンセルできない: 「保存せずに移動してよろしいですか?」という機能を実装するには、すべての履歴変更をバッファリングし、プッシュし直すという処理が必要になります。
- クライアントサイドナビゲーションの実装が複雑すぎる: Webアプリは結局、グローバルなクリックイベントを捕捉し、プログラムでナビゲーションを行うための特別なAPIを自作することになります。
- 履歴とナビゲーションの同期・非同期のセマンティクスが予測不能である。
- iframeの問題: 非常に注意深く作られたWebアプリであっても、iframeがアプリケーションの履歴スタックを完全に台無しにしてしまう可能性があります。
これらの欠点により、ほとんどのアプリは日常的にHistory APIにモンキーパッチ(動的な修正)を当て、独自の非標準APIを作成しています。
新しいAPIの主要原則は以下の通りあるべきです。
- 履歴はスタックであるべき: 状態(state)とイベントはスタックモデルに従うべきです。履歴内の各状態に対して明確なプッシュおよびポップイベントが存在し、新しいプッシュが履歴スタックを完全にリセットすべきではありません。
- 同期 vs 非同期の性質を明確化する。
- 皮肉なことですが、より高レベルなAPIを選好し、History APIへの依存を減らすべきです。
以下は、いくつかのユースケースと可能なアプローチについての議論です。
典型的なWebアプリは、History APIへの過度なパッチ適用、手動でのスタック状態管理、履歴イベントのインターセプト、グローバルクリックのインターセプトなどを行うルーターに依存している可能性があります。これに代わり、ナビゲーションのユースケースは window.onnavigate イベントを使用することで、より直接的にサポートできるはずです。これは、location.assign、location.replace、location.href セッター、form.submit[method=get] など、Webアプリがナビゲートするあらゆる方法をカバーします。
典型的なWebアプリがクライアントサイドナビゲーションをサポートするために必要なのは、以下の処理だけになります。
window.onnavigate = e => {
if (router.matches(e.location)) {
// ブラウザによるナビゲーションを停止する
e.preventDefault();
router.navigate(e.location);
}
};ナビゲートするために、ユーザーコードは通常のHTMLマークアップを使用するか、JavaScriptで location.assign() を呼び出すだけです。
これだけです。グローバルリスナーも、特別な状態管理も必要ありません。実のところ、ここではブラウザの履歴(history)が直接操作されてさえいません。さらなる利点として、performance APIがクライアントサイドナビゲーションのパフォーマンスを正しく計測できるようになり、ツールやパフォーマンス分析において下流工程でのメリットが生まれます。
オーバーレイUXには、ポップアップやダイアログなどが含まれます。典型的な実装では history.pushState を使用し、popstate イベントを監視します。しかし、popstate イベントの処理には多くの問題があります。「この特定の履歴状態がポップされた」と通知するイベントが存在しないのです。その代わり、popstate イベントは新しい「現在の」状態を参照します。
これに対処するために、以下のようないくつかのアイデアが考えられます。
- 新しいAPIでは、履歴スタックからポップされるすべての履歴状態に対して、新しいイベントを設けることができます。
- 新しいAPIでは、状態を完全に上書きするのではなく、新しい履歴状態を既存の状態にマージすることができます(これについては後述します)。
このユースケースで履歴スタックを使用することには、別の煩わしさもあります。例えば、ポップアップが新しい履歴状態を作成するとします。しかし、Webページがリロードされた場合、通常はポップアップを再度開くことは望ましくありません。したがって、この履歴状態はあまり有用ではなく、単に「戻る」ボタンを捕捉するための埋め草(filler)に過ぎません。この場合の「戻る」ボタンのサポートは、多くのUIが現在行っている <kbd>Esc</kbd> キーのハンドリングに近いです。実のところ、新しいAPIとしてより単純なアプローチは、履歴状態を <kbd>Esc</kbd> キーにバインドできるようにし、状態がポップされたときに自動的にエミット(発火)されるようにすることかもしれません。
history.state は多くのユースケースで堅実な選択肢となり得ますが、現在はあまりにも予測不能です。例えば、以下のスニペットを考えてみましょう。
window.history.pushState({a: 1}, '', '');
// これで状態がわかる!
window.history.state.a === 1
// ユーザーが <a href="#b"> でフラグメントへ移動する
// おっと。状態が失われた。
window.history.state === null履歴状態を完全に上書きする代わりに、マージ操作(いわゆる Object.assign())を使用できます。そうすれば、このスニペットは以下のように異なる動作をします。
window.history.pushState({a: 1}, '', '');
// これで状態がわかる!
window.history.state.a === 1
window.history.pushState({b: 2}, '', '');
// 新しい状態:
window.history.state.b === 2
// しかし、古い状態もまだ利用可能:
window.history.state.a === 1
// ユーザーが <a href="#b"> でフラグメントへ移動する
// 古い状態はまだそこにある:
window.history.state.a === 1
window.history.state.b === 2新しい beforepopstate イベントを onbeforeunload をモデルにして作成できます。これは履歴の変更を直接拒否するわけではありませんが、ブラウザに対して確認プロンプトを表示するよう指示することができます。