Next.js@13 と React Server Component と Suspense と GraphQL の @defer, @stream について
注:このスクラップの内容は間違いとか勘違いとか含んでる可能性があります。
間違いの指摘や補足など大歓迎です。
React Server Component と Suspense と Next.js@13 ってなんか紛らわしくね
React Server Component(RSC)
rfc
概要
- RSC はサーバー上で実行されるコンポーネントのこと
- サーバー上で実行されるということで、データベースへの通信などを直接行うことができる
- コンポーネントはレンダリング結果のみがクライアントに送られるので、秘匿情報などの流出は起こらない
適当に具体例を書くと
// 従来の方法(React Client Component, RCC とする)
const Page = () => {
const { data } = useSWR("/todos/1", () => fetch("/todos/1"))
//
}
// RSC
const Page = async () => {
const todo = await db.query("select * from `todo` where id == 1")
//
}
みたいな感じで書くと、サーバでデータベースへのアクセスが行われて、レンダリングするために必要な情報だけがクライアントに送信されるみたいなイメージ
特徴としては RSC では async await を使うことがサポートされている点
現時点で RCC では async await を使うことはできない(後述 [1])が、データを取得するまでレンダリングを中断させてしまうと画面に何も表示できないからである。
ただ Suspense を利用することで、RCS でも部分的にレンダリングを中断するということが可能になっている。また RSC と Suspense の関係については rfc のこの箇所 に言及がある
話を戻して、RSC で async await を利用するということはデータの取得が完了するまでユーザーにHTMLを返せないということになりそうだが、実際はうまいこと実装がされているっぽい
実際に Next.js@13 で意図的に await new Promise((resolve) => setTimeout(resolve, 1000))
こういう感じで待機の処理をしても、そのコンポーネント以外はすぐに結果が帰ってくる
-
RCC と async await に関する rfc https://github.com/acdlite/rfcs/blob/first-class-promises/text/0000-first-class-support-for-promises.md ↩︎
再利用可能な RSC と async await
前述した通り、Next.js@13 では async await を利用したコンポーネント定義が可能となっている。
const Page = async () => {
const todo = await db.query(/* ... */)
//
}
しかし、 async await で定義した RSC のコンポーネントを利用することはできない。(っぽい?)
const Child = async () => <p>child</p>
const Page = async () => {
return (
// 'Child' cannot be used as a JSX component.
// Its return type 'Promise<Element>' is not a valid JSX element.
// Type 'Promise<Element>' is missing the following properties from type 'ReactElement<any, any>': type, props, keyts(2786)
<Child />
)
}
その場合は use
を使えばよい
import { use, Suspense } from "react"
const ChildRSC = () => {
const todo = use(db.query(/* */))
//
}
const Page = async () => {
return (
<>
{/* */}
<Suspense fallback={<div>Loading...</div>}>
<ChildRSC />
</Suspense>
{/* */}
</>
)
}
この時、Suspense
を使っていないと、 Page 自体のレンダリングも遅延されてしまうことには注意が必要
お気持ち
ここの設計は正直美しくないですよね。
個人的に fallback や Error のハンドル義務を上流に移譲することは大賛成なのですが、コンポーネント自体が Suspense すべきかどうかを型など表現できていないことには上流でハンドルが必要かどうかがわからないのでどうかなぁと思います。
https://github.com/acdlite/rfcs/blob/first-class-promises/text/0000-first-class-support-for-promises.md が導入されて Suspense すべきかどうかを Promise を返しているかどうかで判定するみたいな感じでいい感じに仕組み化できることに期待したいですね
ここまでで 個人的に Suspense と RSC について考えをまとめることができたので、次は GraphQL の @defer
との組み合わせについて考えていく
@defer
とは
GraphQL の 詳細な説明については別の記事等を参照ください。
めっちゃ簡単に説明すると、GraphQL のレスポンスを分割して、部分的に返すよみたいな感じです
query TopPage {
# インラインフラグメントを用いてますが、普通にフラグメントにしても大丈夫です。
...on Query @defer {
# 通知バッジに表示する通知数を取得する処理がめっちゃ重いとする
notificationCount
}
# ... その他にも取得したいものがある
}
こうした時に
# まずはその他のデータが帰ってくる
{
data: {
// ...
}
}
# その後追加で notificationCount が帰ってくる
{
data: {
notificationCount: 10
}
}
みたいな感じ(本当はもう少しちゃんとしてる)。現在実装されている方法としては content-type: multipart/mixed
を利用されているのがある。
ここまで聞いて察しの良い方は既にお気づきかもしれないが、 @defer
と Suspense
を組み合わせると、データの遅延取得とコンポーネントの遅延レンダリングを組み合わせることで、大規模なWebページも逐次的なレンダリングを効率の良い方法で実現ができそうだと感じるわけである。
Suspense
と @defer
についての課題
上記でコメントした内容を実現させるには課題がある。
まず一つ目として、そもそも @defer
を適切に扱える GraphQL Client が少ないのである。
-
@apollo/client
は3.7
以降で@defer
の利用が可能となっている -
relay
はまだ@defer
のサポートがされていない -
urql
はサポート済み
ただ urql
に関しては request headers の accept
に multipart/mixed
を追加してくれていないため、僕の環境ではうまく動かすことができなかったのと、コードをいじって multipart/mixed
を accept
に追加してもやっぱり動かなかったので、正直ちょっとわからんって感じです
そして次の課題として、@defer
の正しい扱いが非常に難しいということである
具体的にどういうことかというと、@defer
と Suspense
を組み合わせて使う上で、以下ができるということは求めてはいない。
const Page = () => {
const { data, loading } = useQuery(gql``)
if (loading) {
// @defer を含めデータの取得が完了していない時
return `loading`
}
// @defer を含めデータの取得が完了している状態
}
上記では、せっかく @defer
で遅延読み込みが可能になっても、レンダリングのタイミングが同時となっては全く意味がないのである。
なので、本当に求めているのは例えば次のようなコードである
const NotificationCountFragment = gql`
fragment NotificationCount on Query {
notificationCount
}
`
const PageQuery = gql`
query Page {
... NotificationCount @defer
}
${NotificationCountFragment}
`
const NotificationBadge = ({ getFragment }) => {
const { data } = use(getFragment(NotificationCountFragment))
return (
<div>
count: {data.notificationCount}
</div>
)
}
const Page = () => {
const { getFragment } = query(PageQuery)
return (
<>
<Suspense fallback={"loading..."}>
<NotificationBadge getFragment={getFragment} />
</Suspense>
</>
)
}
みたいな感じである。
こんな感じであれば、Suspense
と @defer
を使った逐次レンダリングが可能となる
@defer
と Server Side Rendering の課題
Server Side Rendering については今回初めて言及したが、読者の方は既にどんな存在であるか理解しているかと思うので、説明は省く。
Server Side Rendering で適切にレンダリングした後、クライアントで hydration を行う際の初期データをどのように届けるかという問題がある。
Server Side Rendering で query を行い、hydration の際にも query を行えば問題はないだろうが、サーバーで実行しているクエリの結果をクライアントで送れたほうが効率的であるし、もしサーバーで実行した時とクライアントで実行した時で結果が異なるとしたら、hydration に失敗したりしそう(本当かは知らない)
よくある既存のSSRでの初期データ埋め込みがどのように実装されているかというと
- SSR の段階で発生した GraphQL リクエストをひとまずキャッシュに入れておく
- 必要な GraphQL リクエストが全て完了したらその結果を
<script>
タグの中に埋め込む
簡易的な例としては
const Page = async () => {
const client = new GraphQLClient()
const element = (
<GraphQLProvider value={client}>
<App />
</GraphQLProvider>
)
// レンダリングを行う過程で発生する GraphQL リクエストが終了するのを待つ
await waitForGraphQL(element)
return (
<>
{element}
<script>
{/* 実際は dangerouslySetInnerHTML を使う必要がある */}
window.__GRAPHQL_INITIAL_DATA = JSON.parse(${JSON.stringify(client.getData())});
</script>
</>
)
}
みたいな感じである。(もしかしたら全然違う実装もあるかもしれない
ここでもやっぱり @defer
を使った遅延読み込みを使った際に、それらを逐次的にクライアントに送ったほうが良さそうに感じる。
もしかしたら、@defer
で遅延された結果が返ってきたタイミングごとに script
タグを配信するみたいな方法が取れるかもしれない
前述の getFragment
フラグメントの利用箇所が1つだけで配列でなければ、どのフラグメントの値を求めているか判断できるけど、そうじゃなければどのフラグメントの値を求めているか判別するの難しそう
fragment ProductPrice1 on Product {
price
}
fragment ProductPrice2 on Product {
price
relatedProducts {
id
...ProductPrice1 @defer
}
}
query Page {
products {
id
...ProductPrice2 @defer
}
}
こういうクエリの時 getFragment(ProductPrice2Fragment)
はどの ProductPrice
に対応する Promise を返せば良いのだろうか
GraphQL のレスポンスには path
が含まれているので、レスポンスからどのフラグメントに対するデータかの判断は可能である
{
"incremental": [
{
"data": {
"price": 1000,
"relatedProducts": [
{ "id": "123" },
{ "id": "234" }
]
"__typename": "Product"
},
"path": ["products", 0, "relatedProducts", 0]
}
],
"hasNext": true
}
コンポーネント側の実装
const Product = ({ getFragment }) => {
// この時 data はどれに対するデータなのか?
const { data } = use(getFragment(ProductPriceFragment))
console.log("price", data.price)
}
事前に path
がわかりきっているとすれば
const Product = ({ getFragment }) => {
// path を指定することで、特定できる
const { data } = use(getFragment(ProductPriceFragment, ["products", 0, "relatedProducts", 0]))
console.log("price", data.price)
}
ってできるけど、配列のアイテムとかだと厳しいよね
こんな風にすればいいのかな?
const ProductChild1 = ({ getFragment, path }) => {
// path を指定すれば特定できる
const { data } = use(getFragment(ProductPriceFragment1, path))
console.log("price", data.price)
}
const ProductChild2 = ({ getFragment, path }) => {
const { data } = use(getFragment(ProductPriceFragment2, path))
return (
<div>
{data. relatedProducts.map((product, i) => (
<ProductChild1 key={product.id} getFragment={getFragment} path={[...path, "relatedProducts", i]} />
))}
</div>
)
}
const Page = () => {
const { data, getFragment } = query(PageQuery)
return (
<div>
{data.products.map((product, i) => (
<ProductChild1 key={product.id} getFragment={getFragment} path={["products", i]} />
))}
<div>
)
}
でもわざわざ path
を props
として引き回すのめっちゃめんどくさい気がするけど、どうにかならんかな