ReactのSuspense、素敵ですよね。
この記事ではGraphQLのワーキンググループで仕様の策定が進んでいる@defer
と@stream
について解説します。@defer
と@stream
はReactのSuspenseの力を引き出す、注目すべきディレクティブです。
なお、これらのディレクティブの仕様はワーキンググループでほぼ合意できていますが確定した仕様ではないことに注意してください。
Suspenseは最高だ!
あるブログの記事の一覧ページを例に見てみます。 記事一覧ページを表示するためのGraphQLのクエリは次のようになります。1
query {
articles(page: 1) { # 記事一覧(1ページ目)
title # タイトル
body # 文章(先頭の少しだけ表示)
author {
name # 著者名
}
like {
count # イイねの数
avatars {
image # イイねしている人のアバター画像
}
}
}
}
このクエリでは、まず記事の一覧(articles
)があり、記事の中に著者(author
)とイイね(like
)があります。
ここでReactのSuspenseの典型的な利用例について簡単におさらいしておきましょう。 Suspenseを使うと、ページを一気に表示するのではなく、データが揃ったコンポーネントから段階的に表示していく処理を簡単に書くことができます。
Webサイトでページの一部にスケルトン(プレースホルダ)が表示されるのを見たことがありませんか? データがまだ表示できない時に、代わりに表示されるアレです。
Suspenseはこういったコンポーネントの「待っている」状態の管理や表示の切り替えを担当します。 Suspenseを使うと、バックエンドに要求したデータ(またはクライアントのキャッシュにあるデータ)が揃ったコンポーネントから順に、スケルトンのUIから実際の表示に切り替えていく処理を簡単に書くことができます。
今回のブログの記事一覧の例で言うと、まず記事のタイトルなどのデータを最初に表示してから、その他のデータ(著者やイイね)を後で表示できるようになります。 著者やイイねのデータがバックエンドから返ってきていない間は、そこだけスケルトンのUIを表示して読み込み中であることを表現できます。 仮にバックエンドで”イイねの数”の集計が遅くても、それが足を引っ張りページ全体のレンダリングまで遅くなるという事態を避けられます。
ダメだキャッシュがない
ということで、Suspenseのおかげでユーザーを待たせずに重要なデータからいち早く表示できるようになりました。手軽にユーザー体験を向上させることができます!
…ちょっと待ってください。 仮に記事一覧ページで使用できるデータのキャッシュが、ローカルに全くなかったとしましょう。2 私達がページの一部分でも表示するためには、データをバックエンドから取得しなければなりません。 しかし、GraphQLのクエリをバックエンドに送ると、全てのデータが1回のレスポンスで返ってきます。そうなると部分的にページを更新しようにも、一気に全て表示する以外できません。
これは困りました。キャッシュなしで一部のデータだけを先に表示するためには、バックエンドからのレスポンスを分割する必要があるようです。
RESTやGraphQL(旧)の問題点
仕方がないので、クエリを3つに分割しましょう。
query Articles { # 記事一覧
articles(page: 1) {
id
title
body
}
}
query ArticleAuthor { # 記事の著者
article(id: $id) {
author {
name
}
}
}
query ArticleLike { # 記事のイイね
article(id: $id) {
like {
count
avatars {
image
}
}
}
}
Articles
クエリで記事一覧を取ってから、記事のid
をArticleAuthor
クエリ(著者)とArticleLike
クエリ(イイね)に渡して、データを取得するようにしました。
このクエリをそれぞれバックエンドに送れば、複数のレスポンスが返ってくるのでSuspenseを使ってデータが揃った順に表示できます。
この例ではGraphQLを使用していますが、基本的にRESTでも同じように一覧を取るAPIを叩いてから詳細なデータを取得します。
さて、これは動作しますが良い解決策というわけではありません。
まず、明らかに最終的なページを表示するまでの速度が低下しています。
ArticleAuthor
クエリとArticleLike
クエリは引数に記事のid
を取るため、Articles
クエリの結果が戻ってきてからでないとリクエストを送ることができません。 3
そのため、ArticleAuthor
クエリとArticleLike
クエリに含まれるデータを使用するコンポーネントのレンダリングは、1つのクエリで全て取得していた時よりも明らかに遅くなります。
また、複数のクエリに分割したことでリクエスト数が増えたためモバイルなどのバッテリーの消費が激しくなりますし、Articles
クエリでDBから読み込んだ記事データにArticleAuthor
クエリとArticleLike
クエリが再度アクセスすることになるためバックエンドの負荷も無駄に高くなります。
そもそも、このようなデメリットを避けるためにRESTではなくGraphQLを使用して1回でデータを取得できるようにしたという歴史的背景があるので、これでは時代が逆行しています。 このような事態を避けるためにできることは何でしょうか。
データに優先度をつけてインクリメンタル配信する
Suspenseを使うと、ページを一気に表示するのではなく段階的に表示できます。しかしReactはページ全体をSuspenseで囲むのではなく、遅延表示して良いものだけをSuspenseで囲むように言っています。
ある機能が次の画面に必須なものではない場合は、それを <Suspense> でラップして、遅延読み込みさせてください。これにより、残りのコンテンツを可能な限り素早く表示できるようになります。逆に、あるコンポーネントがないと次の画面の表示自体が無価値であるという場合(例えば我々の例の <ProfileDetails>)、そのコンポーネントを <Suspense> で囲んではいけません。
ここから得られる洞察は、ページ内のコンポーネントには優先順位があるということです。重要なコンポーネントはすぐに表示させて、そうでないコンポーネントは遅延して表示させるべきです。 この考え方はデータの配信にも言えます。データはコンポーネントで表示するためにあるので、ページ内のコンポーネントに優先順位がつくということは、ページ内で使用されるデータにも優先順位がつきます。
最初のクエリに話を戻しましょう。 結論から言うと、我々の願いは、GraphQLのクエリを1回でリクエストして優先順位に従って複数のレスポンスを返してほしいということなのです。
query {
articles(page: 1) {
title # 優先する
body # 優先する
author { # 優先しない
name
}
like { # 優先しない
count
avatars {
image
}
}
}
}
優先順位としては、まずはarticles
の1個目の記事のtitle
とbody
がレスポンスとして返ってきて、その後に他の部分が遅延して返ってくると理想的ですね。
このように優先順位をつけて部分的にAPIからデータを返すことをインクリメンタル配信(Incremental Delivery、Incremental Data Delivery)と言います。
インクリメンタル配信を行う@defer
と@stream
@defer
と@stream
はクエリ内でインクリメンタル配信を行う箇所を指定するためのディレクティブです。
下記の例ではあくまで説明を簡単にするためのイメージとして記述しています。実際のクエリは最後に載せています。
query {
articles(page: 1) @stream(initialCount: 1, label: "articlesStream") {
title
body
author @defer(label: "articlesAuthorDefer") {
name
}
like @defer(label: "articlesLikeDefer") {
count
avatars {
image
}
}
}
}
@stream(initialCount: 1, label: ...)
は、配列のようなデータ構造から1つ(initialCount
で指定した数)だけはすぐに返してもらい、その他は後で配信してくれれば良いという指定になります。
@defer(label: ...)
は、そのブロックを最初の応答に含めず、後で配信してくれれば良いという指定になります。
2つのディレクティブに共通するlabel
は、一意の値を指定します。これはGraphQLクライアントが後で配信されたデータの中身を識別するために使用します。
これらのディレクティブがあれば、クエリが返すレスポンスを分割し、優先順位をつけて返してもらえるようになります。
1つのリクエスト、複数のレスポンスの実装
ところで、インクリメンタル配信はどうやって実装するのでしょうか。1つのリクエストを送って、複数の結果を返すようなことはHTTPで実現できるのでしょうか。
HTTP1.1ではTransfer-Encoding
というヘッダがあり、Transfer-Encoding: chunked
を指定することで複数のチャンク(塊)でデータを返すことができます。
これはもともとContent-Length
で指定するデータの長さ(大きさ)の計測を避けるためのヘッダなのですが(データの大きさが分からなくてもチャンクで分割して返し始めることができます)、GraphQLではチャンクを順次返す仕組みを使ってインクリメンタル配信を実現します。
現在の進捗
GraphQLの仕様を検討するワーキンググループで@defer
と@stream
の仕様の策定が進められています。仕様自体はほぼ合意が取れている状況です。
バックエンドの実装が必要なタイプの仕様なので、リファレンス実装としてgraphql-jsの実装が先行しています。
ただし、そもそもReactのSuspenseとデータフェッチの組み合わせがいつ実験的機能でなくなるのかも謎なので、早くても普通に使えるようになるのは2021年になってからでしょう。2022年にならないことを祈ってます。
おまけ:実際のクエリ
最後に、現在ワーキンググループで検討されているRFCに沿って今回使用したクエリを記述します。
query {
articles(page: 1) @stream(initialCount: 1, label: "articlesStream") {
title
body
...ArticlesAuthor @defer(label: "articlesAuthorDefer")
...ArticlesLike @defer(label: "articlesLikeDefer")
}
}
fragment ArticlesAuthor on Article {
author {
name
}
}
fragment ArticlesLike on Article {
like {
count
avatars {
image
}
}
}
現在策定されている仕様では、@defer
はフラグメントにしか使用できません。
ReactとGraphQLを結びつけるRelayではフラグメントとコンポーネントはほぼ1対1です。コンポーネントのレンダリングを遅延させて良いのであれば(Suspenseで囲むなら)、@defer
をコンポーネントのフラグメントにつけるという流れになると思います。
-
今回のGraphQLのスキーマは説明を簡単にするために通常ではありえないスキーマ設計になっています(
↩like.count
など)。真似しないでください。 -
Relay、SWR、React Queryなどを使用することで、キャッシュに存在しているデータを先に表示するコードを簡単に書けます(ただしexperimentalのものがほとんどです)。むしろキャッシュ機構を持つデータフェッチライブラリがないとSuspenseの利用は大変だと思います。
↩ -
↩author
やlike
もarticles
で取ればウォーターフォールが発生しないのではと思ったかもしれません。確かに発生しませんが、DBからのデータ取得に無駄が多いという側面もありますし、現実問題として最初のarticles
を取った時と最後のarticles
を取った時で、記事が削除や追加されていない保証はないですから、author
やlike
と記事一覧のズレを考慮するようなカオスなクライアント実装になると思います。