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

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

『ハイパフォーマンス ブラウザネットワーキング』を読んだ

ウェブに関するネットワーク技術について網羅的に扱った書籍。
TCP や UDP のようなトランスポートプロトコルの話から、WebSocket や WebRTC のようなブラウザで使える API まで、幅広く扱っている。

www.oreilly.co.jp

読書メモは Gist に置いた。

『ハイパフォーマンスブラウザネットワーキング』のメモ。 · GitHub

本書は、2017年に voluntas さんから「お勧めの書籍」として頂いた。それをようやく読んだ。
ずっと頭の片隅にはあったのだが、最初に読もうとしたときに挫折してしまい、それから遠のいてしまっていた。
その後、ネットワークの入門書をいくつか読んでから本書を開いたところ、読めそうな気がしたのでそのまま読み進めた。

私の場合はネットワークの議論に対する「土地勘」のようなものがないから苦労したが、ある程度の知識がある人なら、それほど苦労せずに読めると思う。
淡々とプロトコルを紹介するのではなくパフォーマンスという文脈で解説を行っており、それによって本書が読みやすいものになっている。

私のような初心者にとって、ネットワークは興味を維持しにくい。
関心がないわけではないのだが、目に見える成果につなげにくいため、継続的な学習が難しい。日々の開発においても、一般的なウェブアプリを作り、機能要件しか気にしないのであれば、HTTP より下のレイヤーを意識することはあまりない。そうなるとネットワークを学ぶ必要性が生まれず、モチベーションを維持しにくい。
本書は、パフォーマンスという文脈でネットワーク技術を紹介することで、モチベーションを維持しやすい仕組みになっている。
本書のなかに

一度 TCP の輻輳制御を理解すると、キープアライブ、パイプライン化(pipelining)、多重化(multiplexing)などの最適化をすぐにでも始めたいと思うことでしょう。

という文章があるのだが、まさにそのような感じだ。本書で得られるネットワークの知識と日々の開発の間につながりがあることを感じられ、すぐにでも試したいと思わせてくれる。
パフォーマンスという分かりやすい目標や便益があることが、モチベーションになる。
例えば、TLS の説明で「TLS レコードプロトコル」というものが出てきたとき、あまり強い興味は持てなかった。だがこれがパフォーマンスに影響を与える可能性があると知り、俄然興味が出てきた。知っておくべきだと、自然に思うことが出来た。

だが本書はあくまでもネットワーク技術の解説書であり、パフォーマンス改善のための単なるハウツー本やテクニック集ではない。
そのため、まずネットワークの基本的な仕組みや考え方を説明した上で、それに基づいて、最適化の説明に入る。
「はじめに」に

「どのように」と「なぜ」が離れることはありません

とあるが、まさにそのような内容になっている。
冒頭に、「ウェブサイトのパフォーマンスにおいては帯域幅よりもレイテンシが重要だ」と書かれているが、本書を読み進めることで、なぜそうなのかが分かるようになっている。

そのような内容であるため、本書の内容は陳腐化しにくい。
邦訳である本書の出版は 2014 年であり、原文の公開はさらに古い。
しかし、基本的な仕組みや考え方は、陳腐化しない。
HTTP より上のレイヤーについては変化が激しいが、それでも、新しい技術は既存の技術の延長線上にあることが多い。また、既存の技術が抱える課題への対応として、新しい技術は出てくる。そのため、既存の技術について知っておくことで、新しい技術にもキャッチアップしやすくなる。

HTTP 1.x や 2.0 の話などは、『Real World HTTP』と重複している(ちなみにこの書籍も voluntas さんに頂いた)。
だがこの書籍を読んだときよりも、今回のほうが頭に入ってきた。
それは恐らく、自分自身が少しは非機能要件にも興味を持てるようになってきたからだと思う。以前は、機能要件を満たすことで頭がいっぱいで、他のことを気にする余裕がなかった。そういう人間にとってはライブラリの API を覚えていくことが何より重要であり、HTTP 通信のパイプライン化や多重化といった話題は、興味を持ちにくかった。自分には関係のない話のように思えてしまった。

本書や『基礎からわかるTCP/IP ネットワークコンピューティング入門 第3版』を読むことで、ネットワークの話題について多少は文脈を掴めるようになった、というのも大きいと思う。
自分なりの全体像が少しずつ出来てきて、何が分からないのかすら分からない、という状態からは脱しつつある気がする。

ルーティング機能を自作して学ぶ 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章 クライアントサイドルーティングの実装」