Open4

[WIP] React Server componentのレンダリングの仕組み

msy.msy.

React Server Componentはサーバサイドレンダリングではない

React Server Component is not SSR
SSRはReactツリーを生のhtmlにレンダリングする環境をシミュレートするもので、サーバーとクライアントのコンポーネントを区別することなく、同じようにレンダリングする。

SSRとRSCの両方を組み合わせることで、サーバーコンポーネントでサーバーサイドレンダリングを行い、ブラウザで適切にハイドレーションすることは可能。
→ は????すごない???

React Server Componentの最大の活用方法

Server ComponentとClient Componentとの上手な付き合い方は、使い分け
サーバーができることを前もってやっておき、残りをブラウザに任せること。

レンダリングのイメージ

サーバーは、サーバーコンポーネントを通常通り「レンダリング」し、Reactコンポーネントをdivや pといったネイティブなhtml要素に変換する。
しかし、ブラウザでレンダリングされる予定の「クライアント」コンポーネントに遭遇すると、代わりにプレースホルダーを出力し、この穴を正しいクライアントコンポーネントとプロップで埋めるよう指示する。

そして、ブラウザがその出力を受け取り、クライアント・コンポーネントでその穴を埋める。

→このイメージを持っておく。

分割

Reactでは、ファイルの末尾が*.server.jsxであれば、サーバーコンポーネントが含まれ、*.client.jsxであれば、クライアントコンポーネントが含まれる。

サーバーコンポーネントをクライアントコンポーネントから呼び出すことはできない。
クライアント(ex: ブラウザ)上で、本来サーバで行うべき処理を実行することは不可能だから。

→では以下のようなサーバコンポーネントとクライアントコンポーネントが複雑に入り混じったDOMツリーはどのように実現されるのか。


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

共存のヒントはコンポジション

クライアントコンポーネントからサーバーコンポーネントをインポートしてレンダリングすることはできませんが、コンポジションを使用することはできます。

コンポジションのサンプルコード

// ClientComponent.client.jsx
export default function ClientComponent({ children }) {
  return (
    <div>
      <h1>Hello from client land</h1>
      {children}
    </div>
  )
}

// ServerComponent.server.jsx
export default function ServerComponent() {
  return <span>Hello from server land</span>
}

// OuterServerComponent.server.jsx
// OuterServerComponentはクライアントとサーバーの両方をインスタンス化できる
// コンポーネントとして、<ServerComponent/>を渡しています。
// ClientComponentのchildren propを指定します。
import ClientComponent from './ClientComponent.client'
import ServerComponent from './ServerComponent.server'
export default function OuterServerComponent() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  )
}

msy.msy.

※長くなりそうだから続きは分割する

レンダリングのライフサイクル

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

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

※説明のための便宜上、「ルートコンポーネント」という単語を使用させていただきました。

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

2.サーバーがルートコンポーネント要素をJSONにシリアライズする

(サーバーコンポーネントを含めた)コンポーネントのレンダリングするまでの流れは以下の通りです。

  1. 最初のルートサーバーコンポーネントを、基本的なhtmlタグとクライアントコンポーネントの「プレースホルダー」のツリーにレンダリングする
  2. 1.のツリーをシリアライズしてブラウザに送信すると、ブラウザはこれをデシリアライズして、クライアントのプレースホルダに実際のクライアントコンポーネントを充填し、最終結果をレンダリングする
    ※プレースホルダーは「虫食い」みたいなイメージが近いかと

「コンポーネントをシリアライズ」というと下記のようなものを想像するかもしれない。

JSON.stringify(<Component />)

→ 実際には下記のようなcreateElementによって生成されたオブジェクトをシリアライズする

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

// React element for <MyComponent>oh my</MyComponent>
> function MyComponent({children}) {
    return <div>{children}</div>;
  }
> React.createElement(MyComponent, { children: "oh my" });
{
  $$typeof: Symbol(react.element),
  type: MyComponent  // reference to the MyComponent function
  props: { children: "oh my" },
  ...
}

ただそのままJSON.stringifyしただけでは、HTMLのタグはシリアライズ可能ですが、コンポーネントとして定義した関数を指定した場合(type: "component")の場合は、適切なシリアライズが不可能。
→ 関数はシリアラズできないため。

全ての要素をJSON文字列に適切に変換するためにはresolveModelToJSONという置換関数を用いて、置換した結果をJSON.stringifyに渡す。(らしい)

具体的にどんな処理を行うのか????
素の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" },
}

上記の「モジュール参照オブジェクトのシリアライズ」はどこでどのように処理されてるのか?
※サーバーコンポーネントの場合は、コンポーネント関数の参照であるためreplacer関数によってシリアライズ可能にする
JSON.stringify replacer

モジュール参照オブジェクトのシリアライズはどこで実行されているか

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

サーバーコンポーネントが*.client.jsxファイルから何かをインポートするとき、実際にその実態を取得するのではなく、代わりにそのもののファイル名とエクスポート名を含む、モジュール参照オブジェクトを取得するだけとなる。
※クライアントコンポーネントの関数が、サーバー上で構築されたReactツリーの一部になることない。

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

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

つまり呼び出すコンポーネント関数だけではなく、その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.

もしこれを実現したかったら、エラーメッセージも記載されているように一度クライアントコンポーネントを挟む必要がある。
※RSCのシリアライズのプロセスでクライアントコンポーネントに遭遇してもその中身まで見ず、モジュール参照とpropsをもつ要素だけを取得するため。

msy.msy.

※完全に長くなったので次

3.ブラウザがReactツリーを再構築する

ブラウザはサーバーからJSON出力を受け取り、ブラウザでレンダリングするためにReactツリーの再構築を開始する必要がある。

typeがモジュール参照である要素に遭遇したら、それを実際のクライアント・コンポーネント関数への参照に置換する。

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

最後はこのツリーを通常通りレンダリングしてDOMにコミットするだけ。

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