8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SSRとReact Server Components の違いをレスポンスから考える

Posted at

概要

Next.js 13からApp Routerが追加され、それはReact Server Components(以下RSC)と呼ばれる機能の使用に適した設計がされています。RSCはサーバー側でレンダリングをしてバンドルサイズを小さくする、サーバー側でネットワーク呼び出しを完結させるなどのメリットがあります。そこで気になったのが、SSRもサーバー側でレンダリングする機能の一つで、RSCと何が違うということです。

この違いは既に多くの記事で言及されています。本記事では、よりイメージを明確にするため実際の生成物を調べながら違いを考えてみます。特に、レスポンスされるHTMLとJavaScript内のコンポーネント情報の2つの観点に注目します。

この記事の目的

  • SSRとRSCのレンダリング結果の違いを実際の生成物を元に知る
  • Next.jsがSSRとRSCをどのように組み合わせているのかを知る

環境

  • React:18
  • Next.js:14

Client Side Rendering

まずは本編の説明をする前に、基本的なクライアントでレンダリングをする場合について考えます。
ViteでReactの環境を構築し、主な実装は以下としました。

main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
App.tsx
const App = () => {
  return <h1>Hello,World</h1>;
};

export default App;
index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React + TS</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

CSRでのレンダリングの流れは

  1. index.htmlが読み込まれ、main.tsxを取得
  2. main.tsxがroot要素に対してcreateRootを実行し、JSXをレンダリングする
  3. ブラウザのDOMとReactが同期する

となっています。

レスポンスを確認するためにビルド後、HTMLとJavaScript内のコンポーネント情報を開発者ツールから確認すると次のようになりました。

レスポンスされたHTML
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React + TS</title>
    <script type="module" crossorigin src="/assets/index-DOTpxGIX.js"></script>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

/assets/index-DOTpxGIX.jsはminifyされているため、解読が困難でした。その中で一部重要な箇所を抜粋します。

/assets/index-DOTpxGIX.js
// ...
const Rd = ()=>Hl.jsx("h1", {
    children: "Hello,World"
});
Wl.createRoot(document.getElementById("root")).render(Hl.jsx(wc.StrictMode, {
    children: Hl.jsx(Rd, {})
}));

ここでまず重要なのは、レスポンスされたHTMLです。内容がほぼindex.htmlと同じであり、root要素はReactがレンダリングする前なのでAppコンポーネントの中身がまだありません。これが後々のSSRとの違いとなります。
また、変数名が異なりますがAppコンポーネントの情報もRdとしてクライアント側に渡されています。Rdh1のJSXを返す関数なので、Appコンポーネントの情報がJavaScriptのままレスポンスされています。この特徴は、後々にRSCとの違いになります。

SSR (Page Router)

次に従来のPage RouterでSSRを試してみます。実装はNext.jsで次のようにしました。

_document.tsx
import { Html, Head, Main, NextScript } from "next/document";

export default function Document() {
  return (
    <Html lang="en">
      <Head />
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}
_app.tsx
import type { AppProps } from "next/app";

export default function App({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />;
}
index.tsx
export default function Home() {
  return <h1>Hello,World</h1>;
}

同様に開発者ツールでレスポンスを確認した結果がこちらです。

レスポンスされたHTML
<!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>

コンポーネント抜粋 (index-3dedb261939be730.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"
            })
        }
    }

まずHTMLですが、Appコンポーネントがレンダリングされた状態となっています。これはCSRとは明確に違くて、クライアント側でReactと同期をせずともコンポーネントを初期表示で見ることができます。SEOの文脈で意識される箇所ですね。
また、クライアント側でロードされるJavaScriptの中にAppコンポーネントと思われる情報が一緒にレスポンスされていました。つまりSSR単体では、サーバー側でHTMLをレンダリングしつつも、レンダリングしたコンポーネント情報を別途レスポンスしています。
この挙動は、クライアント側でReactと同期するためにコンポーネント情報が必要であるからです。Next.jsのドキュメントには以下の記載がありました。

Each generated HTML is associated with minimal JavaScript code necessary for that page. When a page is loaded by the browser, its JavaScript code runs and makes the page fully interactive (this process is called hydration in React).

このように、既に生成されたHTMLとReactがコンポーネント情報を元に同期することをハイドレーションと呼びます。ReactからAPIが生えていて、ルートのDOMとReactのコンポーネントを指定して使うみたいです。

hydrateRootの使用例
const domNode = document.getElementById('root');
const root = hydrateRoot(domNode, reactNode);

