📌

Next.jsを背景に、レンダリング手法の進化の考察

2024/09/04に公開

クラシックなCSR

業界に早くから携わっているWeb開発者にとって、CSRモデルも最初的なやり方ではないけど、今の2024年という時点に対して、CSRモデルはクラシックモデルに言っても間違いないと思います。

CSRモデルとはクライエントレンダリングモデルということです。このモデルでは、URLを訪問すると、サーバーから戻るものはサーバーデータを持ってないHTMLです。実際には、レンダリングしたいバックエンドデータは、ブラウザで実行されるJavaScriptからネットワークリクエストを発信し、その後に上記のHTMLに挿入する必要があります。

ReactのCSRアプリケーションを例に取り、CSRモデルでのレンダリングプロセス全体を具体的に分析してみましょう:

  1. ユーザーはブラウザからURLをアクセスします

  2. サーバー側に請求を受け取って、HTMLを返します。このHTMLはバックエンドデータを含めてなくて、ルートノードとJSリソースのScriptタグを含めてます

  3. ブラウザがそのHTMLをDOMに変換し、同時に、Script内のJSソースのリクエストも開始し、それが終わったら、JSを実行します

  4. ReactJSの実行を開始し、状態を初期化します

  5. 最初のレンダリングロジックが実行され、最初のレンダリングの終了段階で状態の初期値をHTMLのルートノードに挿入されます

  6. effectもuseEffectの中でコードが実行され始め、ここでは最も重要なネットワークリクエスト部分に焦点を当てます

  7. バックエンドデータを取得した後にsetStateを実行して、再レンダリングが開始します

  8. 今回のレンダリング結果と前回のレンダリング結果を比較し(つまりdiff操作)、今回の更新に必要なDOM操作を取得します。

  9. DOM操作を実行し、最新のデータを反映します。

CSRは、クラシックモデルであり、フロントエンドとバックエンドを分離して責任を明確にしました。実際、フロントエンドエンジニアという職種はCSRが主流になるにつれて発展してきたと言っても言い過ぎではないと思います。

でも、CSRモデルはよく批判される点は初回読み込みの遅延。上記の分析から、最初の画面をレンダリングするには必要な手順が結構多いことがわかります。この中に、重要なバックエンドデータを表示するためには、不要な手順が多く含まれています。具体にどれがなぜが一部の手順が不要なのかは、次のセクションで説明します。

Hydration方式のSSR

CSRの上記の欠点に基づいて、パフォーマンスに敏感なシーンでは、SSRが非常に効果的な解決策となりました。

フレームワークによって、SSRの具体的な実装方法は異なりますが、全体としては基本原則は同じです。
それは、サーバーはURLにアクセスがあった際、データを取得し、それをHTMLに反映してから返すようになっています。

この形で、CSRモデルよりかなり高速になるんです。具体的にSSRで何が省略されたのを説明します:

  1. JSのリソースの読み込み
  2. 最初のリンダリング(コンポーネントツリーを走査、状態の初期化、DOMの挿入とか)
  3. ネットワークリクエストを待つ時間(SSRではデータをDBから読み込む必要があり、速度の違いは取得方法によるものです、一般的にSSRの取得方式ほうが速いです)
  4. 再レンダリング時間(コンポーネントツリーを走査、diff、DOMの更新とか)

上記の手順を省略することで、SSRはユーザーにバックエンドデータを迅速に表示させることができますが、これは明らかにSSRのすべてメカニズムではありません。

サーバーから返されるHTMLは単なる文字列であることを忘れないでね。ブラウザの処理を経て、データを表示するDOMに変換されますが、イベントはバインドされません。DOMはブラウザのAPIであり、JSが実行された場合にのみDOMにイベントをバインドできます。

現在のウェブページにとって、インタラクションはほぼ必須です。交互が不要なコンテンツはごくわずかです。そのため、SSRのDOMにすべてのイベントを持たせる仕組みが必要です。この仕組みはハイドレーション処理(Hydration)です。

Hydrationという名前は非常に適切であり、Hydration処理を通じて元々インタラクティブではなかったDOMをインタラクティブで活気のあるDOMに変えました。ただ、具体的にはどのように実現されていますか?

NextJSを例に取り説明します:

まず、知る必要があるのことはHydration方式のSSRモデルの起動方法です。NextJS 13以降は古いページルートモデルしか使えません。ルートコンポーネントでgetServerSideProps関数を導出する必要があります。URLにアクセスすると、サーバーはこの関数を実行し、バックエンドのデータを取得してコンポーネントに渡し、レンダリングを行い、HTMLデータを含めてクライアントに返します。

Reactコンポーネントはサーバーで実行されることがわかります。したがって、コンポーネントの関数本体にはブラウザAPIや他のNode環境で実行できないコードを書くことはできません。

次にはHydration処理があります。ブラウザはHTMLをDOMに変更しながら、ScriptタグのJSリソースも読み込んでいます。Hydration処理自体はそのJS内に存在しています。

