SSR のシナリオの考え方と使用方法

作成者:カランカラン
💡

質問やフィードバックがありましたら、フォームからお願いします

本文は台湾華語で、ChatGPT で翻訳している記事なので、不確かな部分や間違いがあるかもしれません。ご了承ください

早期にフロントエンドがまだ成熟しておらず、インタラクションの要求もそれほど高くなかった頃、通常ページのレンダリングはバックエンドプログラミング言語が提供するテンプレートエンジン(有名なものには ejspugerbthymeleaf など)を用いて、サーバーサイドで HTML をレンダリングし、ブラウザに返すことで実現されていました。その後、ブラウザ側で JavaScript を使用してさまざまなインタラクションが行われます。

一見すると当然のように思えますが、ブラウザや時代の進化とともにいくつかの欠点が徐々に明らかになってきました:

  • 他のページをクリックするたびにリクエストを再送信する必要があり、HTML、JavaScript、CSS、そして現在保持している状態がすべて再度読み込まれ、実行される必要があります(キャッシュがない場合を前提)。
  • view の柔軟性は、しばしばテンプレートエンジンが提供する構文に制約されます。
  • 時には、view とインタラクション自体を高度に結びつけたいと考えることもあり、両者が分かれていると不便に感じることがあります。
  • インタラクションがますます多様化し、詳細化する中で、リアクティブなメカニズムが必要になることが多くなります。

SPA(シングルページアプリケーション)の進化により、ページ上のインタラクションや表示をすべて JavaScript で実装できるようになり、コンポーネント化やリアクティブ機構を通じてコードの保守が容易になりました。しかし、単に JavaScript のみでは、従来のサーバーレンダリングが実現できるいくつかのことを達成することはできません。たとえば:

  • SEO を助け、検索エンジンがページをクロールできるようにすること。
  • Open Graph のコンテンツを生成すること。
  • ユーザー体験を最適化すること。

これから各ポイントについて説明します。本文に入る前に、SPA と SSR(サーバーサイドレンダリング)という概念に不慣れな読者は、huli の記事 小明と一緒に技術用語を理解しよう:MVC、SPA、SSR を参考にすることをお勧めします。ここでは、主に SSR 自体がもたらす利益と実際のシナリオ(React を例に)について詳しく説明します。

SEO を助け、検索エンジンがページをクロールできるようにすること

ブラウザがウェブページをクロールする際、クローラーはページの HTML コンテンツをクロールして内容を生成し、データベースにキャッシュを作成して定期的に更新します。言い換えれば、SSR を実装していない場合、HTML ファイル自体は空白であり、main.js が解析され、実行されるのを待たなければ実際のページを見ることができません。

例えば:

<html>
    <head>
        
    </head>
    <body>
        <div id="app"></div>
        <script src="main.js"></script>
    </body>
</html>

Google は JavaScript を解析し、実行できると主張していますが、実際の効果は限られていると思いますし、fetch などのリクエストを送信する必要がある機能も実現できません。

ここでは 17 ライブを例にとり、Google で 17 ライブを検索した結果を見てみましょう:

Screenshot from 2020-11-23 21-40-18

検索結果の下では、検索エンジンが通常 <title><meta name="description" content="xxx"/> を表示します。この部分はサーバー側で直接生成するか、HTML ファイルに直接書き込むことができますが、ページのストックファイルをクリックすると、結果は空白になります:

さらにソースコードを確認すると、中には空の body を持つ HTML ファイルが含まれていることがわかります:

<!DOCTYPE html>
<html>

<head>
  <title>17LIVE - Live Streaming 直播互動娛樂平台</title>
  <meta charset="utf-8">
  <meta name="description" content="17LIVE 直播互動零距離。各式特色才藝直播主分享生活每一刻;多元節目內容免費線上看!" />
  ...
</head>

<body></body>

</html>

Open Graph コンテンツを生成すること / <head> を管理すること

Facebook、Twitter、LINE などでリンクを貼ると、クローラーがそのリンク先にリクエストを送り、内部の <meta> を解析してプレビュー表示を決定します(詳細は Open Graph Protocol を参照)。これらのデータはサーバーから返される必要があります。さもなければ、クローラーが見ているのはやはり空白の内容になります。

React でこのような <head> 内の内容を実装する場合、通常はいくつかの方法を用います:

  • react-helmet:コンポーネントを通じて <head> 内の内容を管理でき、サーバーサイドレンダリングも実装されています。
  • next/head:next.js には <head> を管理するための組み込みの方法があります。

ユーザー体験を最適化すること