SSRの結果をまとめると、サーバーでHTMLをレンダリング、そして全てのコンポーネント情報もレスポンスしてクライアントでハイドレーションをするようです。

SSRとRSCの出力の違い

最後にRSCの挙動を見ていきたいところですが、先にSSRとRSCの出力の違いについて説明をします。
SSRは前の章で見た通り、サーバー側で最終的なレンダリング結果を反映したHTMLを出力しています。コンポーネント情報はCSRの場合と変わらずに、クライアント側でのハイドレーションをするためにレスポンスしています。
RSCでは、サーバー側でServer ComponentからRSC Payloadと呼ばれるデータフォーマットを出力します。このデータとClient Componentを組み合わせて、SSRやハイドレーションを実現しています。
なので、SSRとRSCでは出力するデータそのものが違います。Next.jsがこの2つの技術をどのように組み合わせているのかは、ドキュメントに記載されていました。

server side

  1. React renders Server Components into a special data format called the React Server Component Payload (RSC Payload)
  2. Next.js uses the RSC Payload and Client Component JavaScript instructions to render HTML on the server

client side

  1. The HTML is used to immediately show a fast non-interactive preview of the route - this is for the initial page load only
  2. The React Server Components Payload is used to reconcile the Client and Server Component trees, and update the DOM
  3. The JavaScript instructions are used to hydrate Client Components and make the application interactive

つまりServer Componentが生成したRSC Payloadは、サーバー側ではSSR、クライアント側ではハイドレーションのために活用しています。このことを頭の片隅に置いて、生成物の違いを見ていきます。

SSR + RSC (App Router)

Next.jsのApp RouterでSSR + RSCのレスポンスを確認していきます。App RouterでRSC単体を試す方法がわからなかったため、以下の方法でRSCの挙動を確認しています。

  • HomeコンポーネントをServer Component、ButtonコンポーネントをClient Componentとしてレスポンスを比較する

App RouterはデフォルトでServer Componentなので、"use client"を冒頭に記載して従来と同じコンポーネントにしています。実装は次のようにしました。

layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
page.tsx
import Button from "@/components/Button";

export default function Home() {
  return (
    <div>
      <h1>Hello,World</h1>
      <Button></Button>
    </div>
  );
}
Button.tsx
"use client";

const Button = () => {
  return <button>Button</button>;
};

export default Button;

同様にビルドしてレスポンスを確認します。

レスポンスされたHTML
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charSet="utf-8"/>
        <meta name="viewport" content="width=device-width, initial-scale=1"/>
        <link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-bf1a64d1eafd2816.js" crossorigin=""/>
        <script src="/_next/static/chunks/fd9d1056-c7082c319cc53ced.js" async="" crossorigin=""></script>
        <script src="/_next/static/chunks/69-2b08058fa84857fe.js" async="" crossorigin=""></script>
        <script src="/_next/static/chunks/main-app-0c897075487cd8a1.js" async="" crossorigin=""></script>
        <script src="/_next/static/chunks/app/page-c268b5337bdbc279.js" async=""></script>
        <title>Create Next App</title>
        <meta name="description" content="Generated by create next app"/>
        <link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="16x16"/>
        <script src="/_next/static/chunks/polyfills-c67a75d1b6f99dc8.js" crossorigin="" noModule=""></script>
    </head>
    <body>
        <div>
            <h1>Hello,World</h1>
            <button>Button</button>
        </div>
        <script src="/_next/static/chunks/webpack-bf1a64d1eafd2816.js" crossorigin="" async=""></script>
        <script>
            (self.__next_f = self.__next_f || []).push([0]);
            self.__next_f.push([2, null])
        </script>
        <script>
            self.__next_f.push([1, "0:\"$L1\"\n"])
        </script>
        <script>
            self.__next_f.push([1, "2:I[7690,[],\"\"]\n4:I[8447,[\"931\",\"static/chunks/app/page-c268b5337bdbc279.js\"],\"\"]\n5:I[5613,[],\"\"]\n6:I[1778,[],\"\"]\n8:I[8955,[],\"\"]\n9:[]\n"])
        </script>
        <script>
            self.__next_f.push([1, "1:[null,[\"$\",\"$L2\",null,{\"buildId\":\"UZJ0Qz41Ygvw5Brwwrt79\",\"assetPrefix\":\"\",\"initialCanonicalUrl\":\"/\",\"initialTree\":[\"\",{\"children\":[\"__PAGE__\",{}]},\"$undefined\",\"$undefined\",true],\"initialSeedData\":[\"\",{\"children\":[\"__PAGE__\",{},[\"$L3\",[\"$\",\"div\",null,{\"children\":[[\"$\",\"h1\",null,{\"children\":\"Hello,World\"}],[\"$\",\"$L4\",null,{}]]}],null]]},[null,[\"$\",\"html\",null,{\"lang\":\"en\",\"children\":[\"$\",\"body\",null,{\"children\":[\"$\",\"$L5\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\"],\"loading\":\"$undefined\",\"loadingStyles\":\"$undefined\",\"loadingScripts\":\"$undefined\",\"hasLoading\":false,\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L6\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":\"404\"}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],\"notFoundStyles\":[],\"styles\":null}]}]}],null]],\"initialHead\":[false,\"$L7\"],\"globalErrorComponent\":\"$8\",\"missingSlots\":\"$W9\"}]]\n"])
        </script>
        <script>
            self.__next_f.push([1, "7:[[\"$\",\"meta\",\"0\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}],[\"$\",\"meta\",\"1\",{\"charSet\":\"utf-8\"}],[\"$\",\"title\",\"2\",{\"children\":\"Create Next App\"}],[\"$\",\"meta\",\"3\",{\"name\":\"description\",\"content\":\"Generated by create next app\"}],[\"$\",\"link\",\"4\",{\"rel\":\"icon\",\"href\":\"/favicon.ico\",\"type\":\"image/x-icon\",\"sizes\":\"16x16\"}]]\n3:null\n"])
        </script>
        <script>
            self.__next_f.push([1, ""])
        </script>
    </body>