JSリソースを実行する際に、クライアントは最初のレンダリングを行い、その結果をサーバー側でのレンダリング結果と比較します。同じの場合、続きます。異なることがあれば、ハイドレーションエラーになります。

JSXでDate.now()を使用すると、SSR時とCSR時の時間が異なるため、ハイドレーションエラーが発生します。

この要求は厳しいと思いますが、同一性はハイドレーションにおける非常に重要な特徴です。なぜそのような厳しい要求があると考えますか?

実は、単純にインタラクティブDOMをほしいなら、SSRのDOMを削除し、CSRのDOMを挿入すれば実現できます。しかし、このようにするとDOMの再利用ができず、パフォーマンスに問題が生じる可能性があります。また、削除してから挿入することでページがちらつく可能性もあり、ユーザー体験に一定の影響を与えるかもしれません。

SSRのDOMを再利用したいなら、SSRのDOMとCSRの初回レンダリング結果を比較して、正確な対応関係を見つける必要があります。これによって、イベントをどこに挿入すべきかが分かります。これが、SSRのレンダリング結果とCSRの初回レンダリング結果が一致している必要がある理由です。一致しない場合、対応関係が見つからず、ハイドレーションが必要な部分が正しくハイドレーションされずに問題が発生する可能性があります。

この一致性の要求は理由があるんですが、開発者に対して考えしなければならないことや開発のコスト増加させる可能性があります。

例えば、上記のDate.now()例に関して、この問題をどのように解決すればよいでしょうか?通常、追加のstateを宣言し、その後effect内でこのstateを更新する必要があります。なぜなら、effect内のコードはサーバーで実行されるわけでもクライアント側の最初のレンダリング時に実行されるわけでもなく、クライアント側で後続の再レンダリング時に実行されるためです。そのため一致性の問題が発生しないからです。

でも、やっぱりこのような処理方法は、コードが複雑になるだけじゃなくて、余計な初期値を追加し、その値を更新するタイミングも考慮する必要があります。

ハイドレーション方式のSSRの弱点について、一致性を確保が必要な面倒さだけではなく、DOMがインタラクティブになる時間が遅いも問題てんです、特にDOMは見える時点からインタラクティブになる時点までの時間差が存在しています。

その時間差は分析しましょう:

  1. JSリソースの読み込み時間
  2. クライアントで初回レンダリング
  3. 初回レンダリング結果とSSRの結果の比較
  4. ハイドレーションを行い、対応関係を見つけてイベントをDOMにバインドします

一般的な状況では明らかな遅い感じがないが、ハイドレーションは一度始まると止めることができず、全てのコンポーネントツリーが成功にハイドレーションされるのを待たなければインタラクションすることはできません。ページが非常に重い場合、問題が顕著になります。

また、getServerSideProps内のコードは、ページのアクセス、リフレッシュ、および遷移の3つの状況でのみ実行され、ページのライフサイクル内では一度しか実行されません。これは、クライアントの状態を失わずにgetServerSidePropsから取得したデータを更新することができないことを意味します。しかし、このニーズは非常に一般的です。この問題を解決するには、クライアントがこのデータソースを更新したい場合に、クライアント側から追加のリクエストを発行して対応する必要があります。これにより、開発と理解が少し複雑になります。

ハイドレーション方式のSSRは他の小さい問題もあるんけど、そんなに酷い問題ではないから省略します、このモデルの特徴についてまとめます:

  1. バックエンドデータの見える時間が速い
  2. コンポーネントの関数本体にはサーバー側使えないコードを書くことはできません
  3. ハイドレーションエラーを考慮しないと、コードが汚れる可能性があります。
  4. 初めてインタラクティブになる時間が遅い
  5. getServerSidePropsからのデータを更新したい場合、クライアントのネットワークリクエストが追加しなければならない
  6. ルーティングのジャンプはSSRをトリガーできず、ページのジャンプをしなきゃならない

このセクションでは、読者はSSRという言葉を使う際に常に「ハイドレーション方式のSSR」という表現を使用していることに気付くかもしれません。これは、ハイドレーション方式以外にも他のSSRパラダイムが存在することを示唆しており、次のセクションで説明します。

RSC+CSRコンポーネントモデル

RSCの意味はReact Server Componet、これはサーバー側のみ実行されたコンポーネントです、RSC返したのJSXもサーバー側レンダリングです。それなら、ハイドレーション方式のSSRと異なることはなんですか?

まず、app routeモードでは、コンポーネントはデフォルトでRSCです、逆にクライアント環境のみ実行されたいなら「"use client"」というマークが必要です。また、getServerSideProps関数を使用せずに、代わりにバックエンドのコードを直接関数本体に記述します。ここでAPIをリクエストしたり、DBにアクセスしたりすることができます。

あと、最も重要なのは、RSCは完全にハイドレーションしないことです。

ハイドレーションはしないと、インタラクティブな操作ができなくなります。そのため、一般的にはデータの表示が早いものはRSCから取得し、その後このデータをインタラクティブに操作したいデータとそうでないデータを分けて考えます。インタラクティブに扱いたいデータはCSRの子コンポーネントに渡し、そうでない場合はRSCで処理します。

