🌊

【ざっくり解説】React Server Componentのレンダリングプロセス

2023/05/16に公開

はじめに

先日のNext.jsのver13.4の発表でApp Routerがstableになり、まだあまり普及していませんが、React Server Componentが今後本格的に広まっていきそうですね。

React Srever Componentの登場により、Reactのレンダリングプロセスは以前のそれとは異なるものにりました。

今回は、そんなReact Server Componentのレンダリングプロセスをざっくり解説していきます。

本記事で使用する用語について

本記事で使用する用語について、諸々深掘りを行う前に整理しておきます。

用語 意味
クライアントコンポーネント 従来のReactコンポーネントであり、App Routerでuse clientディレクティブを使用したコンポーネントを指します。
サーバーコンポーネント App Routerのデフォルトのコンポーネント。サーバ上で処理されるコンポーネントを指します。今回の主役です。
RSC React Server Componentの略称を指します。
RSCプロセス サーバーコンポーネントをサーバー側で処理する一連のプロセスを指します。

RSC登場による役割分担

そもそも従来のReactコンポーネント(すなわちクライアントコンポーネント)とRSCは役割が違います。なのでRSCを使いこなすためには、個人的には使いわけがかなり重要になってくると思います。

RSC登場以前は、クライアントコンポーネントしか存在せず、SSRでデータフェッチするにも、pages単位でしかデータの取得を行うことができませんでしたが、RSCはコンポーネント単位でサーバー上でレンダリングされるため、RSC登場以前に課題であったコンポーネント単位でのサーバー側でのデータ取得等が可能になりました。

要するにRSCの登場により、サーバーコンポーネントはデータの取得やコンテンツのレンダリング、クライアントコンポーネントはインタラクティブなUIの実現といったように、サーバコンポーネントとクライアントコンポーネントのそれぞれが得意な処理の実行に注力できるようになり、ページの読み込み速度の改善などのメリットを享受できるようになりました。

簡易的なRSCレンダリングのイメージ

いきなりRSCのレンダリングの一連のプロセスを解説されても、おそらく理解しにくいかなと思います。なので事前に簡易的なRSCレンダリングのイメージを持っておくと非常に理解しやすいかなと思います。

【簡易的なRSCレンダリングのイメージ】

  1. サーバーは、サーバーコンポーネントを通常通り「レンダリング」し、Reactコンポーネントをdivや pといったネイティブなhtml要素に変換する。

  2. ブラウザでレンダリングされる予定の「クライアント」コンポーネントに遭遇すると、代わりにプレースホルダーを出力し、この穴(プレースホルダー)を正しいクライアントコンポーネントとプロップで埋めるよう指示する。

  3. そして、ブラウザがその出力を受け取り、クライアントコンポーネントでその穴(プレースホルダー)を埋める。

RSCのレンダリングプロセスについて

RSCを活用すると、Reactツリーは以下のようにサーバーコンポーネントとクライアントコンポーネントとが入り混じったようなもになります。


引用: https://www.plasmic.app/blog/how-react-server-components-work

では、このような複雑に2種類のコンポーネントが入り混じったReactツリーはどのように実現されるのでしょうか?

RSCプロセスはざっくりと以下の3ステップになります。

  1. サーバがレンダリングのリクエストを受信
  2. サーバーがコンポーネント要素をJSONシリアライズする
  3. クライアント(ブラウザ)がReactツリーを再構築する

それぞれ見ていきましょう。

1. サーバがレンダリングのリクエストを受信

サーバがレンダリングの一端を担うため、RSCを使用する場合はレンダリングの開始もサーバから始まります。故に、ルートコンポーネントもサーバーコンポーネントということになります。

サーバーは、リクエストで渡された情報に基づいて、どのサーバーコンポーネントとどのpropsを使用するかを決定します。このリクエストは通常、特定のURLのページリクエストという形で届きます。

2. サーバーがコンポーネント要素をJSON文字列へシリアライズする

RSCプロセスでは、最初のルートサーバコンポーネントをHTMLのタグとクライアントコンポーネントのプレースホルダで構成されるツリーに変換し、最終的には、そのReactツリーはJSON文字列化(シリアライズ)してクライアント(ブラウザ)に渡します。

ReactツリーをJSON文字列化するには、シリアライズ可能なオブジェクトである必要があるのですが、シリアライズ可能なオブジェクトに変換する際にはReact.createElementが使用されています。

最終的にJSON文字列化する部分では、JSON.stringify()の代替として、resolveModelToJSON()を使用して実際のシリアライズを行っているそうです。

以下のは素のHTML要素とコンポーネント要素をReact.createElementした時に生成されるオブジェクトのサンプルです。

// 素のHTML
> React.createElement("div", { title: "oh my" })
// {
//	  $$typeof: Symbol(react.element),
//	  type: "div",
//	  props: { title: "oh my" },
//	  ...
// }

function MyComponent({children}) {
  return <div>{children}</div>;
}

