【Next.js】RSCから見るレンダリングの変遷(MVC〜RSC)

2024/02/18に公開

はじめに

最近、「App Routerで開発すること多いけど、RSCを最大限活用できていないのでは?」と思ったので、MVCアーキテクチャからRSCにかけてのレンダリングの変遷を調べ、どのようにしたら、RSCにおける良い実装ができるかを学習したので、その内容をまとめたいと思います。

本記事では、MVC〜RSCの変遷を記述しているのですが、具体的には、以下の順で解説しています。

1.MVC
2.SPA
3.SSR
4.RSC

これらのレンダリングの変遷を見ていき、RSCでどのように実装したら、よりパフォーマンスの良いフロントエンドを開発できるかを解説しようと思います。

MVCアーキテクチャ

まず、MVCではどのようにレンダリングしていたかを解説します。
図にすると以下のようになるかと思います。

MVC Rendering

MVCでは以下の順で処理を行っていました。

1.クライアントからリクエスト
2.Routingに基づきControllerがリクエストを受け付け
3.必要に応じてModelにアクセスし、データの取得・更新などCRUDを行う
4.Modelから3の結果を受け取る
5.Controllerから必要なデータをViewに渡しをHTMLを生成
6.クライアントにHTMLを返却(ブラウザに表示(レンダリング))
7.クライアント(ブラウザ)でJavaScriptをロード
8.JavaScriptをハイドレーション

ハイドレーションとは・・・サーバー側でレンダリングされたHTMLに対してJavaScriptをクライアントで紐付ける作業のこと(HTMLに紐づいたJavaScriptを実行してイベントリスナなどインタラクティブな操作ができるようにする作業)

まとめると、サーバー側でMVCアーキテクチャに基づく様々な処理をしてHTMLを生成し、JavaScriptはブラウザ側で読み込み、ハイドレーションするというレンダリングをしていました。
(JavaScript以外はサーバーで行っていた)

ただ、MVCの場合、以下のような課題がでてきます。

  • HTML全体をページ遷移の度に読み込む必要がある

要するに、リクエストがあったページに関連するすべてのデータを取得し直し、HTMLも0から生成するということをしていました。

そこで、でてきたのがReactVueなどで構築されるSPAと呼ばれるアプリケーションです。

SPA

まず、かなり初歩的なのですが、SPAとはシングルページアプリケーションの略のことで、初期表示時にJavaScriptを含むHTML全体を読み込み、ユーザーの操作(リクエスト)に応じて動的にページを更新するWebアプリケーションのことです。

上記の定義の通りMVCに比べて、HTMLを0から生成することなく、更新のあった部分のみJavaScriptにより変更していく事ができるためMVCより高速な画面表示が可能となります。
また、画面遷移についてもユーザー操作(リクエスト)に基づき、JavaScriptで動的に画面を変更しているだけなので、高速に遷移しているように見せることができます。
(Reactの場合、上記の画面更新は仮想DOMで、画面遷移の挙動はreact-router-domというライブラリで実現しています)

https://ja.legacy.reactjs.org/docs/faq-internals.html

そして、このようなSPAによるレンダリングをCSR(クライアントサイドレンダリング)と呼びます。
CSRとはブラウザ上でJacaScriptを実行してDOMを生成し画面を表示させるレンダリング方法です。
CSR

以上がSPAによるレンダリングなのですが、SPAにも以下のような課題がありました。

  • 初期表示に時間がかかる

理由は、SPAは初期表示時にアプリケーション全体のJavaScriptを読み込み・ハイドレーションを行うため、初期表示に時間がかってしまうのです。
ここで、JavaScriptの容量(バンドルサイズ)が小さいと問題にはならないのですが、ECサイトなどページ数が多かったり、多数のライブラリを使用している大規模なアプリケーションなどバンドルサイズが比較的大きいアプリケーションの場合、顕著に上記の課題が浮き彫りになってしまうのです。

そのため、初期表示を高速化するために、何がでてきたのかというとSSRというわけです。

SSR

まず例によって初歩的な部分から解説すると、SSRとはサーバーサイドレンダリングのことをいいます。
どういう技術かを簡単にいうと、「HTMLだけサーバーでレンダリングして、とりあえず画面だけ見せてしまおう」という技術です。
SSR

これによりSPAの課題であった、初期表示が遅いという課題は解決できました。
ここで、「あれ?これってMVCのレンダリングと同じじゃない?」と感じた方もいると思います。
そういう方に向けて解説をすると、SSRというのはMVCのようなアーキテクチャではなく、レンダリング手法なので、Next.jsなどReactを軸にしたフレームワークで使われるという前提があります。
つまり、特定のアーキテクチャに依存しないという点が大きく違ってきます。
※ 注意点として、SSRでもReactなどのライブラリと併用しないとHTMLを0から生成することになります(仮想DOMを用いて画面の差分を検知してUIを更新するという処理はReactのものだからです)

