[WIP] React Server componentのレンダリングの仕組み
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>
)
}
参考情報
※長くなりそうだから続きは分割する
レンダリングのライフサイクル
1. サーバがレンダリングのリクエストを受信
サーバがレンダリングの一端を担うため、レンダリングの開始もサーバから始まる。
故に、ルートコンポーネントもサーバーコンポーネントということになる。
※説明のための便宜上、「ルートコンポーネント」という単語を使用させていただきました。
サーバーは、リクエストで渡された情報に基づいて、どのサーバーコンポーネントとどのプロップを使用するかを決定する。
このリクエストは通常、特定のURLのページリクエストという形式になる。
2.サーバーがルートコンポーネント要素をJSONにシリアライズする
(サーバーコンポーネントを含めた)コンポーネントのレンダリングするまでの流れは以下の通りです。
- 最初のルートサーバーコンポーネントを、基本的なhtmlタグとクライアントコンポーネントの「プレースホルダー」のツリーにレンダリングする
- 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フィールドには
- サーバーコンポーネントの場合
- 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をもつ要素だけを取得するため。
※完全に長くなったので次
3.ブラウザがReactツリーを再構築する
ブラウザはサーバーからJSON出力を受け取り、ブラウザでレンダリングするためにReactツリーの再構築を開始する必要がある。
typeがモジュール参照である要素に遭遇したら、それを実際のクライアント・コンポーネント関数への参照に置換する。
バンドラによってモジュール参照オブジェクトがクライアントのコンポーネントに置換され、Reactツリーが再構築されると、すでにサーバーコンポーネントはただのHTMLタグになっており、クライアントコンポーネントと入り混じっている状態になる。
最後はこのツリーを通常通りレンダリングしてDOMにコミットするだけ。
以上がRSCのレンダリングプロセスです。