ただし、確かにこのやり方で、需要なデータの見える時間とハイドレーションのSSRと比較して少し遅くなります。どの部分が遅くなったですか?まず、バックエンドデータの取得は同じです。でも、ハイドレーションのSSRの場合、JSリソース読み込み前に見えます。RSCモデルなら、重要なデータはCSRのコンポーネントに渡しましたので、必ずJS実行して初回レンダリング後で見えます。

それなら別の考えを引き起こすでしょう、このやり方とCSRと基本的に同じではありませんか?確かに、RSC+CSRモデルでは重要なデータもクライアントサイドでレンダリングされますが、違いは明らかです。簡単に言えば、RSCがデータを取得し、CSRコンポーネントがレンダリングするモードでは、初回のレンダリング時にすでにデータが取得されていますが、純粋なCSRモードでは、初回のレンダリング後(この時点でHTML上のデータはuseStateへ渡した状態の初期値です)にデータを取得するためのネットワークリクエストを送信します。したがって明らかにRSC+CSRの方が速いです。

具体的にその時間差を分析します:

  1. データ取得に関しては、どちらも時間が欠かせないですが、一般的にサーバー側のほうが速いです
  2. 初回のレンダリング時に、純粋なCSRでは、開発者が独自に作成した意味のない初期値をレンダリングの時間
  3. 純粋なCSRの場合、ネットワークリクエストが終わった後のdiffとDOM更新の時間

純粋なCSRと比べると、RSC+CSRの利点は速さであり、余分な初期状態を必要としない。

特に言及すべき点は、ハイドレーションのSSRでよく批判された問題はRSC+CSRモデルは解決できますよ。その問題は、ハイドレーションのSSRは確かにバックエンドデータを表示される時間がCSRより速いなんですけど、HTMLを返し前に何も見えない状態であり、CSRならLoadingとかスケルトンスクリーンを含めてHTMLを直ぐ返えるから、事実と合わないけど感覚的に逆にSSRより速い気がするかもしれません。

ただ、RSC+CSRなら、各画面のディレクトリにloading.tsxファイルを追加すると、この問題が解決されます。

実際のエンジニアリングにおいて、通常は2つのケースがあります:

  1. スケルトンスクリーンを追加したい場合、各画面のディレクトリでloading.tsxを追加する:
// src/app/home/loading.tsx
export default function Loading() {
    return "スケルトンスクリーン"
}
  1. 普通のローディングを追加したい場合、ルートでloading.tsxを追加する:
// src/app/loading.tsx
// 全部画面はこのローディングを利用します
export default function Loading() {
    return "loading..."
}

こうすることで、CSRよりも速く表示されるだけでなく、SSRならできないのローディングやスケルトン画面を表示することができます。

ハイドレーションのSSRと比べると、RSC+CSRは他の利点もあります。RSC+CSRはルーティングジャンプであっても、完全なデータリクエストとレンダリングプロセスをトリガーすることができます。

ハイドレーションのSSRと比べて、RSC+CSRの利点をまとめましょう:

  1. ハイドレーションが不要です、ハイドレーションエラーに配慮の必要がない、見える時点からインタラクティブ操作できる時点の時間差がない
  2. RSCとCSRのコンポーネントは明らかに分けてます
  3. 初めてアクセスのとき、真っ白の時間を避けることができます、Loadingとかスケルトンスクリーンが追加可能です
  4. ルーティングジャンプの場合もRSCから完全のレンダリングがをトリガーすることができます

ただ、いくつかの欠点もあります:

  1. バックエンドデータが見える時点が少し遅い
    主にはJSリソース読み込みの時間です、でもこれに対して対策もあります、最優先で見えたいの内容を普通に書いて、他のコンテンツはレイジーロードすることで、リソースの読み込みやレンダリングにかかる時間を短縮することができます。
  2. SEOに不利
    一般的に、RSC+CSRの場合、サーバー側からのHTMLの中にデータは一部だけだから、データを完全に含まれてるのSSRよりSEOは不利です。
    しかし、SEOは複雑なことであり、効果を向上させるための他の多くの方法があります。この点の影響は避けることも可能です。

すでにRSCについて一定程度理解していますので、その特徴をまとめてみましょう:

  1. app routeモデルで、コンポーネントはデフォルトでRSCであり、サーバー上でのみ実行され、HTMLをレンダリングします
  2. RSCはコンポーネントのトップにしか書けません。CSRのコンポーネントは、RSCのサブコンポーネントとしてことができますが、親としてことはできません
  3. 実際の開発では、RSCからデータを取得し、propsとしてCSRコンポーネントに渡してレンダリングすることが一般的な方法です
  4. 初めてアクセス時、Loadingとかスケルトンスクリーンが追加可能です
  5. バックエンドデータを見える時点はJSリソース読み込みした後、初回レンダリング後です、ハイドレーションのSSRより少し遅いです
  6. RSC本体はハイドレーションしないけど、CCはハイドレーションします。

Discussion