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章 クライアントサイドルーティングの実装」