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

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

React の Concurrent Mode を使ってみる(2020年12月版)

React で開発が進められている Concurrent Mode。
まだリリース前の開発中の機能だが、「実験的機能」として提供されており、Experimentalビルドをインストールすることで利用できる。
Experimentalはリリース間の安定性を何も保証しておらず、破壊的変更が行われる可能性がある。Concurrent Mode の動作も、大きく変わる可能性がある。
記事のタイトルに「2020年12月版」と入れたのは、そのため。

公式ドキュメントでは「並列モード」と翻訳されているが、まさに、並列的にレンダリングを行えるようになる。
ネットワークからデータを取得して要素をレンダリングする際に、ユーザーに見えないところで新しいレンダリングの準備をしつつ、データが取得できるまでは古いレンダリングを表示しておく、といったことが可能になる。
この記事では、どういった仕組みでそのようなことが可能になっているのか、ひとつずつ見ていく。

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

  • react@0.0.0-experimental-4ead6b530
  • react-dom@0.0.0-experimental-4ead6b530
  • typescript@4.1.3

Concurrent Mode を有効にする

createRootを使って React 要素をマウントすると、その要素全体で Concurrent Mode が有効になる。
従来はReactDOM.render(element, container)だったものが、ReactDOM.createRoot(container).render(element)になる。
以下のコードでは、<App />全体で Concurrent Mode が有効になる。今回は TypeScript を使うため、トリプルスラッシュディレクティブで型定義も読み込んでいる。

/// <reference types="react-dom/experimental" />

import {unstable_createRoot} from 'react-dom';

import {App} from './components/App';

unstable_createRoot(document.querySelector<HTMLDivElement>('#app')!).render(
  <App />
);

createRootunstable_という接頭語が付いているのは、この API がまだ実験的なものであり安定版ではないことを意味している。
バージョニングポリシー – React

Suspense はスローされた Promise をキャッチする

Concurrent Mode においては、Suspenseコンポーネントが重要な役割を果たす。

Suspenseコンポーネント自体は以前から存在したが、コンポーネントの Dynamic Import を行うために使われていた。

numb86-tech.hatenablog.com

Concurrent Mode では、コンポーネント以外のリソース(例えば、ネットワークから取得するデータ)の取得を待機することができるようになった。

それを可能にしているのが、「スローされたPromiseをキャッチする」というSuspenseの機能である。

React ツリーのなかでPromiseがスローされると、ツリーを上に辿っていき、一番最初に到達したSuspensefallbackが表示される。
もし最後までSuspenseが見つからなかった場合、ツリー全体がアンマウントされる。

例えば以下のコードでは、ThrowPromiseコンポーネントがPromiseをスローしている。
そこからツリーを上に辿っていくと<Suspense fallback={<div>Fallback</div>}>が見つかるため、そのfallbackに設定されている<div>Fallback</div>が表示される。

/// <reference types="react/experimental" />

import {Suspense} from 'react';

function ThrowPromise() {
  throw Promise.resolve(1);
  return <div>foo</div>;
}

export function App() {
  return (
    <Suspense fallback={<div>Fallback</div>}>
      <ThrowPromise />
    </Suspense>
  );
}

「一番最初に到達したSuspensefallbackが表示される」ため、以下のコードでは2が表示される。

export function App() {
  return (
    <Suspense fallback={<div>1</div>}>
      <Suspense fallback={<div>2</div>}>
        <ThrowPromise />
      </Suspense>
    </Suspense>
  );
}

スローされたものをキャッチしてフォールバックを表示する、という点で Error Boundary に近い機能だと言える。

numb86-tech.hatenablog.com

だが Error Boundary と違い、SuspensePromise以外のものがスローされた場合はキャッチしない。
そしてもうひとつ大きな違いが、Promiseの状態が変化すると、Suspenseでラップされた要素のレンダリングを再び試みる、という点である。

以下のコードを実行すると、1秒間Fallbackを表示したあと、fooが表示される。

/// <reference types="react/experimental" />

import {Suspense} from 'react';

let flag = false;

function ThrowPromise() {
  if (!flag) {
    throw new Promise((resolve) => {
      setTimeout(() => {
        flag = true;
        resolve(null);
      }, 1000);
    });
  }
  return <div>foo</div>;
}

export function App() {
  return (
    <Suspense fallback={<div>Fallback</div>}>
      <ThrowPromise />
    </Suspense>
  );
}

ThrowPromiseをレンダリングしようとすると、flagfalseのため、Promiseがスローされる。そしてそれをキャッチしたSuspenseがフォールバックを表示する。
ここまでは、先程の例と同じ。
異なるのは、1秒後にPromiseの状態が変化すること。
そしてPromiseの状態が変化したことで、改めてThrowPromiseをレンダリングしようとする。その際にはflagの値がtrueになっているためPromiseはスローされず、div要素が返される。