</html>
コンポーネント抜粋 (page-c268b5337bdbc279.js)
(self.webpackChunk_N_E = self.webpackChunk_N_E || []).push([[931], {
    5045: function(n, t, e) {
        Promise.resolve().then(e.bind(e, 8447))
    },
    8447: function(n, t, e) {
        "use strict";
        e.r(t);
        var u = e(7437);
        t.default = ()=>(0,
        u.jsx)("button", {
            children: "Button"
        })
    }
}, function(n) {
    n.O(0, [971, 69, 744], function() {
        return n(n.s = 5045)
    }),
    _N_E = n.O()
}
]);

まずHTMLはサーバー側で既にレンダリングが行われており、レスポンス内容にh1button要素が含まれていることがわかります。SSR単体と同じ挙動をしています。前の説明から考えると、このHTMLはRSC Payloadを元に作成されています。逆に言えば、RSC単体だとCSRと変わらないHTMLがレスポンスされると予想されます。RSCとSSRの役割の違いが明確ですね。

またNext.jsが最適化を行い非常に見づらいですが、レスポンスされたJavaScript内にあるコンポーネント情報を抜粋します。t.defaultに代入しているのが、Buttonコンポーネントでありそうです。しかしHomeコンポーネントは、レスポンスされたJavaScript内でHello,Worldをキーワードに検索をかけたのですが見当たりませんでした。ではどこに隠れているのか?HTMLの末尾に謎のデータが付与されていますが、そこに展開されています。この謎のデータこそがRSC Payloadです。

実際に返されたPayloadの中に、h1要素が展開されている箇所がありました。

"children": [
    "__PAGE__",
    {},
    [
      "$L3",
      [
        "$",
        "div",
        null,
        {
          "children": [
            ["$", "h1", null, { "children": "Hello,World" }],
            ["$", "$L4", null, {}]
          ]
        }
      ],
      null
    ]
  ]

ここにはHomeコンポーネントの情報が無く、完全に展開された状態になっています。SSRではこのような展開をすることはないので、違いの一つでありそうです。

その直下には$L4という変数があります。これは恐らくJavaScriptにあるButtonコンポーネントへの参照でしょう。このようにして、Server ComponentのRSC Payloadと、Client ComponentであるJavsScript内のコンポーネント情報を合わせてハイドレーションを実現しているみたいです。

まとめると、Server Componentに指定されたコンポーネントは、JavaScript内から情報が消えて、構造がRSC Payloadとして保たれるみたいです。RSCの特徴でZero-Bundle-Sizeがあるのは、この結果が元になっていそうです。

終わりに

初めてRSCについて学んだ時に、SSRもサーバー側でレンダリングをするけどRSCと何が違うんだ?と疑問に思いました。
この疑問を実際の生成物や実行の流れから調べることで、より理解を深めることができたと思います。
この記事が同じように悩んでいた方の解決に役立てたら幸いです。
最後まで読んでいただきありがとうございました。

参考

8
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?