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

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

React (+ Redux) アプリの state 更新に関するパフォーマンス戦略

コンポーネントの再レンダリングを適切に抑制していくことが、React アプリのパフォーマンス改善の基本。

コンポーネントの再レンダリングが発生する要因はいくつかあるが、「親の再レンダリングによって発生する子の再レンダリング」については、以下の記事にまとめてある。

numb86-tech.hatenablog.com

この記事では「状態の更新」によって発生する再レンダリングについて見ていく。
また、この記事で「コンポーネント」という言葉を使う場合、特に断りがない限り関数コンポーネントを指す。

動作確認に使用したライブラリのバージョンは以下の通り。

  • react@16.13.1
  • redux@4.0.5
  • react-redux@7.2.0

基本的な考え方

再レンダリングが行われるということは、そのコンポーネントを再び実行するということ。そのため、再レンダリングが行われれば行われるほど、処理は重くなっていく。
コンポーネントのなかで明示的に記述している処理の他、差分検出処理など React が内部的に行っている処理も行われる。

そのため、不要な再レンダリングをスキップすることで、パフォーマンスの改善が見込める。

しかし、闇雲に再レンダリングの数を減らせばよい、という話でもない。
その再レンダリングが「不要」であるかどうかを判定するための処理にも、当然コストはかかる。
そのコストが再レンダリングによって発生するコストを上回っていた場合、パフォーマンスはむしろ悪化する。

この記事は「こうすれば確実にパフォーマンスが向上する」「このコードをコピペして流用すればよい」といった「チートシート」ではない。
どのようなときに再レンダリングが発生するのか、そしてどのようなときに発生しないのかを理解し、パフォーマンスを意識したコードを書けるようになることを目的としている。

useState と useReducer

「状態」を扱う組み込みのフックとしてuseStateuseReducerがある。
これらを使うことで「状態」を扱えるが、その「状態」を更新すると、再レンダリングが行われる。その「状態」を実際に表示に使っているかどうかは関係ない。

以下のコンポーネントではボタンを押下する度にstateが更新され、その度にログにReRenderと表示される。

import React, {useState, useEffect} from 'react';

let isReRender = false;

export const App = () => {
  const [state, setState] = useState(0); // このコンポーネントでは`state`は使用していない

  const onClick = () => {
    setState(s => s + 1);
  };

  useEffect(() => {
    if (isReRender) {
      console.log('ReRender');
    }
    if (!isReRender) {
      isReRender = true;
    }
  });

  return (
    <>
      <button type="button" onClick={onClick}>
        count up
      </button>
    </>
  );
};

バッチ処理

一回のイベントのなかでstateの更新を複数回行った場合、それらはまとめて「バッチ処理」される。
そのため、更新を何度行っても、再レンダリングは一度しか行われない。

先程のコードのonClickを以下のように書き換えると、一回のクリック毎にstate3増えるが、再レンダリングはクリック毎に一回しか行われない。
state1増やす度に再レンダリングするのではなく、3増やしてから一度だけ再レンダリングを行う。

  const onClick = () => {
    setState(s => s + 1);
    setState(s => s + 1);
    setState(s => s + 1);
  };

以下のように、異なる種類のstateを更新した場合も、再レンダリングは一回だけ行われる。

  const [stateA, setStateA] = useState(0);
  const [stateB, setStateB] = useState(0);

  const onClick = () => {
    setStateA(s => s + 1);
    setStateB(s => s + 1);
  };

useStateによる更新とuseReducerによる更新を行った場合も同様。全ての更新を行ったうえで一度だけ、再レンダリングされる。

  const onClick = () => {
    setState(s => s + 1);
    dispatch({type: 'INCREMENT'});
  };

非同期処理

stateを更新する同期処理と非同期処理を混在させた場合、まず同期処理を「バッチ処理」して再レンダリングしたあと、非同期処理によるstate更新の際にも改めて再レンダリングが行われる。

以下のケースだと、state2増やしてから再レンダリングを行い、その後非同期処理がstateをさらに1増やした状態で、もう一度再レンダリングを行う。

  const onClick = () => {
    Promise.resolve().then(() => {
      setState(s => s + 1);
    });
    setState(s => s + 1);
    setState(s => s + 1);
  };