レンダリングのトリガーになるのはPromiseの状態変化であり、fulfilledになるかrejectedになるかは、関係ない。
そのため、ThrowPromiseを以下のように書き換えても、同じように動作する。

function ThrowPromise() {
  if (!flag) {
    throw new Promise((_, reject) => {
      setTimeout(() => {
        flag = true;
        reject(new Error());
      }, 1000);
    });
  }
  return <div>foo</div>;
}

Suspense とデータ取得を組み合わせる

ここまで説明したSuspenseの仕組みを使って、ネットワークからのデータ取得を実装してみる。

まず、API サーバを用意する。

const http = require('http');

function resJson(res, data, ms) {
  setTimeout(() => {
    res.writeHead(200, {
      'Content-Type': 'application/json',
      'Access-Control-Allow-Origin': '*',
    });
    res.write(JSON.stringify(data));
    res.end();
  }, ms);
}

http
  .createServer((req, res) => {
    switch (true) {
      case /^\/1$/.test(req.url):
        resJson(res, {id: 1, name: 'Alice'}, 1000);
        break;
      case /^\/2$/.test(req.url):
        resJson(res, {id: 2, name: 'Bob'}, 1000);
        break;
      case /^\/3$/.test(req.url):
        resJson(res, {id: 3, name: 'Carol'}, 1000);
        break;
      case /^\/4$/.test(req.url):
        resJson(res, {id: 4, name: 'Dave'}, 1000);
        break;
      default:
        res.writeHead(404);
        res.end();
    }
  })
  .listen(3000);

挙動が分かりやすくなるように、1秒経過してからレスポンスを返すようにしてある。

そして、この API サーバを叩いてデータを取得する関数が、以下のfetchUser

type Profile = {
  id: number;
  name: string;
};

export function fetchUser(id: number) {
  let status = 'pending';
  let result: Profile;
  let error: Error;

  const suspender = fetch(`http://localhost:3000/${id}`)
    .then((r) => {
      r.json().then((res) => {
        status = 'success';
        result = res;
      });
    })
    .catch((e) => {
      status = 'error';
      error = e;
    });
  return {
    read() {
      if (status === 'pending') {
        throw suspender;
      } else if (status === 'error') {
        throw error;
      }
      return result;
    },
  };
}

重要な点は、fetchUserの返り値はPromiseではないということ。{read() {...}}というオブジェクトを返す。
そしてreadメソッドがどのように動くのかは、呼び出したタイミングによって異なる。
fetchが返したPromiseの状態が変化する前に呼び出すとstatuspendingなので、suspenderPromise)をスローする。
Promiseが解決されたあとに呼び出すとstatussuccessになっているので、result、つまり API からの返り値を返す。

fetchUserを利用する側のコードは以下の通り。
1秒間Loading...を表示したあと、1: Aliceを表示する。

/// <reference types="react/experimental" />

import {Suspense, useState} from 'react';

import {fetchUser} from '../api';

function Profile({resource}: {resource: ReturnType<typeof fetchUser>}) {
  const profile = resource.read();
  return (
    <div>
      {profile.id}: {profile.name}
    </div>
  );
}

const initialResource = fetchUser(1);

export function App() {
  const [resource] = useState(initialResource);
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Profile resource={resource} />
    </Suspense>
  );
}

以下のような処理の流れになる。

  1. fetchUser(1)を実行し、データの取得を開始する
  2. fetchUser(1)の返り値をresourceにセットし、Profileコンポーネントに渡す
  3. Profileコンポーネントはresource.read()を実行してデータを取得しようとするが、まだデータ取得中なのでPromiseがスローされる
  4. スローされたPromiseSuspenseがキャッチして、フォールバックを表示する
  5. 1秒後、スローされたPromiseの状態がfulfilledに変化し、再度Profileコンポーネントをレンダリングしようとする
  6. Profileコンポーネントがresource.read()を実行すると、先程とは違って API からの返り値を取得でき、profile変数に代入される
  7. profile.idprofile.nameを使ってレンダリングが行われ、Promiseもスローされていないので、フォールバックではなくProfileが表示される

データ取得中からデータ取得済みへと状態が変わり、それに伴って表示内容も変わっている。
だがstateへのセットは一度しか行われていない。従来は状態の変化に合わせて開発者がstateを更新し、状態に応じた表示の出し分けも開発者が書く必要があった。if (!profile) return <div>Loading ...</div>のように。
それが不要になったのは、状態の遷移に応じた処理を React が行うようになったため。データ読み込み中はfallbackを表示し、データ読み込みが完了したらfallbackの表示を止めてデータに基づいたレンダリングを行ってくれる。