これを踏まえると、ReactのフレームワークであるNext.jsなどのフレームワークはSPAとMVCのいいとこ取りをしたレンダリングができるということになります。

ただし、このSSRにも課題があります。

  • ページに必要なJavaScriptを一括で読み込みハイドレーションするため、インタラクティブな操作をするまでに時間がかかる可能性がある

「いやいや、当たり前でしょ」と思うかもしれませんが、以下の場面を考えてみてください。

  • ユーザーが「画面が表示されたことを認識し、ボタンをクリックしたが、反応しなかった。しばらくしてもう一度クリックしたら、今度は画面が動いた」

このように、JavaScriptがハイドレーションされていないと画面が動かない(HTMLとJavaScriptのハイドレーションにタイムラグがある)ため、ユーザーからしたら、「なんか1回目は動かなかったのに、2回目は動いた・・・不具合か?謎だ・・・」というようなことになりかねません。

そこででてきたのがRSC(React Server Component)です。
ただ、その前に、SPAでもSSRでも、RSCを使わずとも両者が抱える課題を解決する方法はあります。
それが自前でチャンク化するという方法です。

チャンク化

チャンク化とは、コードを複数の小さなファイル(チャンク)に分割することをいいます。
※私自身、SPAやSSR単体でチャンク化の実装をしたことがないので、設定値などの解説は割愛している部分がありますので、ご了承ください。
ここでは、一応課題解決はできるけど、設定や実装が煩雑で面倒なんだという認識を持てれば良いと考えております。

SPAにおけるチャンク化

SPAの場合、チャンク化により初期表示時に必要なJavaScriptだけ読み込んだり、ユーザーの操作(リクエスト)に応じて必要なチャンク(SPAの場合JSファイル)を読み込んだりすることができます。(その他のコードは必要に応じてあとから非同期にロードすることもできます)

そのため、適切にチャンク化ができれば、初期表示時にアプリケーション全体のJavaScriptを読み込むことなく、必要な分だけJavaScriptを読み込み、レンダリングができるため初期表示のローディング時間の短縮につながります。

ただし、これはReactなどのライブラリが自動で行うわけではなく、開発者自身で設定だったり、実装を行う必要があります。
チャンク化の設定はwebpackで行うことができます。
例えば、以下のようにすることで、非同期及び同期のimportを含むすべての種類のチャンク(JSファイル)に対してチャンク化(ファイル分割)を行うことができます。

webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
    },
  },
};

また、ユーザーのイベントに応じて動的importをすることで必要なときに必要な分のJavaScriptを読み込むようなチャンク化もできます。

// 例: ボタンクリック時に非同期でモジュールをロードする
button.addEventListener('click', () => {
  import('./module').then((module) => {
    // モジュールの使用
    module.doSomething();
  });
});

Reactの場合、Lazyを使用すれば、ルーティングと組み合わせて、ページ遷移時に該当ページのJavaScriptを読み込むこともできます。(これをしないと初期表示時に全ページのJavaScriptが読み込まれることになる。ただし、その場合、ページ遷移はLazyを使用するよりも高速)

App.tsx
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import React, { Suspense, lazy } from 'react';

const Home = lazy(() => import('./Home'));
const About = lazy(() => import('./About'));

function App() {
  return (
    <Router>
      <Suspense fallback={<div>Loading...</div>}>
        <Switch>
          <Route exact path="/" component={Home} />
          <Route path="/about" component={About} />
        </Switch>
      </Suspense>
    </Router>
  );
}

上記の場合、HomeページとAboutページに遷移したときにHomeコンポーネントとAboutコンポーネントがそれぞれ非同期にロードされることになります。

ただし、このような設定をするにあたり、コード量が増えるだけでなく、適切にチャンク化の設計をしないといけないので、アプリケーションの規模が大きくなるほど設定が煩雑になります。
また、webpackのチャンク化は自動で行われるため、最適なチャンクのサイズや数を決定するためには、自分自身で調整が必要となるなど、実装が難しくなる要素があります。(これはSSRのチャンク化についてもいえることです)

SSRにおけるチャンク化

SSRにおけるチャンク化はサーバーで生成されるHTMLに必要なJavaScriptのチャンクを適切に含めることで行えます。
要するにページに必要なJavaScriptだけチャンクに分割し各HTMLに含めるという実装が必要ということです。

また、SSRのチャンク化にもwebpackの設定が必要で、SPAと違いサーバーの設定とクライアントの設定の両方をwebpackに記載する必要があります。
例えば、以下のように設定します。

  • サーバー用の設定
