Closed54

React Server Componentを自分の言葉で説明できるようにする

てずかてずか

【前提】

  • SSR(Server Side Rendering)
  • RSC(React Server COmponents)

の違いが曖昧。どちらもサーバーでJSX => HTMLにするんじゃないの?くらいの知識

てずかてずか

https://github.com/reactwg/server-components/discussions/5

読んだ

  • JSX(e.g. <HogeConponent />)
  • JSXによって生成されるツリー構造を持つオブジェクト
{
  $$typeof: Symbol.for("react.element"),
  type: 'html',
  props: {
    children: [
      {
        $$typeof: Symbol.for("react.element"),
        type: 'head',
        props: {

...
  • HTML

の3要素がキーワードとして挙げられていた

てずかてずか

文章の結論を見る感じ

RSCサーバー

  • JSX => JSXツリーへの変換

SSRサーバー

  • JSXツリー => HTMLへの変換

のように解釈できそうだったが、まだしっくりきていないので引き続き記事を読む

てずかてずか

Client Side Rendering

  • 空のHTMLをサーバーから返し、クライアント(ブラウザ上でReactが動作し、HTMLを組み立てる)

Server Side Rendering

  • サーバー上でReactが動作し、クライアントにHTMLを返し、そのあとでイベントハンドラを登録したりの処理がクライアント上で行われる(Hydration)
てずかてずか

SSRで、

  • サーバーでHTMLが構築される
  • クライアントから情報取得のためにもう一度サーバーに通信を走らせる

つまり

クライアント(ページリクエスト)
↓
サーバー(HTML構築)
↓
クライアント(表示するデータリクエスト)
↓
サーバー(DBなどからデータ取得)
↓
クライアント(ページ完成)

この流れ、馬鹿馬鹿しくないか?と。

てずかてずか
  • データの取得
  • HTMLの構築
    この両方を1回のリクエストでやって仕舞えばいいのではないか。

でもReactだけでは厳しかった(なんで無理だったのかは調査不足)

代わりに、Next.jsGatsbyなどのエコシステムがgetServerSidePropsなどの解決策を生み出した

てずかてずか

Next.jsで考える

getServerSideProps => Reactによるレンダリング

のように、フレームワークによってReact実行の順序を制御することで無駄なリクエストを減らそうとしたということだと解釈

てずかてずか

ただ、この方法にもいくつか問題点があり、Next.jsは『Reactをどう使うか』しか考慮できない。

つまり、コンポーネントの中でデータフェッチしたりすることは、Next.jsからしたら不可能。ゆえに、最上位のルートレベルで取得したデータをバケツリレーしたりする必要があった。

また、Reactは不要な場合にもクライアント上で常にハイドレーションを行う

てずかてずか

そんな中Reactチームが出した解決方法がReact Server Components

import db from 'imaginary-db';
async function Homepage() {
  const link = db.connect('localhost', 'root', 'passw0rd');
  const data = await db.query(link, 'SELECT * FROM products');
  return (
    <>
      <h1>Trending Products</h1>
      {data.map((item) => (
        <article key={item.id}>
          <h2>{item.title}</h2>
          <p>{item.description}</p>
        </article>
      ))}
    </>
  );
}
export default Homepage;

こんなことが可能になる

てずかてずか

サーバー・コンポーネント自体は驚くほど単純ですが、「React Server Components」パラダイムは非常に複雑です。その理由は、通常の古いコンポーネントがまだ残っていて、それらを組み合わせる方法が非常に入り組んだものである可能性があるためです。

この新しいパラダイムでは、私たちがよく知っている「従来の」Reactコンポーネントはクライアント・コンポーネント(Client Component)と呼ばれています。率直に言って、私はこの名称が好きではありません。😅

確かに、旧コンポーネントと新コンポーネントくらいの名称がいいのかな?

クライアントコンポーネントっていう呼び方だと、クライアントコンポーネントがサーバーでレンダリングされるっていう、言葉にしたら不思議なことが起こってしまう

てずかてずか

ただ、新コンポーネント(サーバーコンポーネント)は絶対にサーバーでレンダリングされるのかな?

てずかてずか

ただ、新コンポーネント(サーバーコンポーネント)は絶対にサーバーでレンダリングされるのかな?

この理解でよさそう

てずかてずか

この新しいパラダイムでは、サーバー・コンポーネントという新しいタイプのコンポーネントが導入されました。この新しいコンポーネントはサーバー上でのみレンダリングされます。サーバー・コンポーネントのコードは、JSバンドルに含まれていないため、ハイドレーションや再レンダリングが行われることはありません。

ハイドレーションが行われないってどういうことだ?

export const Button = () => {
  const onClick = () => { console.log('ほげ') };
  return (
    <button onClick={onClick} >hoge<?button>
  )
}

これみたいなコンポーネントがクリックできるようになるのがハイドレーションなのかな?って思ってた

てずかてずか

要するに

onsole.log('ほげ')

少なくともこのコードはJSバンドルに含まれていないとダメじゃない?ってこと

てずかてずか

一旦今日はここまで、ここから先さらに深いことが書いてありそう

てずかてずか

Reactチームは次のルールを追加しました。クライアント・コンポーネントがインポートできるのは他のクライアント・コンポーネントのみである。

'use client'ディレクティブをArticleコンポーネントに追加すると、「クライアント・バウンダリ」が作成されます。このバウンダリ内のコンポーネントはすべて、暗黙的にクライアント・コンポーネントに変換されます。

親コンポーネントに'use client'を指定して、クライアントコンポーネントであることを宣言したら、配下のコンポーネントも自動的にクライアントコンポーネントになるってことかな

てずかてずか

アプリケーションの上の方でstateを使用しなければならない場合、どうすればよいのでしょうか?それは、すべてがクライアント・コンポーネントになる必要があるということでしょうか??

こう思っちゃうよなぁ。まだ理解できてない。今のところ、一番親で'use client'書いたら全部クライアントでレンダリングされるって思ってる

てずかてずか

多くの場合において、アプリケーションを再構築して、レンダリング元となるコンポーネントを工夫することで、この制約を回避できることが分かりました。

ほう。

てずかてずか
'use client';
import { DARK_COLORS, LIGHT_COLORS } from '@/constants.js';
import Header from './Header';
import MainContent from './MainContent';
function Homepage() {
  const [colorTheme, setColorTheme] = React.useState('light');
  const colorVariables = colorTheme === 'light'
    ? LIGHT_COLORS
    : DARK_COLORS;
  return (
    <body style={colorVariables}>
      <Header />
      <MainContent />
    </body>
  );
}

ダークモードとライトモードを切り替えるような場合を考える。モードをuseStateで状態管理している。
この場合

  • useStateを使っている(ユーザー操作により、クライアントで再度レンダリングされる必要がある)

から、use client必須

てずかてずか

一番上のコンポーネントで'use client'したぞ...どうなる...

てずかてずか
/components/ColorProvider.js
'use client';
import { DARK_COLORS, LIGHT_COLORS } from '@/constants.js';
function ColorProvider({ children }) {
  const [colorTheme, setColorTheme] = React.useState('light');
  const colorVariables = colorTheme === 'light'
    ? LIGHT_COLORS
    : DARK_COLORS;
  return (
    <body style={colorVariables}>
      {children}
    </body>
  );
}

こんな感じで'use client'を使っているコンポーネントを分割

/components/Homepage.js
import Header from './Header';
import MainContent from './MainContent';
import ColorProvider from './ColorProvider';
function Homepage() {
  return (
    <ColorProvider>
      <Header />
      <MainContent />
    </ColorProvider>
  );
}

こうやって使う

てずかてずか

確かに'use client'が書かれているファイルは分割できたが、親子関係はそのままだぜ

てずかてずか

しかし、ちょっと待ってください。クライアント・コンポーネントであるColorProviderは、HeaderとMainContentの親です。いずれにせよ、まだツリーでは上位にありますね。

てずかてずか

ただし、クライアント・バウンダリに関しては、親子関係は重要ではありません。Homepageは、HeaderとMainContentをインポートし、レンダリングするものです。これは、Homepageが、これらのコンポーネントのpropsが何であるかを決定することを意味します

ほう

てずかてずか

覚えておいてください。私たちが解決しようとしている問題は、サーバー・コンポーネントは再レンダリングができないため、どのpropsにも新しい値を与えることができないということです。この新しい設定では、Homepageが、HeaderとMainContentのpropsを決定し、またHomepageはサーバー・コンポーネントであることから、問題はありません。

これは頭を悩ませる事柄です。何年もReactを経験した後の今でも、このことはきわめて分かりにくいと思っています😅。これに対する直感を養うためには、かなりの練習を必要としました。

ふむ...なんとなく言いたいことはわかるが

てずかてずか

クライアントで再レンダリングされ得るものは、'use client'じゃないと動作しない。

  • わかる
  • そりゃそう

クライアントで再レンダリングされないものは、サーバーでレンダリングされる(サーバーコンポーネント)

  • ほう

親子関係は関係なく、単純にファイル単位(モジュール単位)で決まる

  • こういうことか?
てずかてずか

内部で何が起きているのか

function Homepage() {
  return (
    <p>
      Hello world!
    </p>
  );
}

超シンプルなアプリケーション

てずかてずか

ブラウザでアクセスすると以下のHTMドキュメントを受け取る

<!DOCTYPE html>
<html>
  <body>
    <p>Hello world!</p>
    <script src="/static/js/bundle.js"></script>
    <script>
      self.__next['$Homepage-1'] = {
        type: 'p',
        props: null,
        children: "Hello world!",
      };
    </script>
  </body>
</html>
てずかてずか

分かりやすくするため、ここであえて再構成を実施しました。たとえば、RSCコンテキストで生成された真のJSは、このHTMLドキュメントのファイル・サイズを削減するための最適化として、文字列化されたJSON配列を使用します。

また、HTMLの重要でない部分(<head>など)もすべて削除しました。

なるほど。この文字列化されたJSON配列ってのは、前の記事の『JSXによって生成されるツリー構造のオブジェクト』ってやつか?

てずかてずか

HTMLドキュメントには、Reactアプリケーションによって生成されたUI、「Hello world!」パラグラフが含まれていることが分かります。これはサーバー・サイド・レンダリングのおかげであり、React Server Componentsに直接起因するものではありません。

あ"??

てずかてずか

最後に、いくつかのインラインJSを含む2番目の<script>タグがあります。

self.__next['$Homepage-1'] = {
  type: 'p',
  props: null,
  children: "Hello world!",
};
てずかてずか

通常、Reactがクライアント上でハイドレーションを行うと、すべてのコンポーネントが素早くレンダリングされ、アプリケーションの仮想DOMが構築されます。サーバー・コンポーネントの場合、コードがJSバンドルに含まれていないため、これを行うことができません。

ここでいう通常っていうのは、クライアントサイドでReactがレンダリングを行う場合のことだよな。

サーバー・コンポーネントの場合はサーバーからのレスポンスがjsじゃなくてHTMLだからレンダリングできないと

てずかてずか

したがって、レンダリングされた値と共に、サーバーによって生成された仮想DOMを送信します。Reactがクライアントにロードされると、そのディスクリプションを再生成する代わりに再利用します。

RSCはサーバーから

  • レンダリングされた値(HTML)
  • 仮想DOM

を送信し、、、

そのディスクリプションを再生成する代わりに再利用します。

これは何をしているの?

てずかてずか

サーバーでレンダリングされたHTMLに対して、サーバーで生成された仮想DOMを用いてハイドレーションするってこと?

てずかてずか

サーバー・コンポーネントはサーバーを必要としない

  • 静的:HTMLは、展開プロセス中に、アプリケーションのビルド時に生成されます。
  • 動的:ユーザーがページをリクエストすると、HTMLは「オンデマンド」で生成されます。

React Server Componentsは、これらのレンダリング戦略のいずれとも互換性があります。

てずかてずか

サーバーの環境でJSXで書かれたコンポーネントを

  • HTML
  • ハイドレーション用のjavascript

に変換するのがReact Server Componentなのか?

で、RSCのこの変換処理はいつ行われてもいい(リクエスト時でもビルド時でも)

ビルド時に行うとすると、JSXで書かれたコードを完全に静的な状態でリクエストを待機できる

てずかてずか

RSCは、このツリーを構成する一部のコンポーネントがサーバによってレンダリングされ、他のコンポーネントがブラウザによってレンダリングされることを可能にします。🤯

てずかてずか

SSRとRSCって同じことしてない?って思っちゃうんよなぁ

てずかてずか

最初のルートサーバコンポーネントをHTMLのタグとクライアントコンポーネントのプレースホルダで構成されるツリーとしてレンダリングすることです。 次に、そのツリーをシリアライズしてブラウザに送ります。 ブラウザ側では、受け取ったデータをデシリアライズし、プレースホルダを実際のクライアントコンポーネントに置き換え、最終結果をレンダリングします。

結局RCSは

  • HTML
  • サーバーでレンダリングできなかったコンポーネントをレンダリングするためのjs

をクライアントに送りつけるんだな

てずかてずか

クライアントレンダリングわかりやすすぎて安心するな

てずかてずか

Page RouterのSSRのレスポンス

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charSet="utf-8"/>
        <meta name="viewport" content="width=device-width"/>
        <meta name="next-head-count" content="2"/>
        <noscript data-n-css=""></noscript>
        <script defer="" crossorigin="" nomodule="" src="/_next/static/chunks/polyfills-c67a75d1b6f99dc8.js"></script>
        <script src="/_next/static/chunks/webpack-4e7214a60fad8e88.js" defer="" crossorigin=""></script>
        <script src="/_next/static/chunks/framework-5429a50ba5373c56.js" defer="" crossorigin=""></script>
        <script src="/_next/static/chunks/main-cb086c1786f15058.js" defer="" crossorigin=""></script>
        <script src="/_next/static/chunks/pages/_app-b8840b4f8f2fad1f.js" defer="" crossorigin=""></script>
        <script src="/_next/static/chunks/pages/index-3dedb261939be730.js" defer="" crossorigin=""></script>
        <script src="/_next/static/mnu6FneGf2rg-_xRnbrTq/_buildManifest.js" defer="" crossorigin=""></script>
        <script src="/_next/static/mnu6FneGf2rg-_xRnbrTq/_ssgManifest.js" defer="" crossorigin=""></script>
    </head>
    <body>
        <div id="__next">
            <h1>Hello,World</h1>
        </div>
        <script id="__NEXT_DATA__" type="application/json" crossorigin="">
            {
                "props": {
                    "pageProps": {
                    }
                },
                "page": "/",
                "query": {
                },
                "buildId": "mnu6FneGf2rg-_xRnbrTq",
                "nextExport": true,
                "autoExport": true,
                "isFallback": false,
                "scriptLoader": [
                ]
            }</script>
    </body>
</html>

抜粋したjs

function(n, u, t) {
        "use strict";
        t.r(u),
        t.d(u, {
            default: function() {
                return e
            }
        });
        var r = t(5893);
        function e() {
            return (0,
            r.jsx)("h1", {
                children: "Hello,World"
            })
        }
    }
てずかてずか

Page RouterのSSRでは

  • HTML
  • コンポーネント情報を含むjs

の両方がレスポンスの中に入ってる

てずかてずか

まずRSC => RSC Payloadを生成する

RSC Payloadには以下の内容が含まれます。

  • SCのレンダリング結果
  • CCをレンダリングする場所のplaceholderとJavaScriptファイルへの参照
  • SCからCCに渡されたprops

SCのレンダリング結果はHTMLのことかな

てずかてずか

Next.jsはザックリ以下のような流れでSCとCCをレンダリングします。

サーバーサイド

  1. SCからRSC Payloadをレンダリングする
  2. RSC PayloadとCCから初期表示のためのHTMLをレンダリングする(SSR)

クライアントサイド

  1. 1-2で生成されたHTMLを元にすぐに非インタラクティブな画面を表示する
  2. RSC Payloadを元にSCとCCをツリー上で紐づけ、レンダリングする
  3. JavaScriptをhydrateし、CCをインタラクティブにする
てずかてずか

個人的結論

RSCとは

単純にReactコンポーネントを2段階に分けて、HTMLにレンダリングするだけの仕組み

サーバーでの処理

  1. サーバーサイドでレンダリングできる静的なHTMLを出力(ブラウザでハイドレーションする必要がないコンポーネント)。この出力には、HTMLの他に、クライアントコンポーネントがどこにレンダリングされるかや、どのようにレンダリングされるかの情報も含む。この出力を2段階目の計算に渡す

クライアントでの処理
2. 残りのコンポーネントをHTMLにレンダリング & ハイドレーションでインタラクティブにする

以上

SSRとは

個人的にPage RouterとApp Routerでは少しSSRの処理内容が変わっているように思える

SSR (Page Router)

  1. クライアントからリクエストを受ける
  2. Reactを走らせて全部HTMLにレンダリング、ハイドレーション用のJSも生成
  3. クライアントに送信
  4. クライアントでハイドレーション

SSR (App Router)

  1. クライアントからリクエストを受ける
  2. React(RSC)を走らせてサーバーコンポーネントをHTMLにレンダリング、クライアントコンポーネントをレンダリングするための情報も併せて生成
  3. クライアントに送信
  4. クライアントでクライアントコンポーネントをHTMLにレンダリング & ハイドレーション

こういう理解であってるんじゃないか?

このスクラップは4ヶ月前にクローズされました