コンポーネント側はシンプルに書けるようになった反面、データ取得側はSuspenseに対応した処理が必要になる。
「データを取得できるまではPromiseをスローし、取得後はデータを返す」という仕組みが重要なので、素朴にPromiseを返すだけでは、Suspenseと連携できない。
公式ドキュメントによると、Facebook では Relay を使ってSuspenseと連携させているとのこと。

並列的なレンダリングによるユーザ体験の向上

useTransitionは、 Concurrent Mode で追加された Hooks のひとつ。
これを使うことで、より柔軟に UI を設計できるようになる。

題材として、先程のコードを拡張し、ボタンを押下する度に次の ID のユーザが表示されるようにする。

まずは、useTransitionを使わずに書く。

/// <reference types="react/experimental" />

import {Suspense, useState} from 'react';

import {fetchUser} from '../api';

function Profile({
  nextId,
  resource,
  handleClick,
}: {
  nextId: number;
  resource: ReturnType<typeof fetchUser>;
  handleClick: () => void;
}) {
  const profile = resource.read();
  const onClick = handleClick;
  return (
    <>
      <button type="button" onClick={onClick}>
        Next
      </button>{' '}
      <i>Next ID: {nextId}</i>
      <div>
        {profile.id}: {profile.name}
      </div>
    </>
  );
}

const INITIAL_ID = 1;

const initialResource = fetchUser(INITIAL_ID);

export function App() {
  const [resource, setResource] = useState(initialResource);
  const [nextId, setNextId] = useState(INITIAL_ID + 1);

  const handleClick = () => {
    setNextId((id) => (id === 4 ? 1 : id + 1));
    setResource(fetchUser(nextId));
  };

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Profile nextId={nextId} resource={resource} handleClick={handleClick} />
    </Suspense>
  );
}

基本的な仕組みは先程と変わらない。
ボタンを押下するとstateが更新されるため、再レンダリングが発生する。だがresource.read()Promiseがスローされるため、フォールバックが表示される。Promiseの状態が変わったら、つまり API からレスポンスが返ってきたら、改めてレンダリングを試みる。そうすると今度はresource.read()で API からのレスポンスを取得できるので、無事にレンダリングされる。

f:id:numb_86:20201216194759g:plain

次に、useTransitionを使った形に書き換える。
useTransitionの返り値の最初の要素である、startTransitionを使う。このstartTransitionには関数を渡すのだが、そのなかでresourceの更新を行うようにする。

/// <reference types="react/experimental" />

import {Suspense, useState, unstable_useTransition} from 'react';

import {fetchUser} from '../api';

function Profile({
  nextId,
  isPending,
  resource,
  handleClick,
}: {
  nextId: number;
  isPending: boolean;
  resource: ReturnType<typeof fetchUser>;
  handleClick: () => void;
}) {
  const profile = resource.read();
  const onClick = handleClick;
  return (
    <>
      <button type="button" onClick={onClick} disabled={isPending}>
        {isPending ? 'Loading...' : 'Next'}
      </button>{' '}
      <i>Next ID: {nextId}</i>
      <div>
        {profile.id}: {profile.name}
      </div>
    </>
  );
}

const INITIAL_ID = 1;

const initialResource = fetchUser(INITIAL_ID);

export function App() {
  const [resource, setResource] = useState(initialResource);
  const [nextId, setNextId] = useState(INITIAL_ID + 1);
  const [startTransition, isPending] = unstable_useTransition();

  const handleClick = () => {
    setNextId((id) => (id === 4 ? 1 : id + 1));

    // resource の更新を startTransition でラップした
    startTransition(() => {
      setResource(fetchUser(nextId));
    });
  };

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Profile
        nextId={nextId}
        isPending={isPending}
        resource={resource}
        handleClick={handleClick}
      />
    </Suspense>
  );
}

そうすると、先程とは挙動が変わる。

f:id:numb_86:20201216194902g:plain

具体的には、ボタンを押下してもフォールバックが表示されなくなった。

なぜこのような結果になるのか、ひとつずつ見ていく。

useTransition の仕組み

原則として、startTransitionを実行するとレンダリングが2回発生する(例外もあるので後述する)。startTransitionのなかで状態を更新しているかどうかは、無関係。

そのため以下のコードの場合、ボタンを押す度にダイアログが2回表示される。

function Child({count, handleClick}: {count: number; handleClick: () => void}) {
  useEffect(() => {
    alert(count);
  });

  return (
    <button type="button" onClick={handleClick}>
      child
    </button>
  );
}