webpack.server.config.js
module.exports = {
  target: 'node',
  entry: './src/server.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'server.js',
    libraryTarget: 'commonjs2',
  },
  optimization: {
    splitChunks: {
      chunks: 'async',
    },
  },
};
  • クライアント側の設定
webpack.client.config.js
const path = require('path');

module.exports = {
  target: 'web',   
  entry: './src/client.js',
  output: {
    path: path.resolve(__dirname, 'public'),
    filename: 'client.js',
  },
  // その他の設定(ローダー、プラグインなど)
};

そして、実際の実装例は以下のようにライブラリを使用して行うことが多いようです。
代表的なものとしては、@loadable/componentなどがあります。
このライブラリは、Reactコンポーネントの動的インポートとSSRをサポートしています。

LoadbleComponent.js
import loadable from '@loadable/component';

export const LoadableComponent = loadable(() => import('./SomeComponent')
App.tsx
import React from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { LoadableComponent } from './loadableComponents';


function App() {
  return (
    <Router>
      <Switch>
        <Route exact path="/" component={() => <div>Home Page</div>} />
        <Route path="/loadable" component={LoadableComponent} />
        {/* 他のルート定義 */}
      </Switch>
    </Router>
  );
}

export default App;

そして、SSRの処理で@loadble/serverを使用して、リクエストに必要なすべてのチャンクを特定し、それらをHTMLに含める実装をします。

app.js
import { ChunkExtractor } from '@loadable/server';
import path from 'path';
import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import App from './App';

const app = express();

app.get('*', (req, res) => {
  // loadable-stats.jsonのパスを解決
  const statsFile = path.resolve('./build/loadable-stats.json');
  // ChunkExtractorのインスタンスを作成
  const extractor = new ChunkExtractor({ statsFile });
  const context = {};

  // Appコンポーネントとその子コンポーネントをレンダリングし、
  // 必要なJavaScriptチャンクを収集
  const jsx = extractor.collectChunks(
    <StaticRouter location={req.url} context={context}>
      <App />
    </StaticRouter>
  );

  // ReactコンポーネントをHTML文字列に変換
  const html = renderToString(jsx);
  // 必要なスクリプトタグを取得
  const scriptTags = extractor.getScriptTags();

  // クライアントに送信するHTMLを構築
  res.send(`
    <!DOCTYPE html>
    <html>
      <head><title>SSR with Code Splitting</title></head>
      <body>
        <div id="root">${html}</div>
        ${scriptTags}
      </body>
    </html>
  `);
});

// サーバーを起動
app.listen(3000, () => {
  console.log('Server is running on http://localhost:3000');
});

このようにチャンク化のための特定の実装をページごとに行う必要があります。
また、設計や実装前の段階で適切なチャンク分割を設計する必要もあります。

まとめると、SPAもSSRのチャンク化も以下のような課題があると言えます。

  • 設定の煩雑さ: どのライブラリやコンポーネントをチャンクにするか決めたり、どのタイミングでインポートするかなどを決め、実装する必要がある
  • コード量の増加: 追加の実装が必要なためシンプルにコード量が増える
  • チャンクの自動分割: webpackのチャンク分割は自動的に行われるのですが、最適なチャンクの最ざうや数を決定するには自分自身の手を動かす必要がある

そのため、JavaScriptのチャンク化をコンポーネントごとに行う技術としてRSCがでてきたという流れになります。

RSC

ようやくRSCの登場です。
RSCにおけるレンダリングは図にすると以下のようになります。
RSC Rendering

具体的には、以下の順でレンダリングをします。
1.サーバー側でサーバーコンポーネントをレンダリングする
2.サーバーコンポーネントのHTMLとクライアントコンポーネントのJavaScriptをクライアント側に送信する
3.クライアントコンポーネントをレンダリング
4.生成したHTMLをDOMに反映し、画面表示

ここで重要なのがサーバーコンポーネントのレンダリングです。
レンダリングの処理内部では、以下のようにデータ形式の変換も行っています。
このことは、Next.jsのドキュメントにも記載があります。

サーバーコンポーネントをレンダリングする際には、RSCペイロードと呼ばれれるデータ形式にサーバーコンポーネントをレンダリングする

https://nextjs.org/docs/app/building-your-application/rendering/server-components

そして、RSCペイロードを通じてクライアントコンポーネントのレンダリング指示がクライアントに送信されるというのがRSCのレンダリングになっています。

RSCペイロードを含んだ順にレンダリングをまとめると以下のようになります。
1.サーバーコンポーネントがサーバー側でレンダリングされる
(その際、サーバーコンポーネントをRSCペイロードと呼ばれる特別なデータ形式にレンダリングする)
2.RSCペイロードを通じてクライアントコンポーネントのレンダリング指示がクライアントに送信される
3.サーバーコンポーネントのHTMLとクライアントコンポーネントのJavaScriptをクライアント側に送信する
4.クライアントコンポーネントをレンダリング
5.生成したHTMLをDOMに反映し、画面表示

このRSCペイロードと呼ばれるプロセスにより、コンポーネントごとにHTMLが生成され、クライアントサイドでのJavaScriptの読み込み・ハイドレーションもコンポーネントごとに行われます。

この仕組みにより、SSRの利点の高速な初期表示を維持しつつ、JavaScriptのチャンク化が最適化され、クライアントに送信されるJavaScriptの容量(バンドルサイズ)を減らすことが可能になります。
そして、コンポーネントごとにHTML生成・ハイドレーションが行われるので、ページ全体のインタラクティブ性の損失も最小限に留めることができます。

RSCではこのようなことを追加の実装やwebpackの設定なく行えます。
だからこそ、革新的な技術といわれているわけです。

また、RSCはSSRと組み合わせて使用することもできます。
これについては、以下の記事にまとめていますので、興味がある方は読んでみてください。
https://zenn.dev/sc30gsw/articles/0941e76ae96260

以上がRSCにおけるレンダリングになります。
ここまで読むと何となくはどんな実装をしたらよいか見えてくると思います。
そこで、最後にRSCにおけるより良い実装について解説して、本記事を終えようと思います。
(「これは違うのでは?」「これはこうすべき」「私はこうしている」などあれば、コメントしてくださると嬉しいです)

RSCのグッド・プラクティス/ベストプラクティスな実装

ズバリ、「JavaScriptのバンドルサイズを下げるような実装をする」というのが結論になります。
具体的には、以下となります。

  • 適切なチャンク化
  • サーバーモジュールグラフを増やす

適切なチャンク化

これは、Next.jsでいうならappディレクトリにて適切なフォルダ分け(ルーティング設計)をして、Route Segmentを増やそうということと、Suspenseを使用するということです。
例えば、Parallel Routesを使ってlayout.tsx以外のコンポーネントを共通化するなどです。

Parallel Routesを使用すれば、components/Foo.tsxなどと切り分けていたものをappディレクトリのRoute Segmentに含めることができるので、適切なチャンク化がなされます。
ルーティングについて知りたい方は記事にまとめているのでよかったら覗いてみてください。
https://zenn.dev/sc30gsw/articles/67aae793e39d74

Suspenseについては、解説は不要と思いますが、これを利用することでデータフェッチなどをして表示に時間がかかるコンポーネントをローディング表示にして、処理が完了次第表示するということができます。
つまり、重たいコンポーネントのJavaScriptハイドレーションはとりあえずローディングにして、他の画面部品は、重たいコンポーネントを待つことなくインタラクティブな状態で表示できる実装を心がけるということになります。(ストリーミングHTMLというやつです)

以下の図が参考になるので、載せておきます。
loading

https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming#instant-loading-states

サーバーモジュールグラフを増やす

これはクライアントコンポーネントのインポートを減らして、CSRされるJavaScriptの量を減らす実装をするということです。
モジュールグラフというのは、コンポーネントにインポートしているモジュール群(ライブラリや他のコンポーネントなど)のことです。

当然、クライアントコンポーネントのモジュールグラフが多いと、その分、CSRされるJavaScriptのサイズが大きくなり、ハイドレーションに時間がかかるので、可能な限りサーバーコンポーネントでインポートできるものは、そうするべきです。

例えば、next/imageのImageコンポーネントはCSRで読み込むと、それなりのサイズがクライアントで処理されますが、これをサーバーコンポーネントでchildrenとしてクライアントコンポーネントにpropsで渡すなどすれば、Imageコンポーネントをサーバーコンポーネントで読み込ませることができたりします。

以上を心がければ、少なくともバッドプラクティスな実装にはならないのではないかなと私は考えております。

おわりに

今までRSCはコンポーネントをサーバーで処理できるようになったから、非同期処理を関数コンポーネントで実装できたり、セキュリティ面で安全性が増して、レンダリングもサーバー側でパフォーマンスもいいくらいの認識しかなかったのですが、今回の記事作成に当たり、こんなに革新的な技術なのかと驚きまして、認識を改めさせられました。

今後もReactの動向を見た時に、変遷がわかっていると、より良い実装を自分で見つけられるのかなと思わされました。

最後になりますが、本記事がどなたかの参考になれば幸いです。

参考文献

https://qiita.com/yusuke2310/items/411c6fd4cce9eba084f2
https://nextjs.org/docs/app/building-your-application/rendering/server-components
https://zenn.dev/sc30gsw/articles/0941e76ae96260
https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming#instant-loading-states
https://zenn.dev/sc30gsw/articles/67aae793e39d74

Discussion