ウェブページが解析される際、必ず HTML を受け取ってから JavaScript を解析します。JavaScript が実行される前には内容を見ることができません。主流のコンピュータ機器(CPU i5 以上)では差はそれほどありませんが、いくつかの考慮点を挙げます:

  • ユーザーが必ずしもコンピュータやスマホを使ってブラウジングするわけではありません:IoT デバイス、電子書籍リーダー(Kindle など)、PS4、テレビなどでウェブページを閲覧することもあります。これらのデバイスはしばしば JavaScript を解析できず、性能にも限界があります。
  • すべてのユーザーが JavaScript を有効にしているとは限りません(相対的には少数ですが)。ユーザーがページが空白だと見たとき、すぐにページを離れる可能性があり、潜在的なユーザーを失うことになります。
  • SSR の助けを借りることで、JavaScript の実行中にフレームワークが内容を一致させ、document.appendChild などの DOM API の性能を節約できます。

従来のテンプレートエンジンとの違い

SSR

React や Vue などのフレームワークを用いた SSR は、従来のテンプレートエンジンを使用した場合といくつかの点で異なります:

  1. 従来のテンプレートエンジンがレンダリングする結果は純粋に静的な HTML 文字列であり、変数はサーバーによって注入されます。一方、フロントエンドフレームワークの場合、サーバーでは HTML 文字列をレンダリングするだけでなく、クライアント側では DOM API を動的に呼び出して HTML に追加し、SSR レンダリング後には対応するイベントリスナー(click, change など)を注入し、対応するライフサイクルメソッドを実行します。
  2. 従来のテンプレートエンジンにはリアクティブの概念がなく、変数が変わっても DOM が自動的に更新されることはありません。
  3. レンダリングされた HTML はフロントエンドのコードと一致する必要があるため、フロントエンドとバックエンドのコードは共用する必要があります(HTML をレンダリングする際)。そのため、フロントエンドフレームワークの SSR は node.js と組み合わせて使用する必要があります。従来のテンプレートエンジンは必ずしもそうではなく、バックエンドのプログラミング言語に応じて選択できます。

フロントエンドフレームワークはどのように SSR を実現しているのか?

ここでは特定のフレームワークの実装方法には触れません。主に主流のフロントエンドフレームワークがどのように SSR を実現しているかについて紹介します。

フロントエンドフレームワークが SSR を実現するために最初に考慮すべきは状態です。同じ状態が与えられた場合、レンダリングされる画面が同じであるという仮定のもと、フロントエンドとバックエンドで同じ状態を達成する必要があります(HTML をレンダリングする際に)。

一般的に、サーバーでレンダリングする際には、グローバルなストアを準備します:

route.get('/', (req, res) => {
  const store = {
    posts: [],
    user: {
      name: 'kalan',
      ...
    },
  };
      
  const html = ReactDOMServer.renderToString(<App />);
  res.render('index', {
    html: html,
    store: JSON.stringify(store);
  })
});

次に、ストアのデータをグローバル変数に配置します:

<div id="app">
 <%- html %>
</div>
<script>
  window.GLOBAL_STORE = store;
</script>

最後に app.js では次のように書きます:

ReactDOM.hydrate(<App store={window.GLOBAL_STORE} />, document.getElementById('app'));

ここでは hydrate という API を使用して、React にサーバー側で事前に内容をレンダリングしたことを伝えます。React は DOM API の過程を省略し、HTML に対応するイベントリスナーやライフサイクルイベント、useEffect などを追加します。ここで注意すべきは、いわゆる SSR は最初のレンダリング(リクエストを発行して受け取る HTML)のみであるということです。

アプリが複雑になるにつれて、自分でグローバル変数を準備するのは良いアイデアとは言えません。この時点で、next.js のようなフレームワークを考慮し、SSR の際に考慮すべき問題や実装の複雑さを簡素化することができます。

一般的な誤解:動的インポート(dynamic import)と AJAX

アプリの規模が徐々に拡大するにつれ、SPA の特性からビューとインタラクションロジックがすべてフロントエンドに置かれるため、バンドルサイズが容易に臨界点に達し、初期ロードのパフォーマンスに影響を与えることがあります。この時、dynamic import のメカニズムを利用して、あまり重要でないコンポーネントやページを別のファイルに分割し、必要なときにリクエストを送信することができます。

現在、React が提供する React.lazy は SSR をサポートしていませんが、公式が提供する SSR ガイド(loadable-components 使用) を参照できます。