export function App() {
  const [startTransition] = unstable_useTransition();

  const handleClick = () => {
    startTransition(() => {});
  };

  return <Child count={0} handleClick={handleClick} />;
}

f:id:numb_86:20201216195034g:plain

次にこのコードに状態管理を組み合わせる。

function Child({
  foo,
  bar,
  handleClick,
}: {
  foo: number;
  bar: number;
  handleClick: () => void;
}) {
  useEffect(() => {
    alert(`foo: ${foo}, bar: ${bar}`);
  });

  return (
    <button type="button" onClick={handleClick}>
      child
    </button>
  );
}

export function App() {
  const [foo, setFoo] = useState(0);
  const [bar, setBar] = useState(0);
  const [startTransition] = unstable_useTransition();

  const handleClick = () => {
    setFoo((s) => s + 1);
    startTransition(() => {
      setBar((s) => s + 1);
    });
  };

  return <Child foo={foo} bar={bar} handleClick={handleClick} />;
}

イベントハンドラのなかでfoobarをインクリメントするのだが、fooの更新はstartTransitionでラップせず、barの更新はラップした。
この状態でボタンを押下すると、どうなるか。

f:id:numb_86:20201216195102g:plain

最初のダイアログではfooのインクリメントのみが反映されており、2回目のダイアログでようやく、barのインクリメントが反映されている。
つまり、startTransitionを実行すると、ラップされていない状態更新が反映されたレンダリングを行い、その後、ラップされた状態更新も反映されたレンダリングを行う。

さらにここに、useTransitionの返り値の 2 番目の要素であるisPendingも組み合わせてみる。

function Child({
  foo,
  bar,
  isPending,
  handleClick,
}: {
  foo: number;
  bar: number;
  isPending: boolean;
  handleClick: () => void;
}) {
  useEffect(() => {
    alert(`foo: ${foo}, bar: ${bar}, isPending: ${isPending}`);
  });

  return (
    <button type="button" onClick={handleClick}>
      child
    </button>
  );
}

export function App() {
  const [foo, setFoo] = useState(0);
  const [bar, setBar] = useState(0);
  const [startTransition, isPending] = unstable_useTransition();

  const handleClick = () => {
    setFoo((s) => s + 1);
    startTransition(() => {
      setBar((s) => s + 1);
    });
  };

  return (
    <Child
      foo={foo}
      bar={bar}
      isPending={isPending}
      handleClick={handleClick}
    />
  );
}

f:id:numb_86:20201216195143g:plain

最初のダイアログではisPendingtrue2回目のダイアログではfalseになっている。

ここまでの内容をまとめると、次のようになる。

  • startTransitionを実行するとレンダリングが発生する
    • その際、startTransitionでラップされている状態更新以外の更新が、反映される
    • isPendingtrueとして、レンダリングされる
  • 上記のレンダリングが終わると、再びレンダリングが行われる
    • 今度は、startTransitionでラップされている状態更新も反映される
    • isPendingfalseとして、レンダリングされる

そして最後に、useTransitionの最大の特徴について説明する。
それは、2回目のレンダリング時にPromiseがスローされると、Suspenseがそれをキャッチするのではなく、1回目のレンダリングが表示され続け、Promiseの状態が変化した時点で改めてレンダリングを試みる、というものである。

以下のコードでそれを確認できる。

function countUp(arg: number) {
  let status = 'pending';

  const suspender = new Promise((resolve) => {
    setTimeout(() => {
      resolve(null);
    }, 3000);
  }).then(() => {
    status = 'success';
  });
  return {
    get() {
      if (status === 'pending') {
        throw suspender;
      }
      return arg + 1;
    },
  };
}

function Child({
  foo,
  bar,
  isPending,
  handleClick,
}: {
  foo: number;
  bar: {get(): number};
  isPending: boolean;
  handleClick: () => void;
}) {
  const barValue = bar.get();

  useEffect(() => {
    alert(`foo: ${foo}, bar: ${barValue}, isPending: ${isPending}`);
  });

  return (
    <button type="button" onClick={handleClick}>
      child
    </button>
  );
}

const initialBar = countUp(-1);

export function App() {
  const [foo, setFoo] = useState(0);
  const [bar, setBar] = useState(initialBar);
  const [startTransition, isPending] = unstable_useTransition();

  const handleClick = () => {
    setFoo((s) => s + 1);
    startTransition(() => {
      setBar(countUp(foo));
    });
  };

  return (
    <Suspense fallback={<div>Fallback</div>}>
      <Child
        foo={foo}
        bar={bar}
        isPending={isPending}
        handleClick={handleClick}
      />
    </Suspense>
  );
}