非同期処理のなかでstateの更新を複数回行うと、その度に再レンダリングされる。
以下のケースだと、state3増やしてから再レンダリング、ではなく、state1増える毎に再レンダリングされる。つまり、再レンダリングは3回行われる。

  const onClick = () => {
    Promise.resolve().then(() => {
      setState(s => s + 1);
      setState(s => s + 1);
      setState(s => s + 1);
    });
  };

state が更新されないケース

stateが「更新」されなければ、再レンダリングは行われない。

以下のケースでは、stateの初期値は0であり、ボタンを押下するとstate1がセットされる。
そのため、最初にボタンを押下したときは再レンダリングが発生するが、それ以降は何度押下してもstate1のままなので、再レンダリングは発生しない。

  const [state, setState] = useState(0);

  const onClick = () => {
    setState(1);
  };

「更新」が行われたかどうかの判定には、SameValueアルゴリズムを使っている。===による比較とほぼ同じだが、若干の差異もある。
SameValueアルゴリズムについては、以下の記事に書いた。

numb86-tech.hatenablog.com

今回は再現できなかったが公式ドキュメントによると、更新が発生していなくても再レンダリングが行われる可能性もあるとのこと。
しかしその場合も、子コンポーネントの再レンダリングやuseEffectの実行は回避される。

まとめ

ここまでの内容をまとめる。サンプルコードではuseStateを使ってきたが、useReducerでも同じ挙動になる。

  • 状態が更新されたときにのみ、再レンダリングが発生する
  • 更新が発生したかどうかの判定にはSameValueアルゴリズムを使う
  • 同期処理のなかで複数の更新を行った場合は「バッチ処理」され、それが終わってから再レンダリングが行われる
  • 非同期処理のなかでの更新については「バッチ処理」されず、更新が行われる度に再レンダリングされる

React Redux

ある程度以上に規模が大きい React アプリでは、状態管理に Redux を使うことが多い。
そして React と Redux を結びつけるために使われるライブラリが、React Redux である。

「状態を扱う」のはuseStateuseReducerと同じだが、React Redux では基本的な仕組みや考え方が異なる。

状態の「取得」と「更新」が分離されている、というのが最大の違い。
useStateuseReducerとは異なり、状態はコンポーネントの外側にある。状態の管理は Redux の管轄であり、React は関知しない。
そのため、コンポーネントのクリックイベントなどをトリガーにして状態の更新を行っても、それだけではコンポーネント自体は影響を受けない。「状態の更新」は、コンポーネントの外側での出来事だからだ。
コンポーネントのなかで状態を使うためには、状態の更新とは別に、Redux から状態を取得するための手続きが必要になる。

状態の取得と更新を、分けて考える必要がある。
メソッドも、取得と更新、それぞれに専用のものが用意されている。useSelectoruseDispatchである。

まずは、状態の更新を行うuseDispatchについて見ていく。

以下では、まず Store を用意している。stateの構造は{a: number, b: number}
そしてそれをAppコンポーネントで利用できるようにしており、state.astate.bを個別に増やすためのボタンを用意した。

import React, {useEffect} from 'react';
import {createStore} from 'redux';
import {Provider, useDispatch} from 'react-redux';

const INCREMENT_A = 'INCREMENT_A';
const INCREMENT_B = 'INCREMENT_B';

const initialState = {
  a: 0,
  b: 0,
};

const reducer = (state = initialState, action) => {
  switch (action.type) {
    case INCREMENT_A:
      return {
        a: state.a + 1,
        b: state.b,
      };
    case INCREMENT_B:
      return {
        a: state.a,
        b: state.b + 1,
      };
    default:
      return state;
  }
};

const store = createStore(reducer);

export const Container = () => {
  return (
    <Provider store={store}>
      <App />
    </Provider>
  );
};

let isReRender = false;