loadable-components の現在のアプローチは、現在の <App/> コンポーネント内で使用される dynamic import を事前に収集し、マニフェストファイルを生成した後、これらのファイルを <script src="xxx.js"> の形式で読み込むことです。ページ内のコンポーネントがすべて動的に読み込まれる方式でレンダリングされる場合、初期レンダリングの結果が空白になる可能性があります(なぜならファイルはまだ <script> を通じて読み込まれなければならないからです)。

さらに、フロントエンドでデータを取得する方法が ajax である場合、サーバーでデータを取得しないと、初期のレンダリングも空白またはローディング画面になってしまいます。仮に簡単なコンポーネントが次のようなものであった場合:

const App = () => {
  const [data, setData] = useState([]);
  useEffect(() => {
    fetch('/api/data').then(res => res.json())
      	.then(data => setData(data));
  }, [])
  
  if (data.length === 0) {
      return <span>loading</span>
  }
    
  return <div>
    {renderData()}
  </div>
}

React が SSR を行う際には、useEffect 内のコードは実行されず、サーバーのデータをコンポーネントに挿入してマークアップをレンダリングするだけです。実際にリクエストを送信するのは、ブラウザがページを実際にレンダリングした後になります。したがって、レンダリングされたコードは次のようになります:

<span data-reactroot="">loading</span>

SSR にとって、このようなページは初期レンダリングのパフォーマンス負担を少し軽減できますが、SEO やユーザー体験にとっては良くありません。結局のところ、SSR を実現する目的の一つは、より良いユーザー体験(ローディングページを待たずに済む)や、より良い SEO(ブラウザが見えるページがローディングのままである)を実現することです。より良いアプローチは、グローバルストアを通じてサーバーのデータをフロントエンドに送信するか、next.jsgetStaticProps() メカニズムを活用することです。

SSR があるかないか、本当に大きな違いがあるのか?

これは SSR をどの視点から見るかによりますし、使用シーンによっても決まります。前述の 17 ライブの例を考えると、そのページは動的な動画が多く、SSR の効果はあまり大きくなく、また変換コストが相対的に高いことも考慮すべき点の一つです。

また、バックエンドシステムの開発を行う場合、大多数のユーザーが社内の人員であり、SEO を考慮する必要がありません。そのため、大部分のユーザーのデバイス性能が良好であれば、SSR を特に実装しなくても問題ありません。SSR を実装する際には考慮すべきことが多く、初めから SSR を考慮していなかった場合、後期になるほど変換コストは高くなります。したがって、できるだけ開発初期に SSR 機構を導入する必要があるかを評価し、早めに準備することが重要です。ブログ、eコマース、記事閲覧、ホームページなど、SEO の要求が高いアプリケーションに関しては、SSR は必ず考慮すべきポイントです。

しかし、私は SSR の必要性について議論するよりも、ユーザーのニーズの観点から出発した方が、より良い議論の余地があると思います。結局のところ、どのような技術や製品も最も重要なのは人間にサービスを提供することです。

例えば:

  • ユーザーは電子書籍リーダーや IoT デバイス、性能のあまり良くないデバイスを通じて私たちのウェブサイトを見る可能性があるため、無駄な性能の浪費をできるだけ減らす必要があります。
  • ユーザーは特定の目的を達成するためにこのサービスを使用しているため、最適化すべきはプロセスであり、デザインであり、できるだけブラウザと JavaScript を通じて体験を強化することです。

その他のソリューションと考察

ここでは、いわゆるベストプラクティスを見ているのではなく、実際のシーンや制約の中でどのように応用できるかを考えてみます。

  • 現在の開発状況で直接 SSR を導入することがコスト効果的でない場合、バックエンドがクローラー向けに簡略版のビューを提供し、ユーザーがウェブページを閲覧する際には JavaScript のレンダリングメカニズムを使用することができます。異なる2つのビューを維持する必要があるかもしれませんが、時にはそうする方が簡単なこともあります。
  • 内容が常に同じであれば、純静的な HTML を直接生成できます。
  • 変換コストが非常に高い場合、puppeteer のようなヘッドレス Chrome を使用してウェブページを直接閲覧し、レンダリングされた HTML をキャッシュしてサーバー側に保存することができます。スケジュールを通じて定期的に更新することが可能です。
  • SPA を使用することは、往々にしてフロントエンドの JavaScript の複雑さやバンドルサイズが避けられないほど大きくなることを意味します。そのため、従来のレンダリング方法と prefetch メカニズムを組み合わせることで、パフォーマンスや体験が向上する可能性もあります。

この記事が役に立ったと思ったら、下のリンクからコーヒーを奢ってくれると嬉しいです ☕ 私の普通の一日が輝かしいものになります ✨

Buy me a coffee