f:id:numb_86:20201216195302g:plain

ボタンを押下するとまず、fooが更新された状態でレンダリングされる。これは先程までと同じ。
次にbarが更新された状態でレンダリングを試みるのだが、そうすると、const barValue = bar.get();の部分でPromiseがスローされる。
すると、Suspenseがキャッチしてフォールバックを表示する、のではなく、1回目のレンダリングが表示され続ける。つまり、fooのみが更新された状態のレンダリングが、そのまま表示され続ける。
そしてPromiseの状態が変化したとき(この例だと3秒後)に、barが更新された状態でのレンダリングを改めて試みる。今度はbar.get()Promiseをスローしないので、問題なくレンダリングされる。

注意しなければならないのは、2回目のレンダリングではなく1回目のレンダリングでPromiseがスローされた場合、また異なった挙動になるということである。

1回目のレンダリングでPromiseがスローされると、それはSuspenseにキャッチされ、フォールバックが表示される。
そしてPromiseの状態が変化した際に改めてレンダリングが行われるのだが、その際にはstartTransitionでラップされた状態更新も反映した形で、レンダリングされる。isPendingfalseになる。

handleClickを以下のように書き換えることで、確認できる。

  const handleClick = () => {
    setBar(countUp(foo));
    startTransition(() => {
      setFoo((s) => s + 1);
    });
  };

f:id:numb_86:20201216195347g:plain

ここまで説明してきた内容を踏まえて、改めてデータ取得の例を見てみる。

ボタンを押下すると、以下のコードが実行される。

  const handleClick = () => {
    setNextId((id) => (id === 4 ? 1 : id + 1));
    startTransition(() => {
      setResource(fetchUser(nextId));
    });
  };

setNextIdstartTransitionでラップされていない。そのため、Next IDの変更はすぐに画面に反映される。
その後、setResourceによる更新を反映させてレンダリングを行おうとするが、ProfileコンポーネントがPromiseをスローする。そのため、先程のレンダリング結果(Next IDが更新された画面)をそのまま表示し続ける。そしてPromiseの状態が変化すると改めてレンダリングが行われ、新しいユーザの情報が画面に表示される。
また、Promiseの状態変化を待っている間はisPendingtrueであるため、その間だけボタンの文字列はLoading....になる。

f:id:numb_86:20201216194902g:plain

useTransitionによって実現されたこの挙動は、「レンダリングが並列的に行われている」と捉えることができる。
resourceが更新されたバージョンのProfileを準備しつつ、nextIdは更新されたがresourceが更新されていないバージョンのProfileを表示している。2 つのProfileが存在している。
この仕組みを上手く使うことで、不完全な状態の画面が表示されてしまうのを回避したり、逆に少しでも速くユーザの操作に対するフィードバックを返したり、といったことが可能になる。

useDeferredValue を使ったレスポンシブ性の向上

useTransitionの他にuseDeferredValueという Hooks が追加されており、こちらを使うことでも、状態の更新を遅延させることができる。
優先度が低い上に処理に時間がかかる更新を後回しにして、優先度が高い更新をできるだけ早く行って表示に反映させる。そうすることで、アプリのレスポンス性を高めることが企図されている。

stateuseDeferredValueに渡すと、deferredValueを得られる。
そしてuseDeferredValueが存在する状態でstateを更新すると、まず、stateだけを更新した状態でレンダリングを行う。その後、deferredValueの値を更新後のstateと同じにした上で、またレンダリングを行う。

例を示す。

/// <reference types="react/experimental" />

import {Fragment, useState, unstable_useDeferredValue} from 'react';

export function App() {
  const [state, setState] = useState(1);
  const deferredValue = unstable_useDeferredValue(state);

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

  useEffect(() => {
    alert(`state: ${state}, deferredValue: ${deferredValue}`);
  });

  return (
    <>
      <button type="button" onClick={countUp}>
        count up
      </button>
      <div>state: {state}</div>
      <div>deferredValue: {deferredValue}</div>
    </>
  );
}

f:id:numb_86:20201216195604g:plain

まずstate:2, deferredValue:1でレンダリングを行い、その直後にstate:2, deferredValue:2でレンダリングを行っている。

上記の例ではuseEffectでダイアログを出していたので、レンダリングが2回行われたことを確認できた。
だがuseEffectを削除してしまうと、statedeferredValueがほぼ同時に更新されるため、違いを知覚できない。

f:id:numb_86:20201216195639g:plain