const App = () => {
  const dispatch = useDispatch();

  const countUpA = () => {
    dispatch({type: INCREMENT_A});
  };

  const countUpB = () => {
    dispatch({type: INCREMENT_B});
  };

  useEffect(() => {
    if (isReRender) {
      console.log('ReRender');
    }
    if (!isReRender) {
      isReRender = true;
    }
  });

  return (
    <>
      <button type="button" onClick={countUpA}>
        count up a
      </button>{' '}
      <button type="button" onClick={countUpB}>
        count up b
      </button>
    </>
  );
};

どちらのボタンを押しても、再レンダリングは行われない。
もちろんstateは正しく更新されているが、それはAppコンポーネントの外側の出来事に過ぎない。だから、いくらstateが更新されようとも、再レンダリングは行われない。再レンダリングが必要ない、とも言える。

再レンダリングが行われるのは、stateが更新され、そしてそのstateをコンポーネントが取得しているとき。コンポーネントの内部にstateがあるのだから、stateが更新されたら当然、再レンダリングを行う必要がある。

stateの取得には、useSelectorを使う。

先程の例を以下のように書き換える。

@@ -1,6 +1,6 @@
 import React, {useEffect} from 'react';
 import {createStore} from 'redux';
-import {Provider, useDispatch} from 'react-redux';
+import {Provider, useSelector, useDispatch} from 'react-redux';

 const INCREMENT_A = 'INCREMENT_A';
 const INCREMENT_B = 'INCREMENT_B';
@@ -50,9 +50,11 @@
     dispatch({type: INCREMENT_B});
   };

