30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

ルーティング機能を自作して学ぶ History API

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);

第三引数にパスを渡すと、そのパスに遷移する。statetitleについては後述する。

/で表示するドキュメントが以下の内容のときに/にアクセスすると、/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.backwindow.history.forwardwindow.history.go、の 3 つの API がそれに該当する。

backはブラウザバック、forwardはブラウザフォワードと同じことを実行する。
そのため以下の「戻る」ボタンは、ブラウザバックと同じように動作する。

<button
  type="button"
  onClick={() => {
    window.history.back();
  }}
>
  戻る
</button>

これ以上戻れない状態でbackを実行したり、進めない状態でforwardを実行したりしても、何も発生しない。例外が投げられることもない。

goは引数に数値を渡し、その数だけ履歴を移動する API。go(1)ならひとつ先に進み、go(-2)ならふたつ前に戻る。
この API もbackforwardと同様に、存在しない履歴に移動しようとすると何も発生せず、例外も発生しない。

そしてpopstateイベントは、これらの API を実行したときも発生する。
もちろん、該当する履歴が存在しなかった場合は何も発生しないため、popstateイベントも起きない。

state を使って履歴毎に状態を設定する

<Content />の上に、カナを表示するようにした。
foobarなどのパスの読み仮名を表示する。
現在はフーとハードコーディングしているが、これをパスに応じて動的に変化させるようにしたい。

   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で、この挙動を制御することができる。

このプロパティには、automanualを設定できる。
初期値はautoで、既に見たようにスクロール位置を復元する。

console.log(window.history.scrollRestoration); // auto

manualを設定すると、スクロール位置が復元されなくなる。
そのため、/fooから/barに遷移したあとにブラウザバックで戻った際、一番上にスクロールされた状態で表示されるようになる。

  useEffect(() => {
    window.history.scrollRestoration = 'manual';
  }, []);

参考資料

  • 『WEB+DB PRESS Vol.97』「特集1 Reactで作るシングルページアプリケーション入門 第3章 クライアントサイドルーティングの実装」