レンダリングのための処理が重く、stateが頻繁に更新されると画面の更新が遅れてしまうような状況で、useDeferredValueは力を発揮する。
そのような状況では、処理が落ち着くまでuseDeferredValueの更新が遅延される。そのため、表示の更新が遅れても問題ないコンポーネントにはdeferredValueを渡してレンダリングを抑制し、優先的に表示を更新したいコンポーネントにのみstateを渡すことで、更新を遅れを減少させることができる。

優先的に表示すべき情報の典型例として、ユーザの操作に対するフィードバックがある。
例えば、テキストボックスに文字列を入力したら、その内容が即座に表示されることが求められる。

以下のテキストボックスは、文字列を入力しても反映までに時間が掛かってしまう。

/// <reference types="react/experimental" />

import {memo, useState} from 'react';

const ExpensiveComponent = memo(({text}: {text: string}) => {
  // わざと処理に時間がかかるようにしている
  const startTime = performance.now();
  while (performance.now() - startTime < 120);

  if (text === '') {
    return (
      <div>
        <i>Please input.</i>
      </div>
    );
  }

  return (
    <div>
      Text is <b>{text}</b>.
    </div>
  );
});

export function App() {
  const [text, setText] = useState('foo');

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.currentTarget.value);
  };

  return (
    <>
      <input value={text} onChange={handleChange} />
      <ExpensiveComponent text={text} />
    </>
  );
}

f:id:numb_86:20201216195802g:plain

ExpensiveComponentの処理に時間がかかってしまっているのが原因。
ExpensiveComponentの処理が終わる前に次々とtextの更新が発生するため、表示の更新が追いつかない。ユーザによる入力が一段落して、ようやく表示が更新される。

このように、テキストボックスへの反映が遅れてしまうと、目に見えて操作性が悪くなる。
このとき、ExpensiveComponentの更新だけを遅延させることが許容されるなら、useDeferredValueを使うことで操作性を改善できる。
具体的には、以下のようにtextではなくdeferredTextExpensiveComponentに渡すようにすればよい。

import {memo, useState, unstable_useDeferredValue} from 'react';

// 中略

export function App() {
  const [text, setText] = useState('foo');
  const deferredText = unstable_useDeferredValue(text);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.currentTarget.value);
  };

  return (
    <>
      <input value={text} onChange={handleChange} />
      <ExpensiveComponent text={deferredText} />
    </>
  );
}

そうすると、ExpensiveComponentの更新にある程度の遅れが発生する代わりに、テキストボックスの更新は迅速に行われるようになる。

f:id:numb_86:20201216195835g:plain

先程と同じようにtextは次々と更新されていくが、deferredTextは更新されず、textだけが更新された状態でレンダリングが行われていく。
そのため、ユーザのタイピングに合わせてinput.valueには最新のtextが次々と渡されるが、ExpensiveComponent.textには常に同じ値('foo')が渡される。
そしてExpensiveComponentmemoでラップされているため、propsが変わらない限りは再レンダリングされない。
そのため、ExpensiveComponentによる重い処理の影響を受けることなくテキストボックスが更新されていき、textの更新が一段落した段階でdeferredTextが更新され、ようやくExpensiveComponentの再レンダリングが行われる。

今回のようなケースの他に、Promiseがスローされてレンダリングが中断された際にも、deferredValueの更新が遅延される。

復習を兼ねてまず、useDeferredValueを使わないパターンの挙動について見てみる。

/// <reference types="react/experimental" />

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

function countUp(arg: number) {
  let status = 'pending';

  const suspender = new Promise((resolve) => {
    setTimeout(() => {
      resolve(null);
    }, 2000);
  }).then(() => {
    status = 'success';
  });
  return {
    get() {
      if (status === 'pending') {
        throw suspender;
      }
      return arg + 1;
    },
  };
}

function Child({resource}: {resource: {get(): number}}) {
  const value = resource.get();

  useEffect(() => {
    alert(value);
  });

  return <span>{value}</span>;
}

const initialBar = countUp(-1);

export function App() {
  const [foo, setFoo] = useState(0);
  const [bar, setBar] = useState(initialBar);

  const handleClick = () => {
    setFoo((s) => s + 1);
    setBar(countUp(foo));
  };

  return (
    <>
      <button type="button" onClick={handleClick}>
        count up
      </button>
      <Suspense fallback={<div>Fallback</div>}>
        <div>
          bar: <Child resource={bar} />
        </div>
      </Suspense>
    </>
  );
}

上記のコードの場合、count upボタンを押下するとPromiseがスローされ、レンダリングが中断される。
Promiseの状態が変化すると改めてレンダリングが試みられ、今度はresource.get()Promiseをスローしないのでレンダリングに成功する。
そしてuseEffectが実行され、ダイアログが表示される。

