絞り込みタブを切り替えたあとにページを共有したら、相手には初期表示が見えてしまった。ブラウザーの戻るボタンを押したら、タブを戻るどころか前のページへ飛ばされてしまった。こうした「画面の状態が URL に残っていない」問題を解決するのが History API です。history.pushState() と history.replaceState() を使うと、ページを再読み込みせずにアドレスバーの URL だけを書き換えられます。この記事では2つのメソッドの引数、戻る/進むに対応するための popstate イベント、そして絞り込みタブを URL に残す実例まで解説します。
目次
History API で何ができるか
history オブジェクトは、そのタブで開いてきたページの履歴(セッション履歴)を扱うための窓口です。History API が提供するのは大きく2つで、ひとつは履歴を前後に移動すること、もうひとつが新しい履歴エントリーを自分で積んだり、今のエントリーを書き換えたりすることです。後者が pushState() と replaceState() にあたります。
ここで重要なのは、これらのメソッドがページの読み込みを一切行わないことです。location.href = '?tab=css' と書けばページ全体が再読み込みされますが、pushState() はアドレスバーの表示と履歴エントリーを変えるだけで、画面はそのまま何も起きません。表示を変えるのは自分のコードの仕事です。
この性質が役に立つのは、たとえば次のような場面です。一覧ページの絞り込み条件、タブの選択状態、モーダルの開閉、無限スクロールで今どこまで読んだか。いずれも「表示は JavaScript だけで切り替わるが、その状態を URL に残して共有・ブックマークできるようにしたい」という要求で、そこに戻る/進むボタンが期待どおり効くと、ユーザーにとってはごく普通のページとして扱えるようになります。
pushState() で URL を書き換える
まずは最小の例です。https://example.com/articles を開いている状態で、次のコードを実行してみます。
// 第1引数が state、第2引数は使われない、第3引数が新しい URL
history.pushState( { page: 2 }, '', '?page=2' );
// アドレスバーは https://example.com/articles?page=2 になる
// ただし再読み込みは起きず、画面の中身は一切変わらない
console.log( location.search ); // "?page=2"
console.log( history.state ); // { page: 2 }
アドレスバーの表示が変わり、履歴が1つ積まれました。この状態で戻るボタンを押せば ?page=2 が付く前の URL に戻ります。location.search や location.href も新しい値になっているので、その時点の URL を読んで処理を分けることもできます。
3つの引数
| 引数 | 内容 |
|---|---|
state | その履歴エントリーに保存しておくデータ。オブジェクト・配列・文字列など、構造化複製できる値なら何でも入る(DOM 要素や関数は入れられない)。不要なら null |
unused | もともとは履歴のタイトル用だったが、現在ほとんどのブラウザーが無視する。空文字列 '' を渡しておく |
url | 新しい URL。省略または null なら現在の URL のまま。同一オリジンでなければならない |
url は相対パスで書けます。'?page=2' ならクエリ文字列だけの差し替え、'/articles/2' ならパスごとの差し替え、'detail.html' なら現在の URL を基準に解決されます。絶対 URL も渡せますが、別オリジンを指定すると SecurityError という例外になります。アドレスバーの表示だけ他サイトに見せかける、といったことはできない仕様です。
state は履歴エントリーそのものに紐づいて保存されます。あとで戻る/進むでそのエントリーに帰ってきたとき、保存しておいた値をそのまま受け取れるのがポイントです。ただし保存領域は無制限ではなく、ブラウザーによっては上限があります(Firefox は 16MiB)。記事の本文データを丸ごと入れるような使い方はせず、復元に必要な最小限のキーだけを入れるのが安全です。
replaceState() は履歴を増やさない
引数はまったく同じで、違うのは履歴の扱いだけです。pushState() が新しいエントリーを追加するのに対し、replaceState() は今いるエントリーを上書きします。戻るボタンの行き先は変わりません。
// 履歴が1つ増える。戻るボタンで直前の URL に戻れる
history.pushState( { tab: 'css' }, '', '?tab=css' );
// 履歴は増えない。今の ?tab=css が ?tab=js に差し替わるだけ
history.replaceState( { tab: 'js' }, '', '?tab=js' );
使い分けの目安は「ユーザーが戻るボタンで取り消したいと思う操作かどうか」です。タブの切り替えやページ送りのように、意識的に選んだ操作は pushState() が向いています。一方、検索ボックスに文字を打つたびに URL を同期したい場合や、スライダーを動かしている最中のような連続的な変化で pushState() を呼ぶと、戻るボタンを何十回も押さないと前のページに帰れなくなります。こうしたケースは replaceState() にするか、入力が落ち着いてから一度だけ pushState() する形にします。
もうひとつ replaceState() が活躍するのが、ページを開いた直後です。通常の読み込みで作られた履歴エントリーには state が入っていないので(history.state は null です)、最初の状態をここで書き込んでおきます。これをやっておかないと、戻ってきたときに復元すべき情報が無い、という事態になります。後半の「戻るボタンで画面が戻らないとき」で詳しく触れます。
履歴を移動するメソッドと状態を読むプロパティ
URL の書き換えとあわせて覚えておきたいのが、履歴を移動する側の機能です。どれも history オブジェクトから使えます。
| メンバー | 内容 |
|---|---|
history.back() | 1つ前の履歴へ戻る。ブラウザーの戻るボタンと同じ |
history.forward() | 1つ先の履歴へ進む。進むボタンと同じ |
history.go( n ) | n 個分移動する。go( -1 ) は back()、go( 1 ) は forward() と同じ。go( 0 ) は現在のページを再読み込みする |
history.length | セッション履歴のエントリー数(現在のページを含む)。読み取り専用 |
history.state | 現在の履歴エントリーに保存されている state。何も入っていなければ null。読み取り専用 |
移動先が無いところへ動こうとした場合、たとえば履歴の先頭で back() を呼んでも、エラーにはならず何も起きません。また history.length で分かるのは件数だけで、履歴に入っている URL の一覧を読むことはできません。ユーザーの閲覧履歴をサイト側から覗けてしまうと問題があるため、そうした情報は意図的に公開されていません。「前のページに戻るボタン」を実装するときに、戻り先がサイト内かどうかを判定したくなりますが、document.referrer を見るなど別の手段が必要になります。
popstate イベントで戻る・進むに対応する
ここが History API のいちばん大事な部分です。pushState() で URL を変えても、画面は自動では変わりません。同じように、ユーザーが戻るボタンを押して URL が前の状態に戻っても、画面は自動では戻りません。ページの再読み込みが起きていないのですから当然です。
そこで、戻る/進むが起きたことを知らせてくれるのが popstate イベントです。window で発生し、移動先の履歴エントリーに保存されていた state が event.state に入ってきます。
window.addEventListener( 'popstate', ( event ) => {
// 移動先のエントリーに保存されていた state
console.log( event.state ); // 例: { tab: 'css' }
// URL はすでに書き換わっているので、こちらからも読める
console.log( location.search );
// 画面を state に合わせて描き直すのは自分の仕事
render( event.state );
} );
注意したいのは、pushState() と replaceState() は popstate を発火しないことです。自分で URL を変えたときは、続けて自分で描画処理を呼びます。popstate が飛んでくるのは、戻る/進むボタンが押されたときや、history.back()・history.go() をコードから呼んだときなど、履歴エントリー間を移動したときだけです。
つまり全体の流れは、「状態が変わったら pushState して描画する」「popstate が来たら state を読んで描画する」の2本立てになります。描画処理を1つの関数に切り出しておき、両方からそれを呼ぶ形にすると見通しが良くなります。
絞り込みタブの状態を URL に残す
実際のコードで組み立ててみます。3つのタブで表示を切り替え、選んだタブを ?tab=css のようにクエリ文字列へ残す例です。クエリ文字列の組み立てには URLSearchParams を使います。
<div class="tabs">
<button type="button" class="tab" data-tab="all">すべて</button>
<button type="button" class="tab" data-tab="css">CSS</button>
<button type="button" class="tab" data-tab="js">JavaScript</button>
</div>
<div class="panel" data-panel="all">すべての記事</div>
<div class="panel" data-panel="css" hidden>CSS の記事</div>
<div class="panel" data-panel="js" hidden>JavaScript の記事</div>
const tabs = document.querySelectorAll( '.tab' );
const panels = document.querySelectorAll( '.panel' );
// 表示を切り替えるだけの関数。履歴には一切触れない
function render( tab ) {
tabs.forEach( ( button ) => {
button.classList.toggle( 'is-active', button.dataset.tab === tab );
} );
panels.forEach( ( panel ) => {
panel.hidden = panel.dataset.panel !== tab;
} );
}
// 現在の URL から tab を読む。無ければ all
function getTabFromUrl() {
return new URLSearchParams( location.search ).get( 'tab' ) ?? 'all';
}
// 1. 最初の履歴エントリーにも state を入れておく
const initialTab = getTabFromUrl();
history.replaceState( { tab: initialTab }, '', location.href );
render( initialTab );
// 2. タブを押したら URL を書き換えて履歴を積み、描画する
tabs.forEach( ( button ) => {
button.addEventListener( 'click', () => {
const tab = button.dataset.tab;
// 同じタブを連打しても履歴を増やさない
if ( tab === history.state?.tab ) {
return;
}
const params = new URLSearchParams( location.search );
params.set( 'tab', tab );
history.pushState( { tab }, '', `?${ params }` );
render( tab );
} );
} );
// 3. 戻る/進むで呼ばれる。ここでも描画は自分で行う
window.addEventListener( 'popstate', ( event ) => {
render( event.state?.tab ?? getTabFromUrl() );
} );
タブを「CSS」「JavaScript」と押してから戻るボタンを2回押すと、URL とタブの表示が一緒に巻き戻ります。?tab=js を付けた URL をそのまま開けば、最初から JavaScript タブが選ばれた状態になります。
読み込み時に replaceState() を呼んでいるのは、最初のエントリーにも { tab: 'all' } を持たせるためです。これが無いと、タブを切り替えたあと最初まで戻ったときに event.state が null になり、何を表示すべきか分からなくなります。なお上のコードでは event.state?.tab ?? getTabFromUrl() と書いて、state が無い場合は URL から読み直す保険も掛けています。state と URL の両方から状態を復元できるようにしておくと、どちらの経路で来ても同じ画面になり、扱いが楽になります。
戻るボタンで画面が戻らないとき
History API を使い始めて最初に引っかかるのは、たいてい戻る/進むまわりです。症状ごとに原因を見ていきます。
URL だけ戻って画面が変わらない
アドレスバーは ?tab=all に戻るのに、表示は CSS タブのまま。これは popstate を購読していないときの典型的な症状です。繰り返しになりますが、履歴を移動してもブラウザーは画面を復元してくれません。window.addEventListener( 'popstate', ... ) を登録し、その中で描画関数を呼んでいるか確認してください。
登録しているのに呼ばれない場合は、document や特定の要素に登録していないか見直します。popstate は window で発生するイベントです。
最初のページまで戻ると表示が崩れる
途中までは正しく戻るのに、いちばん最初の状態まで戻ったときだけおかしくなる、というパターンです。原因は、通常の読み込みで作られた履歴エントリーには state が入っていないことにあります。event.state が null のまま描画関数に渡され、event.state.tab の参照でエラーになるか、何も表示されない状態になります。
対策は先ほどの例のとおり、起動時に replaceState() で初期状態を書き込んでおくことです。あわせて event.state が null でも URL から復元できるようにしておけば、どちらか一方が欠けても画面は成立します。
その URL を直接開くと 404 になる
pushState() はサーバーにその URL が存在するかどうかを確認しません。存在しない URL でも平気でアドレスバーに表示します。問題が出るのは、ユーザーがその URL をブックマークしたり、ページを再読み込みしたりしたときです。そこで初めてサーバーへリクエストが飛び、ルートが無ければ 404 が返ります。
これは JavaScript 側では解決できません。サーバー側に、その URL でも同じページを返す設定が必要です。Apache なら .htaccess の mod_rewrite、Nginx なら try_files、WordPress ならリライトルールの追加といった対応になります。逆に言えば、?tab=css のようにクエリ文字列だけを変える形にしておけば、サーバーから見れば同じページへのリクエストなので、この問題は起きません。パスを書き換えるかクエリで済ませるかは、サーバー側の手当てとセットで決めてください。
SecurityError が出て URL を変えられない
「A history state object with URL … cannot be created in a document with origin …」といったメッセージとともに例外が発生する場合、url に別オリジンの URL を渡しています。オリジンはスキーム・ホスト・ポートの組み合わせなので、http:// のページから https:// の URL を渡した場合や、ポート番号が違う場合も別オリジン扱いです。
もうひとつ見落としやすいのが、ローカルのファイルを file:// で直接開いて試しているケースです。ブラウザーによっては期待どおりに動かないので、簡易サーバーを立てて http://localhost 経由で確認してください。
まとめ
history.pushState( state, '', url ) は履歴を1つ積みながら URL を書き換え、history.replaceState() は今の履歴エントリーを上書きします。第2引数は現在使われないので空文字列を渡し、url は同一オリジンのものだけを指定します。state にはその画面を復元するための最小限のデータを入れておきます。
そして肝心なのは、URL を変えても画面は変わらないということです。状態を変えたときは pushState() と描画をセットで行い、戻る/進むに備えて popstate を購読し、event.state から画面を組み立て直します。ページを開いた直後に replaceState() で初期状態を入れておくこと、そして URL からも状態を復元できるようにしておくことを忘れなければ、ひととおり期待どおりに動くはずです。
なお、より高機能な後継として Navigation API という新しい仕様も登場していますが、対応状況はブラウザーによって差があります。まずは History API で、手元の絞り込みやタブの状態を URL に残すところから始めてみてください。