この記事は 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を使用
- 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回のレンダーが起きます。
Apollo Client / fetch policy pic.twitter.com/dtEHurJbqJ
— 熱量 (@shoota) December 8, 2021
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状態の表示を観察してみます。
Slow 3G pic.twitter.com/mWPGgqv7lQ
— 熱量 (@shoota) December 8, 2021
なんということでしょう。network-only
やno-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} />
read query pic.twitter.com/K2J8opbGah
— 熱量 (@shoota) December 8, 2021
意図通り、no-cache
の場合はreadQueryからデータを取り出しても取得することができていないことが確認できました。
まとめ
Apollo clientのfetch policyについて、できるだけ視覚的に理解できるようなサンプルを作ってみました。
fetch policyは奥が深く、データのライフサイクルやリアルタイム性を考える上で重要な要素かと思います。例えば、リアルタイムでの更新が重要な場合は、network-only
や cache-and-network
を利用するのがよいでしょうし、検索系のQueryはno-cache
にしてクライアントが持つデータの肥大化を防止していくのが求められるでしょう。
また今回は触れることができませんでしたが、データの更新(mutation)を実行した場合に、再度データを取り直したり(refetch)、GraphQLのFragmentに対応したreadFragment/writeFragment、サーバーのレスポンスを待たずにcacheを更新する(Optimistic mutation results)など、様々なデータのライフサイクルが用意されています。
開発初期の段階ではこれらを細かく制御をするのは大変ですが、SPAチューニングの大事な要素としてしっかりと理解して扱っていけると良いなと思いました。
-
弊チームはGLOPLA LMSという社会人向け学習管理プラットフォームを開発しています。 ↩