f:id:numb_86:20201216195946g:plain

これを書き換えて、ChildコンポーネントにbarではなくdeferredBarを渡すようにする。

import {Suspense, useState, useEffect, unstable_useDeferredValue} from 'react';

// 中略

export function App() {
  const [foo, setFoo] = useState(0);
  const [bar, setBar] = useState(initialBar);
  const deferredBar = unstable_useDeferredValue(bar);

  const handleClick = () => {
    setFoo((s) => s + 1);
    setBar(countUp(foo));
  };

  return (
    <>
      <button type="button" onClick={handleClick}>
        count up
      </button>
      <Suspense fallback={<div>Fallback</div>}>
        <div>
          bar: <Child resource={deferredBar} />
        </div>
      </Suspense>
    </>
  );
}

そうすると挙動が変わり、フォールバックが表示されなくなる。
そして、ボタンを押下する度にダイアログが2回表示されるようになっている。

f:id:numb_86:20201216200025g:plain

PromiseがスローされるとdeferredBarの更新が遅延されるため、このような挙動になる。
ボタンを押下してもdeferredBarを更新せず、前回のレンダリング時と同じ値で、レンダリングを行う。
当然、resource.get()は前回と同じ値を返すため、レンダリングの結果は前回と変わらない。
そして、Promiseの状態が変化するとdeferredBarが更新される。そうするとChildも再レンダリングされるが、今度はresource.get()が新しい値を返すため、それを使ったレンダリングが行われる。
そのため、1回目のダイアログでは前回と同じ値が使われ、2回目のダイアログでは値が更新されているのである。

趣味で作っている SPA の Performance スコアを 40 点上げた

Lighthouse の Performance スコアを52から94に上げた。

Before。

f:id:numb_86:20201202072709p:plain

After。

f:id:numb_86:20201202072722p:plain

施策として具体的に何を行ったのか、書いていく。

経緯

以前、Shape Painter という SPA を作った。
構成はシンプルで、エントリポイントはひとつだけ。そしてそこで、ライブラリのコードをバンドルしたvendors.contenthash.jsと、アプリケーションのコードをバンドルしたindex.contenthash.jsを読み込んでいる。サーバとの通信はページ読み込み時のみで、あとはフロントエンドで完結して動作する。

React や Redux の習作という意味合いが強く、公開後は放っておいたのだが、なんとなく Tree ページを Lighthouse でスコアを計測してみたところ、Performance 項目がまさかの52だった。
パフォーマンスを意識せずに作っていたのは確かだが(useCallbackを使った最適化などは行っていた)、広告も入れてないしソーシャルウィジェットも入れていないのだから、もう少しマシだろうと思っていた。画像もほとんど使っていないし。

自分以外の利用者がほぼいないのが現状なので放っておいてもよかったのだが、ここ最近学んでいたパフォーマンス改善の実践として丁度よさそうなので、スコア改善に取り組むことにした。

テキストリソースの圧縮

Lighthouse は診断結果に応じたレポートを作成してくれるので、その内容を見ていく。

まず目を引いたのは、Enable text compression
テキストリソースを圧縮せずに配信しており、これを改善するだけでもファイルサイズを大幅に削減できそうである。

f:id:numb_86:20201202092630p:plain

Shape Painter のリソースは S3 に置いてあり、CloudFront で配信している。
この場合、CloudFront でリソースの圧縮を設定できる。

Edit Behavior で Compress Objects AutomaticallyYesにすると、コンテンツを自動的に圧縮して配信してくれる。

f:id:numb_86:20201202092640p:plain

Create Invalidationでキャッシュをクリアしたあとに確認したところ、無事にcontent-encodingvaryが設定されていた。

f:id:numb_86:20201202092650p:plain

vendors.contenthash.jsに対する効果が特に大きく、551kBから152kBにまでサイズを削減できた。

再度計測したところ、大幅にスコアが改善し、Enable text compressionの警告も消えた。

f:id:numb_86:20201202092710p:plain

テキストリソースの圧縮についての詳細は、以前書いた。

numb86-tech.hatenablog.com

script 要素に defer 属性を設定する

Lighthouse のレポートに書かれていたわけではないのだが、script要素にdefer属性をつけていないことに気付き、修正した。
具体的には、html-webpack-pluginの設定を変えた。

github.com

defer属性にどのような効果があるのかは、以下を参照。

numb86-tech.hatenablog.com

スコアは横ばいだったが、これ自体がやるべきことだったので、よしとする。
First Contentful PaintSpeed Indexも目に見えて改善した。

f:id:numb_86:20201202092722p:plain

