History API は、HTML5 で導入された API。
これを使うことで、JavaScript で URL の履歴を管理できるようになる。
多くの場合、そういった操作は React Router や Vue Router などのルーティングライブラリを通して行うことになる。そのため、History API を直接操作する機会は稀だと思う。
しかし、ルーティングライブラリを使いこなし、特殊なユースケースにも対応できるようになるためには、History API そのものについても理解しておきたい。
この記事では、ルーティング機能を持った React アプリを開発しながら、History API について学んでいく。
使用している React のバージョンは16.13.1
。
動作確認は Google Chrome の81.0.4044.113
で行っている。
コンテンツに対して URL を割り当てる
まず、URL に応じて表示内容を切り替えられるようにする。
これは難しい話は何もなく、現在の URL を取得し、それに応じてコンテンツを返すようにすればよい。History API を使う必要もない。
今回作るのはシンプルなアプリなので、switch
を使った単純な実装にした。
import React from 'react'; export const App = () => { const Content = () => { const path = window.location.pathname; switch (path) { case '/foo': return <div>foo</div>; case '/bar': return <div>bar</div>; default: return <div>Not Found</div>; } }; return <Content />; };
/foo
にアクセスすればfoo
が表示され、/bar
にアクセスすればbar
が表示される。
それ以外のパスだとNot Found
が表示される。
pushState で履歴を追加する
次に、URL を変更し、コンテンツからコンテンツへと遷移できるようにする。
ここから History API を使っていく。
pushState
を使用することで、指定した URL に遷移し、その履歴をブラウザに記録させることができる。
次のように、3 つの引数を渡すことができる。
window.history.pushState(state, title, url);
第三引数にパスを渡すと、そのパスに遷移する。state
とtitle
については後述する。
/
で表示するドキュメントが以下の内容のときに/
にアクセスすると、/foo
に遷移する。
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>History API test</title> </head> <body> <script> console.log(window.location.pathname); // / window.history.pushState(null, null, 'foo'); console.log(window.location.pathname); // /foo </script> </body> </html>
第二引数で、遷移後のページタイトルを指定できることになっている。
しかし、これに対応しているブラウザは現在のところほぼ存在せず、値を渡しても無視される。
下記の例でも、Original Name
のまま変化しない。
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>Original Name</title> </head> <body> <script> console.log(window.location.pathname); // / window.history.pushState(null, 'New Name', 'foo'); console.log(window.location.pathname); // /foo console.log(window.document.title); // Original Name </script> </body> </html>
第三引数には URL のフルパスを渡すこともできるが、現在の URL と同じオリジンでなければならない。
オリジンが異なる URL を渡すと、例外を投げる。
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>Original Name</title> </head> <body> <script> history.pushState(null, null, 'https://google.com'); // Uncaught DOMException: Failed to execute 'pushState' on 'History' </script> </body> </html>
pushState
を使って、先程の React アプリにページ遷移機能を導入する。
import React from 'react'; const Link = ({to, children}) => { const onClick = e => { e.preventDefault(); window.history.pushState(null, null, to); }; return ( <a href={to} onClick={onClick}> {children} </a> ); }; export const App = () => { const Content = () => { const path = window.location.pathname; switch (path) { case '/foo': return <div>foo</div>; case '/bar': return <div>bar</div>; default: return <div>Not Found</div>; } }; return ( <div> <Content /> <nav> <Link to="/foo">to foo</Link> <Link to="/bar">to bar</Link> </nav> </div> ); };
Link
コンポーネントを作り、それにto
というprops
を渡すことで、指定したパスに遷移できるようにした。
まず/foo
にアクセスし、そこでto bar
を押下すると、URL が/bar
切り替わっていることを確認できる。
その後ブラウザバックを行うと、/foo
に戻る。
しかしよく見ると、表示内容はfoo
のまま変化していない。
pushState
はあくまでも URL の履歴を追加する機能であり、表示内容には関知しないのである。
pushState
によって何らかのリソースが読み込まれることもない。
このままでは困るので、useState
を使ってApp
を再レンダリングする仕組みを導入した。
reRender
を実行するとstate
が変化するため、再レンダリングされる。
そして、リンクを押下したタイミングでreRender
を実行するようにしている。
import React, {useState, useCallback} from 'react'; const Link = ({to, reRender, children}) => { const onClick = e => { e.preventDefault(); window.history.pushState(null, null, to); reRender(); }; return ( <a href={to} onClick={onClick}> {children} </a> ); }; export const App = () => { const [state, setState] = useState(true); const reRender = useCallback(() => { setState(s => !s); }, []); const Content = () => { const path = window.location.pathname; switch (path) { case '/foo': return <div>foo</div>; case '/bar': return <div>bar</div>; default: return <div>Not Found</div>; } }; return ( <div> <Content /> <nav> <Link to="/foo" reRender={reRender}> to foo </Link>{' '} <Link to="/bar" reRender={reRender}> to bar </Link> </nav> </div> ); };
これで、リンクを押すと URL もコンテンツも正しく変化するようになった。
popstate イベントでブラウザ操作に対応する
しかしまだ問題が残っている。
リンクを押下したときはコンテンツが切り替わるのだが、ブラウザバックやフォワードを行ったときは、一切変化しない。
考えてみれば当然のことで、reRender
を実行するのはあくまでもLink
を押下したときであり、ブラウザバックしたときはreRender
は実行されない。
この問題には、popstate
イベントを使って対応する。
popstate
は History API ではなく、ブラウザバックやフォワードが発生した際に呼ばれる、window
オブジェクトのイベントである。
マウント後にpopstate
イベントに対してreRender
を設定することで、ブラウザバックやフォワードでもreRender
が実行されるようになる。
@@ -1,4 +1,4 @@ -import React, {useState, useCallback} from 'react'; +import React, {useState, useCallback, useEffect} from 'react'; const Link = ({to, reRender, children}) => { const onClick = e => { @@ -21,6 +21,13 @@ setState(s => !s); }, []); + useEffect(() => { + window.addEventListener('popstate', reRender); + return () => { + window.removeEventListener('popstate', reRender); + }; + }, [reRender]); + const Content = () => { const path = window.location.pathname; switch (path) {
これで、意図した通りに動くようになり、最低限のルーティング機能を実装できたと言える。
ちなみに History API には、ブラウザバックやフォワードと同様の操作を行う API が用意されている。
window.history.back
、window.history.forward
、window.history.go
、の 3 つの API がそれに該当する。
back
はブラウザバック、forward
はブラウザフォワードと同じことを実行する。
そのため以下の「戻る」ボタンは、ブラウザバックと同じように動作する。
<button type="button" onClick={() => { window.history.back(); }} > 戻る </button>
これ以上戻れない状態でback
を実行したり、進めない状態でforward
を実行したりしても、何も発生しない。例外が投げられることもない。
go
は引数に数値を渡し、その数だけ履歴を移動する API。go(1)
ならひとつ先に進み、go(-2)
ならふたつ前に戻る。
この API もback
やforward
と同様に、存在しない履歴に移動しようとすると何も発生せず、例外も発生しない。
そしてpopstate
イベントは、これらの API を実行したときも発生する。
もちろん、該当する履歴が存在しなかった場合は何も発生しないため、popstate
イベントも起きない。
state を使って履歴毎に状態を設定する
<Content />
の上に、カナ
を表示するようにした。
foo
やbar
などのパスの読み仮名を表示する。
現在はフー
とハードコーディングしているが、これをパスに応じて動的に変化させるようにしたい。
return (
<div>
+ カナ: フー
<Content />
<nav>
<Link to="/foo" reRender={reRender}>
様々な方法が考えられるが、pushState
の第一引数であるstate
を使うことでも、対応できる。
pushState
で履歴を追加する際、第一引数に渡した値が、履歴に紐付いた情報として保存される。
保存した情報はwindow.history.state
に格納されている。
履歴に紐付いているため、ブラウザバック等を行ったときも問題なく復元される。
今回は{kana: 'フー'}
のような形で、読み仮名をstate
に保存することにした。
@@ -1,9 +1,9 @@ import React, {useState, useCallback, useEffect} from 'react'; -const Link = ({to, reRender, children}) => { +const Link = ({to, state, reRender, children}) => { const onClick = e => { e.preventDefault(); - window.history.pushState(null, null, to); + window.history.pushState(state, null, to); reRender(); }; @@ -45,10 +45,10 @@ カナ: フー <Content /> <nav> - <Link to="/foo" reRender={reRender}> + <Link to="/foo" state={{kana: 'フー'}} reRender={reRender}> to foo </Link>{' '} - <Link to="/bar" reRender={reRender}> + <Link to="/bar" state={{kana: 'バー'}} reRender={reRender}> to bar </Link> </nav>
あとは、情報を取り出して表示に使えばよい。
@@ -40,9 +40,11 @@ } }; + const kana = window.history.state ? window.history.state.kana : ''; + return ( <div> - カナ: フー + カナ: {kana} <Content /> <nav> <Link to="/foo" state={{kana: 'フー'}} reRender={reRender}>
注意点としては、ページを最初に読み込んだときはまだ何も情報を保存していないため、window.history.state
にはnull
が格納されている。
そのため、そのことも考慮したコードを書かなければならない。
今回は使わなかったが、popstate
のイベントオブジェクトでも、state
を取得することができる。
window.addEventListener('popstate', e => { console.log(window.location.pathname); // /foo console.log(e.state); // {kana: "フー"} });
replaceState で履歴を書き換える
先程のアプリを少し改変して、新しくアプリを作った。
今度のアプリは、/public
と/private
の 2 つのパスが用意されており、/public
は誰でもアクセスできる。
だが/private
は認可を受けたユーザしかアクセスできず、認可を受けていない人が/private
にアクセスすると/error
に飛ばされる仕様になっている。
認可の仕組みはまだ実装できていないが、取り敢えず/private
にアクセスがあった場合は/error
に遷移させるようにした。
import React, {useState, useCallback, useEffect} from 'react'; // Link は省略 export const App = () => { const [state, setState] = useState(true); const reRender = useCallback(() => { setState(s => !s); }, []); useEffect(() => { window.addEventListener('popstate', reRender); return () => { window.removeEventListener('popstate', reRender); }; }, [reRender]); const path = window.location.pathname; const Content = () => { switch (path) { case '/public': return <div>public</div>; case '/private': return <div>private</div>; case '/error': return <div>error</div>; default: return <div>Not Found</div>; } }; // /error に遷移させる if (path === '/private') { window.history.pushState(null, null, '/error'); reRender(); } return ( <div> <Content /> <nav> <Link to="/public" reRender={reRender}> to public </Link>{' '} <Link to="/private" reRender={reRender}> to private </Link> </nav> </div> ); };
だがこの実装には問題がある。
まず/public
にアクセスし、そこでto private
を押下する。すると、/error
に遷移する。
ここまではよい。しかし、ブラウザバックで/public
に戻ろうとしても、戻れない。常に/error
が表示されてしまう。
これは、ブラウザバックするとまず/private
にアクセスし、そして間髪入れずに/error
への遷移を行うためである。
/public -> /private -> /error
と遷移しているため、ブラウザバックで/private
に戻る度に/error
に遷移してしまう。
/error
への遷移をpushState
ではなくreplaceState
で行うようにすると、この問題を解決できる。
replaceState
の使い方は、pushState
と同じ。
window.history.replaceState(state, title, url);
違いは、pushState
は履歴を追加するのに対し、replaceState
は現在の履歴を上書きする。
/private
にアクセスがあった場合にreplaceState
を使って/error
に遷移させると、/private
へのアクセスが/error
へのアクセスに上書きされる。
これにより、URL の履歴上は/public -> /error
と遷移したことになる。そのため、/error
からブラウザバックすると/public
が表示されるようになる。
// /error に遷移させる if (path === '/private') { window.history.replaceState(null, null, '/error'); reRender(); }
scrollRestoration でスクロールバーの状態を制御する
通常、スクロールバーの状態は維持され、ブラウザバックやフォワードを行うと、正しく復元される。
検証のため、Content
を以下のように変えた。
const Content = () => { const path = window.location.pathname; switch (path) { case '/foo': return [...Array(30)].map((i, index) => <p key={index}>foo</p>); case '/bar': return <div>bar</div>; default: return <div>Not Found</div>; } };
/foo
のコンテンツが長くなったので、スクロールしないとリンクが表示されない。
そして、一番下までスクロールした状態でリンクを押下して/bar
に遷移する。
そしてブラウザバックで/foo
に戻ってくると、一番下までスクロールされた状態で表示される。
window.history.scrollRestoration
で、この挙動を制御することができる。
このプロパティには、auto
かmanual
を設定できる。
初期値はauto
で、既に見たようにスクロール位置を復元する。
console.log(window.history.scrollRestoration); // auto
manual
を設定すると、スクロール位置が復元されなくなる。
そのため、/foo
から/bar
に遷移したあとにブラウザバックで戻った際、一番上にスクロールされた状態で表示されるようになる。
useEffect(() => { window.history.scrollRestoration = 'manual'; }, []);
参考資料
- 『WEB+DB PRESS Vol.97』「特集1 Reactで作るシングルページアプリケーション入門 第3章 クライアントサイドルーティングの実装」