+  const state = useSelector(s => s);
+
   useEffect(() => {
     if (isReRender) {
-      console.log('ReRender');
+      console.log('ReRender', state);
     }
     if (!isReRender) {

こうすると、ボタンを押下する度にstateが更新され、かつ、再レンダリングも行われているのが分かる。

バッチ処理と非同期処理

「バッチ処理」や非同期処理における挙動は、useStateuseReducerと同じ。

count up bothというボタンを用意し、そのクリックイベントのなかで複数回の状態の更新を行っているが、再レンダリングが行われるのは一度のみ。
state.astate.bをそれぞれ2ずつ増やしたうえで、再レンダリングが行われる。

@@ -50,6 +50,13 @@
     dispatch({type: INCREMENT_B});
   };

+  const countUpBoth = () => {
+    dispatch({type: INCREMENT_A});
+    dispatch({type: INCREMENT_A});
+    dispatch({type: INCREMENT_B});
+    dispatch({type: INCREMENT_B});
+  };
+
   const state = useSelector(s => s);

   useEffect(() => {
@@ -68,6 +75,9 @@
       </button>{' '}
       <button type="button" onClick={countUpB}>
         count up b
+      </button>{' '}
+      <button type="button" onClick={countUpBoth}>
+        count up both
       </button>
     </>
   );

そして以下のようにコードを書き換えると、ボタンを押下する度に再レンダリングが三回行われる。

  1. state.b2増やして再レンダリング
  2. state.a1増やして再レンダリング
  3. state.a1増やして再レンダリング
  const countUpBoth = () => {
    Promise.resolve().then(() => {
      dispatch({type: INCREMENT_A});
      dispatch({type: INCREMENT_A});
    });
    dispatch({type: INCREMENT_B});
    dispatch({type: INCREMENT_B});
  };

useState や useReducer と組み合わせる

useStateuseReducerによる更新とdispatchによる更新を、一度のイベントのなかで同期的に行った場合、再レンダリングは一度だけ行われる。

  const countUpBoth = () => {
    // 以下の全ての更新を終えた上で、再レンダリングが行われる
    dispatch({type: INCREMENT_A});
    dispatch({type: INCREMENT_B});
    setState(s => s + 1);
  };

useSelector の比較ロジック

useStateuseReducerと同じように、状態の「更新」が発生した場合にのみ、再レンダリングが発生する。

だがuseStateuseReducerとは異なる点も多いので、注意が必要。

まず、更新が発生したかどうかの判定には、SameValueではなく===を使う。

そして、更新が発生したかどうかチェックする対象は「セレクタの返り値」であり、それは必ずしもstateとは限らない。
ここでいうセレクタとは、useSelectorの第一引数として渡す関数のこと。
以下の場合はs => sがセレクタ。

const state = useSelector(s => s);

このケースだとセレクタがstateを返しているので、stateがチェックの対象になる。
だがこれを以下のように書き換えると、state.bが対象になる。

const b = useSelector(s => s.b);

この場合、state.bが更新されたときにのみ、再レンダリングが行われる。
そのため、count up aボタンを押下してstate.aをインクリメントしても、state.bに変化はないため、再レンダリングは行われない。

equalityFn

useSelectorは比較のためのアルゴリズムとして===を使うが、自分でカスタマイズして独自のアルゴリズムを使うこともできる。
比較を行う関数をuseSelectorの第二引数に渡すことで、それが可能になる。この関数はequalityFnと呼ばれる。
つまり、以下の形になる。

useSelector(selector, equalityFn);

equalityFnについては公式ドキュメントでもほとんど触れられておらず、「ドキュメントを書こう」という Issue も立っているのだが、今日現在ではまだドキュメントは作成されていない。

github.com

ここまで紹介してきた機能に比べるとequalityFnの挙動はかなり複雑なので、大まかな処理の流れについて順を追って説明する。

例として、以下のようなuseSelectorを用意した。state.bの初期値は0で、ボタンを押下する度にstate.b1ずつインクリメントされるとする。

const selector = s => s.b;
const equalityFn = (value, memoizedValue) => value === memoizedValue;
const b = useSelector(selector, equalityFn);

1. コンポーネントがマウントされ、useSelector が実行される

useSelectorが実行されるとまず、selectorが実行される。
state.bの初期値は0なので、selector0を返す。

2. equalityFn が実行される

selectorが実行されたあと、equalityFnが実行される。
equalityFnは、selectorの返り値が更新されたかどうかを判定するための関数。更新がない、つまり前回のレンダリング時と同じ値であると見做した場合はtruthyを、更新があった、つまり前回のレンダリング時とは異なる値であると見做した場合はfalsyを、返すことになっている。

equalityFnは、引数として 2 つの値を受け取る。
第一引数(今回の例ではvalueと命名した)には、selectorの返り値が入る。つまり今回のケースだと、0
第二引数(今回の例ではmemoizedValueと命名した)には、useSelectorの初回実行時のみ、selectorの返り値が入る。
つまり、useSelectorの初回実行時においては、valuememoizedValueも同じ値(今回のケースでは0)になる。

equalityFntruthyを返すかfalsyを返すかで、以降の処理が変わる。

truthyを返す場合。
前回のレンダリング時からs.bの値は更新されていないと判断され、再レンダリングは行われない。
そして、今回のmemoizedValueの値がそのまま、次回のequalityFn実行時のmemoizedValueに使われる。

falsyを返す場合。
前回のレンダリング時からs.bの値が更新されていると判断され、再レンダリングが行われる。
そして、次回のequalityFn実行時には、今回のvalueの値(つまりselectorの返り値)が、memoizedValueに使われる。

今回は例ではequalityFnの返り値をvalue === memoizedValueと定義しており、どちらも0なので、trueを返す。
そのため、再レンダリングは行われない。そして次にequalityFnが実行される際は、memoizedValueには0が入る。

再レンダリングが行われないため、ここで処理は終わる。

3. ボタンの押下をトリガーにして dispatch が実行され、state.b がインクリメントされる

このあと詳述するが、dispatchが行われると、useSelectorが実行される。

先程と同じように、まずselectorが実行される。state.bはインクリメントされて1になっているため、selectorの返り値も1になる。

次に、equalityFnが実行される。
valueには、selectorの返り値である1が入る。memoizedValueには、先程説明したように0が入る。
そうするとvalue === memoizedValuefalseになるため、equalityFnfalseを返す。

equalityFnfalsyを返したため、再レンダリングが発生する。そして次回のequalityFnの実行時には、今回のvalueの値である1が、memoizedValueに入る。

この処理はボタンが押下される度に繰り返し行われる。

state.b が偶数に更新された時にのみ再レンダリングを行うサンプル

先程の例ではstate.bが更新される度に再レンダリングが行われるため、デフォルトの挙動と変わらない。
理解を深めるため、state.bが偶数に更新された時にのみ再レンダリングが行われるようにしてみる。

Appコンポーネントを以下のように書き換える。

const App = () => {
  const dispatch = useDispatch();

  const countUpB = () => {
    dispatch({type: INCREMENT_B});
  };

  const selector = s => s.b;
  const equalityFn = (value, memoizedValue) => {
    if (value !== memoizedValue && value % 2 === 0) {
      return false;
    }
    return true;
  };
  const b = useSelector(selector, equalityFn);

  return (
    <>
      <div>{b}</div>
      <button type="button" onClick={countUpB}>
        count up b
      </button>
    </>
  );
};

これで、「state.bが偶数に更新された場合」にのみ再レンダリングが行われるようになる。

equalityFnはまず、第一引数であるvalueがメモ化された値と同じかどうかチェックする。
同じだった場合は再レンダリングが行われないため、変数bの値は更新されず、ボタンを押下する前の値がそのまま表示され続ける。
valueが奇数だった場合も同様に、再レンダリングは行われない。
valueが更新されており、かつ偶数だった場合にのみ、equalityFnfalseを返す。そうすると再レンダリングが行われるため、表示内容も更新される。

shallowEqual

React Redux はshallowEqualという関数を用意しており、それをequalityFnとして使うこともできる。

import {Provider, useSelector, useDispatch, shallowEqual} from 'react-redux';
// 中略
const state = useSelector(s => s, shallowEqual);

この関数は、渡されたオブジェクトの1階層目をチェックしていく。プリミティブな値を渡された場合は、その値そのものをチェックする。

例えばstateが以下の構造のとき、equalityFnを何も渡さなかった場合は、stateそのものを===でチェックする。そのため、参照が変わってしまえば、abもそれぞれ更新がなかったとしても、再レンダリングされてしまう。
だがshallowEqualを渡せば、abをそれぞれ===でチェックする。そのため、stateそのものの参照が変わったとしても、その中身の値が変わっていなければ、再レンダリングは行われない。

state = {a: number, b: number}

selector や equalityFn はいつ実行されるのか

dispatchによる状態の更新は「バッチ処理」されると書いたが、selectorequalityFnは、dispatchの度に都度実行される。
一回にまとめられるは再レンダリングであって、selectorequalityFnはそうではない。

以下のコードを使って、いつselectorequalityFnが実行されるのか検証してみる。

  const selector = s => console.log(s) || s;
  const equalityFn = (a, b) => console.log('equalityFn') || a === b;
  const state = useSelector(selector, equalityFn);

  useEffect(() => {
    if (isReRender) {
      console.log('ReRender', state);
    }
    if (!isReRender) {
      isReRender = true;
    }
  });

まずは、マウント時。つまり初回のレンダリング時。
以下のログが流れる。

{a: 0, b: 0}
{a: 0, b: 0}
equalityFn

つまり、selectorは 2 回、equalityFnは 1 回、実行されている。

次に、クリックイベントでdispatch({type: INCREMENT_A})を発生させ、state.aをインクリメントする。
すると、以下のログが流れる。

{a: 1, b: 0}
equalityFn
{a: 1, b: 0}
ReRender {a: 1, b: 0}

selector-> equalityFn -> selector -> 再レンダリングという順番で実行されていることになる。

今度は、一度リロードしてstateを初期化したうえで、以下の処理を実行する。

dispatch({type: INCREMENT_A});
dispatch({type: INCREMENT_A});
dispatch({type: INCREMENT_B});
dispatch({type: INCREMENT_B});

その際のログは以下の通り。

{a: 1, b: 0}
equalityFn
{a: 2, b: 0}
equalityFn
{a: 2, b: 1}
equalityFn
{a: 2, b: 2}
equalityFn
{a: 2, b: 2}
ReRender {a: 2, b: 2}

最後に、equalityFnを以下のように書き換えてみる。

const equalityFn = (a, b) => console.log('equalityFn') || true;

equalityFnが常にtrueを返すため、再レンダリングは行われない。
この状態でまた、INCREMENT_AINCREMENT_Bを 2 回ずつdispatchしてみる。
そうすると、ログの内容が変化する。

{a: 1, b: 0}
equalityFn
{a: 2, b: 0}
equalityFn
{a: 2, b: 1}
equalityFn
{a: 2, b: 2}
equalityFn

ここから、初回レンダリング時は別として、それ以降にdispatchが行われた際は以下のように処理されることが分かる。

  • dispatchが行われる毎に、selectorequalityFnが実行される
  • そして「再レンダリングする必要あり」と判断された場合は、最後にもう一度selectorを実行した上で、再レンダリングが行われる

気を付けなければならないのは、一連のequalityFnの実行のなかで、どれかひとつでもfalsyを返せば再レンダリングが実行されるということ。
そして、再レンダリングされる際には改めてselectorが実行されるため、最新の値がコンポーネントに反映されるということ。

これらを理解していないと、意図せぬ挙動によってバグを作り出してしまう恐れがある。

以下は、先程作った「state.bが偶数に更新されたときにのみ再レンダリングされるコンポーネント」である。
ボタンを押すとdispatchが 3 回行われることだけが、先程とは異なる。
初期状態では0が画面に表示されているが、ボタンを押下すると何が表示されるだろうか。

const App = () => {
  const dispatch = useDispatch();

  // dispatch を 3 回行っている
  const countUpB = () => {
    dispatch({type: INCREMENT_B});
    dispatch({type: INCREMENT_B});
    dispatch({type: INCREMENT_B});
  };

  const selector = s => s.b;
  const equalityFn = (value, memoizedValue) => {
    if (value !== memoizedValue && value % 2 === 0) {
      return false;
    }
    return true;
  };
  const b = useSelector(selector, equalityFn);

  return (
    <>
      <div>{b}</div>
      <button type="button" onClick={countUpB}>
        count up b
      </button>
    </>
  );
};

正解は、2ではなく3である。
3は偶数ではないので再レンダリングが行われないように思えるが、そうはならない。

既に説明したように、equalityFndispatchが実行される度に実行されていく。
そして、そのなかでひとつでもfalsyを返せば、再レンダリングが行われる。
このケースだと、3 回実行されるequalityFnのうち、2 回目がfalseを返すので、再レンダリングが行われる。

再レンダリングが行われる場合、状態の更新が全て終わったあとに改めてselectorが実行されるため、state.bの最新の値を返す。
dispatch({type: INCREMENT_B})が 3 回行われているため、state.bの最新の値は3
そのため、変数b3が渡された状態で、再レンダリングされるのである。

複数の useSelector を使うケース

ここまでの例では、ひとつのコンポーネントのなかでuseSelectorをひとつだけ使っていた。
最後に、複数のuseSelectorを使うケースについて見ていく。

まず、useSelectorを複数使うことによって再レンダリングが増えてしまう、ということはない。
useSelectorをいくつ使っていても、再レンダリングは全く行われないか、一度だけ行われるか、どちらかである。

そして、各useSelectorequalityFnがひとつでもfalsyを返せば、全てのselectorを再実行したうえで、再レンダリングが行われる。

以下のコンポーネントは、equalityFnが常にtrueを返すため、何度ボタンを押下しても再レンダリングされず、表示はA is 0.のまま変化しない。

const App = () => {
  const dispatch = useDispatch();

  const countUpBoth = () => {
    dispatch({type: INCREMENT_A});
    dispatch({type: INCREMENT_B});
  };

  const a = useSelector(s => s.a, () => true);

  return (
    <>
      <div>A is {a}.</div>
      <button type="button" onClick={countUpBoth}>
        count up both
      </button>
    </>
  );
};

このコンポーネントに、以下の変更を加える。

   };

   const a = useSelector(s => s.a, () => true);
+  const b = useSelector(s => s.b, () => false);

   return (
     <>
-      <div>A is {a}.</div>
+      <div>
+        A is {a}. B is {b}.
+      </div>
       <button type="button" onClick={countUpBoth}>
         count up both
       </button>

そうすると、ボタンを押下する度にaの値もbの値も更新されるようになる。
これは、新しく追加したuseSelectorequalityFnが常にfalseを返すためである。そのため、必ず再レンダリングが行われ、それに先立って 2 つのuseSelectorの両方のselectorが実行される。

そのため、最初のuseSelectorequalityFnは常にtrueを返すにも関わらず、常にstate.aの最新の値が画面に反映されてしまうのである。

SPA を GitHub Actions でビルドして GitHub Pages にデプロイする

デプロイの自動化と History API のフォールバック設定を行うことで、GitHub Pages で SPA を公開できるようにする。

具体的なゴールは以下の通り。

  • masterブランチにプッシュすると、自動的にビルドが行われる
  • ビルドした内容が GitHub Pages として公開される
  • History API のフォールバックが設定されており、SPA として問題なく機能する

以下がサンプルコード。

github.com

上記のリポジトリによって公開されたページ。内容はない。
https://numb86.github.io/spa-sample/

なお、この記事では SPA の作り方そのものは扱わない。

ビルド用のコマンドを用意し、ビルドの出力先を決める

サンプルコードでは、$ yarn buildでビルドし、その結果をpublic/に出力するようにしている。
public/の内容を GitHub Pages として公開することになるので、公開に必要なものは全てこのディレクトリに含まれるようにしておく。

GitHub Actions の設定を行う

デプロイを行うための GitHub Actions の設定を行う。

ここがこの記事の主題ではあるのだが、まさにそのための Action を公開している方がいたので、そのままそれを使うことにした。

github.com

README に記載されているサンプルも、ほぼそのまま流用した。

これを元にしたコードを.github/workflows/gh-pages.ymlとして保存した。
これで、GitHub Actions が有効になる。

以下のように記載することで、masterブランチにプッシュされたときにのみ、実行されるようになる。

on:
  push:
    branches:
      - master

最後の行のpublish_dir: ./publicで、public/の中身がgh-pagesブランチに展開されるように設定している。
その前に$ yarn buildでビルドすることを忘れないようにする。

${{ secrets.GITHUB_TOKEN }}と記載してトークンを使用しているが、このトークンは自動的に生成されるので、改めて何かする必要はない。

help.github.com

これもREADME に書かれてあるが、初回デプロイ時のみ、リポジトリの設定ページで GitHub Pages の設定し、改めてもう一度デプロイする必要がある。

History API のフォールバックを設定する

GitHub Pages に限らず、SPA を公開する場合は History API のフォールバックを設定する必要がある。

フォールバックを設定しない場合、https://numb86.github.io/spa-sample/aboutに直接アクセスしたり、このページでリロードしたりすると、404 ページが表示されてしまう。
今回のサンプルページの場合、まずindex.htmlが表示され、そこに記載されている JavaScript が読み込まれることで、SPA が展開される。
そのため、まず最初にindex.htmlを表示する必要がある。だがhttps://numb86.github.io/spa-sample/aboutにアクセスするとabout.htmlを表示しようとするため、ファイルが見つからず 404 エラーになってしまう。

通常はサーバ側で対応するが GitHub Pages ではそういったことはできないため、JavaScript で対応する。

まず、オリジナルの 404 ページを用意する。デフォルトだと GitHub が用意した 404 ページが表示されるが、404.htmlという名前のファイルを用意することで、404 エラーのときにそのファイルが表示されるようになる。
今回はsrc/404.htmlを用意し、それをGitHub Actions のなかでpublic/にコピーすることにした

そしてその 404 ページにスクリプトを書き込み、index.htmlにリダイレクトさせる。
その際に URL にクエリパラメータをつけることで、どこからリダイレクトされてきたのかが分かるようにする。
最後にindex.htmlにもスクリプトを書き、リダイレクト元に応じてページの内容を書き換える。

https://numb86.github.io/spa-sample/aboutの場合、以下のような流れになる。

  1. https://numb86.github.io/spa-sample/about.htmlが存在しないため、https://numb86.github.io/spa-sample/404.htmlが表示される
  2. 404.htmlのスクリプトによって、https://numb86.github.io/spa-sample/?originalPath=/aboutにリダイレクトされる
  3. https://numb86.github.io/spa-sample/index.htmlが返され、そこに書かれているスクリプトによって URL がhttps://numb86.github.io/spa-sample/aboutに書き換えられ、React Router によって処理されてThis is about page.と表示される