Apollo ClientのCache機構(fetch policy)を視覚的に理解する


この記事は GLOBIS Advent Calendar 2021 の12月9日の記事です。

まえがき

こんにちは、こんばんわ。GLOBISでフロントエンドエンジニア / エンジニアリングマネージャーをしている @shoota です。
GLOBISでは React/TypeScriptでのFull SPAをコア技術のひとつとしてWebサービスの開発を進めており、その中のひとつの知見を改めて記事にまとめました。
今回は弊チームが開発しているReactアプリケーション1でも利用している、Apollo Clientについてです。

Apollo Clientは非常に強力なCache機構をもっており、fetch policyを中心に、サンプルを用いながらその挙動を視覚的に理解できるようにしてみました。

SPAの開発初期には、クライアント側のCacheをどう取り扱うか迷ったり議論することもしばしばあり、それらを適切に扱っていくための開発コストもかかりがちかと思います。弊チームも例にもれず、Cacheをどう運用してSPAを作っていくか悩んだこともあり、これからのSPA開発の判断の一助となれば嬉しいなと思います。

なおこちらの記事で動かしたサンプルはこちらにあります。

環境構築

まずはSPAを動かす環境をつくります。

SPA / Client

  • TODOリストを表示するViewとContainerで構成する
  • Apollo ClientのHook関数をGraphQL code generatorで自動生成しておく
  • ボタンを押すとgraphqlを実行してデータがReact propsに流れる
  • webpack-dev-serverからlocalhost:3035でHTMLとSPAをserveする

表示するページのComponentはこんな感じにしました

type Props = {
  isLoading?: boolean
  todos?: Todo[]
  getContent?: () => void
}

export const Todos: React.VFC<Props> = ({ todos, isLoading, getContent }) => {
  console.log(isLoading, new Date())
  return (
    <Card title="TODO List" style={{ margin: '5%' }}>
      <Button onClick={getContent}>データを取る</Button>
      <Divider />
      <List
        loading={isLoading}
        dataSource={todos}
        renderItem={({ id, contents, finished }) => (
          <List.Item key={id}>
            <Typography.Title level={3}>
              <Space>
                <CheckCircleTwoTone
                  twoToneColor={finished ? '#52c41a' : '#e3e3e3'}
                />
                {contents}
              </Space>
            </Typography.Title>
          </List.Item>
        )}
      />
    </Card>
  )
}

GraphQL Server

  • Hasuraを使用
    • 今回は配布されているDockerファイルを利用してlocalhostで実行
    • CORSで動くため HASURA_GRAPHQL_CORS_DOMAIN: 'http://localhost:3035'を指定しておく
  • TODOリストのテーブル定義
    • id: number (auto increment)
    • contents: text
    • finished: boolean

実行するクエリはこうなりました

query Todos {
  todos {
    id
    contents
    finished
  }
}

まずは単純なQueryで比較

ではまずQuery実行時のfetchPolicyオプションごとの挙動を見ていきます。
Queryに指定できる fetch policyはこちらのとおりです。

Cacheがどのように働いているかは、初回のデータフェッチがすんだ後に、再度取得したときにリクエストが飛んでいるか?とReactの再レンダーが何度起きているかを見ればわかります。
再レンダーの回数は、Presentational Componentの実行時にloading stateと現在時刻のログを仕込むことで測りました。
データ取得時には、loading状態を挟んでいるので、「Loading中」と「データ取得後」で2回のレンダーが起きます。

fetch policy 結果
cache-first (default) cacheが存在しないときのみRequestを発行してCacheし、一度データがとれたらRequestは飛ばない。loading状態のレンダリングは初回のみにおこる。
network-only 常にRequestを発行してCacheするので、Query実行をするたびにloading状態のレンダリングが起きる
cache-and-network network-onlyとRequestやレンダリング回数に差はない。データ取得を連打したときにちょっとだけloading状態の表示にばらつきがないように見える。
cache-only Apollo cacheのみを参照するので、Requestが発行されず、loadingは常にfalse。
no-cache network-onlyと挙動は同じ(Apolloがcache自体を放棄しているはずだが、見た目ではわからない)
standby ドキュメントに書かれている通り、任意のタイミング(refetch/update)でデータ更新をするため、これを指定していない場合はRequestが発行されず、loadingがtrueになって停止する。

注意したいのはcache-and-networkの挙動です。cacheとRequestでデータが渡されるタイミングが2度あるので、「loading -> cacheデータを表示 -> loading -> 更新データを表示」 のように動いているとすれば、(矢印の数だけの再レンダーが発生するので)3回のレンダリングとなるように思えますが、実際にはnetwork-onlyと比べてレンダリング回数に差はありませんでした。これを次に視覚的に確認したいと思います。

cacheの挙動の比較

cache-firstは一度cacheされるとRequestしないため、HasuraのGUIから直接データを追加してもSPAに反映されないというところまで特徴がみえましたが、network-only, cache-and-network, no-cacheは単純なQueryの比較では見た目の違いが捉えられず、挙動の差がいまいちわかりませんでした。
そこでつぎはGoogle Chromeのネットワークを Slow 3Gにして、意図的にloading状態の表示を観察してみます。

なんということでしょうnetwork-onlyno-cacheの場合では、初回データ取得のあとにも「データが空になる」という状態をはさんで表示されるのに対して、cache-and-networkはデータの取得が完了するまで(loadingがfalseになるまで)は初回で取得したcacheを渡し、データ取得が完了すると新たなデータとして確定してくれました。

no-cacheの確認

最後に、no-cacheとnetwork-onlyを比較します。といっても、これらはApollo client内部にcacheがあるかどうかの違いだけなので、ドキュメントに書いてあることの確認になってしまいますね。この比較は単純なデータ取得のQueryではうまく比較できません。そこで恣意的ではありますが、「Requestで取得した後に、改めてcacheからデータをとりだす」ようにしてみます。これならno-cacheの場合はcacheがないので、何も表示されないはずです。

  /* データ取得の返却値は使わずにreadQueryでcacheからわざわざ取り出す */
  useTodosQuery({ fetchPolicy }) // network-only or no-cache
  const data = client.readQuery({ query: TodosDocument })

  return <Todos todos={data?.todos} />

意図通り、no-cacheの場合はreadQueryからデータを取り出しても取得することができていないことが確認できました。

まとめ

Apollo clientのfetch policyについて、できるだけ視覚的に理解できるようなサンプルを作ってみました。
fetch policyは奥が深く、データのライフサイクルやリアルタイム性を考える上で重要な要素かと思います。例えば、リアルタイムでの更新が重要な場合は、network-onlycache-and-networkを利用するのがよいでしょうし、検索系のQueryはno-cacheにしてクライアントが持つデータの肥大化を防止していくのが求められるでしょう。

また今回は触れることができませんでしたが、データの更新(mutation)を実行した場合に、再度データを取り直したり(refetch)、GraphQLのFragmentに対応したreadFragment/writeFragment、サーバーのレスポンスを待たずにcacheを更新する(Optimistic mutation results)など、様々なデータのライフサイクルが用意されています。

開発初期の段階ではこれらを細かく制御をするのは大変ですが、SPAチューニングの大事な要素としてしっかりと理解して扱っていけると良いなと思いました。


  1. 弊チームはGLOPLA LMSという社会人向け学習管理プラットフォームを開発しています。