// Reactコンポーネント(関数)
> React.createElement(MyComponent, { children: "oh my" });
// {
//	  $$typeof: Symbol(react.element),
//	  type: MyComponent  // MyComponentと言うコンポーネント関数の参照
//	  props: { children: "oh my" },
//	  ...
// }

上記のサンプルの一つ目は、素のHTMLタグをシリアライズしています。HTMLのタグ文字列はシリアライズ可能ですが、上記のサンプルの二つ目のコンポーネントとして定義した関数は、関数のままではシリアライズができません

では、具体的にどのようにしてシリアライズ可能なオブジェクトへの変換処理を行っているのでしょうか?

実は、素のHTMLタグなのか、サーバーコンポーネントなのかクライアントコンポーネントなのかで、少し処理が異なります

  • 素のHTMLの場合
    • typeフィールドにはdivのようなタグの文字列が入っており、すでにシリアライズ可能であるため特別な処理は何も施されない。
  • サーバーコンポーネントの場合
    • typeフィールドに格納されているサーバーコンポーネントの関数とそのpropsを呼び出して、その結果をシリアライズします。
    • そのためサーバーコンポーネントは最終的にはただのHTMLタグ文字列になります。
  • クライアントコンポーネントの場合
    • クライアントコンポーネントの場合は、typeフィールドにはコンポーネント関数ではなく、モジュール参照オブジェクトが格納されているため、すでにシリアライズが可能。
      → 特別な処理は何も施されない。

モジュール参照オブジェクトとは何か

RSCでは、React要素のtypeフィールドに「モジュール参照オブジェクト」と呼ばれる新しい値を導入できます。 コンポーネント関数の代わりに、コンポーネント関数へのシリアライズ可能な「参照」を渡します。

例えば、ClientComponentというコンポーネント要素は以下のようになります。

{
  $$typeof: Symbol(react.element),
  // type フィールドが、実際のコンポーネント関数の代わりに参照オブジェクトを持つ
  type: {
    $$typeof: Symbol(react.module.reference),
    // ClientComponentは以下のファイルからdefault exportされる
    name: "default",
    // ClientComponentをdefault exportしているファイルのパス
    filename: "./src/ClientComponent.client.js"
  },
  props: { children: "oh my" },
}

では、クライアントのコンポーネントのみを上記のようなシリアライズ可能なモジュール参照オブジェクトへと変換しているはいったい何でしょうか?

誰がモジュール参照オブジェクトへ変換しているのか?

結論から言うと、Reactチームが公開している公式のRSCサポートであるwebpack loaderまたはnode-registerがやってくれてるらしいです。

サーバーコンポーネントが"use client"ディレクティブが記述されているクライアントコンポーネントファイルから何かをインポートするとき、実際にその実態を取得するのではなく、代わりに、そのもののファイル名とエクスポート名を含む、モジュール参照オブジェクトを取得します。

シリアライズ可能なReactツリー

クライアント(ブラウザ)にReactツリーをJSONにシリアライズして送信するために、サーバーコンポーネントで生成したReactツリーは全てシリアライズ可能である必要があります。

引用: https://www.plasmic.app/blog/how-react-server-components-work

つまり呼び出すコンポーネント関数だけではなく、そのpropsすらもシリアライズ可能である必要があります。

そのためサーバーコンポーネントから直接子孫コンポーネントのpropsにイベントハンドラを渡すことができません

実際にサーバーコンポーネントからbuttonタグを呼び出し、onClickプロパティに関数を渡そうとすると以下のエラーが発生します。

Unhandled Runtime Error
Error: Event handlers cannot be passed to Client Component props.
  <button onClick={function} children=...>
                  ^^^^^^^^^^
If you need interactivity, consider converting part of this to a Client Component.

もしこれを実現したかったら、エラーメッセージも記載されているように一度クライアントコンポーネントを挟む必要があります。

3. クライアント(ブラウザ)がReactツリーを再構築する

ブラウザはサーバーからJSON出力を受け取り、ブラウザでレンダリングするためにReactツリーの再構築を開始します。typeフィールドがモジュール参照である要素に遭遇したら、それを実際のクライアントコンポーネント関数への参照に置換します。

バンドラによってモジュール参照オブジェクトがクライアントのコンポーネントに置換され、Reactツリーが再構築されると、サーバーコンポーネントはただのHTMLタグになっているため、以下の画像のようなクライアントコンポーネントとHTMLタグが入り混じっている状態になります。


引用: https://www.plasmic.app/blog/how-react-server-components-work

最後はこのツリーを通常通りレンダリングしてDOMにコミットすることで、晴れてRSCが画面に描画されます。

以上がRSCのレンダリングプロセスです。

まとめ

RSCがレンダリングされ、画面に描画されるまでの一連のプロセスがなんとなくイメージできたのではないでしょうか。

RSCの役割やレンダリングプロセスをイメージ・理解することで、RSCを適切に使いこなし、RSCによるメリットを最大限享受できるのではないかなと思います。

参考情報

https://nextjs.org/docs/getting-started/react-essentials
https://jser.dev/react/2023/04/20/how-do-react-server-components-work-internally-in-react/


Discussion