ブラウザにキャッシュさせるようにする

Lighthouse のレポートにServe static assets with an efficient cache policyという警告が出ているので、次はそれに取り組む。

f:id:numb_86:20201202093737p:plain

Cache-Controlを使って効率的にキャッシュしましょうとのこと。
確かに現状ではCache-Controlを全く設定していない。

既に述べたように、ライブラリのバンドルファイルと、アプリケーションのバンドルファイルを読み込んでいる。
そしてどちらも、ファイル名にハッシュ値を使うことで、ファイルの中身が変わればファイル名も変わるようにしている。
これはキャッシュバスティングという手法で、リソースの内容が変わればリソースの URL も変わるため、古い内容のリソースを参照し続けることを回避できる。
そのため、キャッシュを長く設定しても問題ないように思える。ただ、先程の圧縮のケースのように、同じ URL でも配信内容が変わることもあるので、極端に長い時間は設定しないほうがいいかもしれない。
取り敢えず今回は、90 日間キャッシュするようにした。

また、いくらキャッシュバスティングを行っていても、HTML ファイルが古いままでは意味がない。
そのため、HTML ファイルだけは、キャッシュしないようにした。

Shape Painter は、masterブランチにコミットされた際に GitHub Actions でデプロイを行っている。
そのため、.github/workflows/deploy.ymlを編集して、S3 にリソースを設置する際にCache-Controlフィールドが付与されるようにした。

github.com

レスポンスヘッダにCache-Controlが付与され、2 回目以降のアクセスではキャッシュを使うようになっている(from memory cacheとなっている)。

f:id:numb_86:20201202092735p:plain

f:id:numb_86:20201202092745p:plain

スコアが上がり、Serve static assets with an efficient cache policyの警告も消えた。

f:id:numb_86:20201202092802p:plain

Cache-Controlそのものの説明は、以下を参照。

numb86-tech.hatenablog.com

webpack のコード分割

最後に、残っている警告であるRemove unused JavaScriptに取り組む。

未使用の JavaScript があるとのことだが、心当たりがある。
既に少し触れたが、ライブラリのコードは全てvendors.contenthash.jsにバンドルしている。だが意図があってそうしているわけではなく、webpack のドキュメントにあるサンプルの内容をただコピペしただけである。

    cacheGroups: {
      commons: {
        test: /[\\/]node_modules[\\/]/,
        name: 'vendors',
        chunks: 'all',
      },
    },

こうして作られたバンドルファイルをページロード時に読み込んでいるのだが、明らかに、不要なコードも含まれているはず。
これを改善できれば、ページの読み込みや表示を高速化できると思われる。

まず、いい機会なので webpack をv5に上げた。

github.com

次に、webpack-bundle-analyzerで現状を確認する。

f:id:numb_86:20201202092816p:plain

html2canvasが目を引く。
これは、その名の通り任意の HTML 要素を Canvas 要素に変換してくれるライブラリ。
Shape Painter は、描画した図形をこのライブラリで Canvas 要素に変換し、そこからさらにtoBlobメソッドで PNG ファイルに変換することで、図形を画像ファイルとしてダウンロードできるようにしている。
つまり、このライブラリは画像のダウンロード時に必要になるもので、ウェブアプリの初期表示時には不要。このライブラリを Dynamic Import で読み込むようにすれば、ページロード時に読み込まれるファイルのサイズを削減できるはず。

それ以外にも、React Router のルーティング単位でのコード分割を導入したり、webpack.config.jsの設定を見直したりした。

github.com

その後も、初期表示時に不要なファイル(モーダルなど)を Dynamic Import で読み込むようにして、コード分割を進めた。
最終的に、以下のような形になった。

f:id:numb_86:20201202092833p:plain

その結果、冒頭で述べたようにスコアは94まで改善された。

webpack によるコード分割については、以下の記事に詳しく書いた。

numb86-tech.hatenablog.com

感想

パフォーマンス改善というとテクニカルな手法を駆使するイメージがあったが、当たり前のことを当たり前にやるだけでもスコアが改善されることが分かった。

gzipによる圧縮やレスポンスヘッダの設定などはバックエンドっぽいというか、あまりフロントエンドエンジニアが行うイメージがなかったのだが、今回のような構成ではフロントエンドエンジニアが行うことが多いと思う。
「これはフロントエンド領域、これはバックエンド領域」のように決め付けず、幅広く学んでおかないと、いざという時に対応できないように感じた。というより、CDN による配信やキャッシュの設定も、「フロントエンド」に含まれていると認識すべきなのかもしれない。CDN Edge の利用も、History APIのフォールバックのために必要だったりするし。