· 12分で読了

SSR に対する考察とユースケース

この記事は中国語から自動翻訳されたものです。翻訳によりニュアンスが失われている場合があります。

フロントエンドがまだ成熟しておらず、インタラクションに対する要求もそれほど高くなかった初期の頃は、通常、バックエンド言語が提供するテンプレートエンジン(有名なものとしては ejspugerbthymeleaf など)を使って、まずサーバー側で HTML をレンダリングしてブラウザに返し、ブラウザ側で JavaScript を使って各種インタラクションを行うのが一般的だった。

これは当たり前のことのように思えるが、ブラウザと時代の進化に伴い、徐々にいくつかのデメリットが浮き彫りになってきた:

  • 別のページをクリックするたびにリクエストを再送信する必要があり、HTML、JavaScript、CSS、そしてその時点で保持されていた状態をすべて再読み込み・再実行しなければならない(キャッシュがないと仮定した場合)。
  • View の柔軟性が、テンプレートエンジンで提供されている構文に制限されがちである。
  • View とインタラクション自体を密結合させたい場合があるが、両者が分かれていると不便に感じられる。
  • インタラクションが増え、繊細になるにつれて、リアクティブ(Reactive)な仕組みを導入する必要が出てくる。

SPA の進化により、ページ上のインタラクションと表示をすべて JavaScript の実装に移行できるようになり、コンポーネント化とリアクティブな仕組みによってコードの保守が容易になった。しかし、JavaScript 単体では、従来のサーバーサイドレンダリングで実現できていたいくつかのことを達成できない。例えば以下のような点だ:

  • SEO や検索エンジンのクローラーによるページ巡回の支援
  • Open Graph コンテンツの生成
  • ユーザー体験の最適化

これ以降、それぞれのポイントについて説明していく。本題に入る前に、SPA と SSR の 2 つの概念にあまり詳しくない読者は、まず huli の記事「跟著小明一起搞懂技術名詞:MVC、SPA 與 SSR」を参考にしてみてほしい。ここでは主に、SSR 自体がもたらすメリットと実際のユースケース(React を例にする)を中心に説明していく。

SEO や検索エンジンのクローラーによるページ巡回の支援

ブラウザが Web ページをクロールする際、クローラーは Web ページの HTML コンテンツをクロールした後にインデックスを生成し、データベースにキャッシュを作成して定期的に更新する。つまり、SSR を実装していない場合、HTML ファイル自体は真っ白であり、main.js がパースされて実行されるまで実際のページを見ることはできない。

例えば:

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

Google は JavaScript をパースして実行できると公言しているものの、その効果にはやはり限界があると思うし、fetch などのリクエスト送信を伴う処理までは対応しきれない。

ここで 17LIVE を例に挙げてみよう。Google で 17LIVE を検索する:

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 などでタイムラインにリンクを投稿すると、クローラーがリンク先の URL にリクエストを送り、内部の <meta> を解析してプレビュー画面の表示方法を決定する(詳細は Open Graph Protocol を参照)。これらのデータはサーバーから返却される必要がある。そうでなければ、クローラーが見る内容は依然として真っ白なままになってしまう。

React において、このような <head> 内に配置するコンテンツを実装するには、通常いくつかの方法が用いられる:

  • react-helmet:コンポーネントの形式で <head> 内のコンテンツを管理でき、サーバーサイドレンダリングの実装も備わっている。
  • next/head:Next.js に組み込まれている <head> を管理するための仕組み。

ユーザー体験の最適化

Web ページがパースされる際、必ず HTML を受け取ってから JavaScript を解析するため、JavaScript の実行が完了するまではコンテンツを見ることができない。主流の PC 環境(CPU i5 以上など)であればそれほど大きな差はないかもしれないが、以下のような考慮点が存在する:

  • ユーザーが必ずしも PC やスマートフォンで閲覧しているとは限らない:IoT デバイス、電子書籍リーダー(Kindle など)、PS4、スマートテレビなどで Web ページを閲覧している可能性もある。これらのデバイスは多くの場合、JavaScript をパースできないか、マシンスペックが非常に限られている。
  • すべてのユーザーが JavaScript を有効にしているとは限らない(比較的少数ではあるが)。ユーザーはページが真っ白な状態を見ると、そのまま離脱してしまい、潜在的なユーザーを失う可能性がある。
  • SSR の助けを借りることで、JavaScript の実行時にフレームワークがコンテンツをマッピング(ハイドレーション)できるようになり、document.appendChild などの DOM API 呼び出しのパフォーマンスを節約できる。

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

SSR

React や Vue などのフロントエンドフレームワークを用いて SSR を行う場合、従来のテンプレートエンジンを使用する方法と比べていくつかの違いがある:

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

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

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

フロントエンドフレームワークで SSR を実現するために、まず考慮すべきは「状態」である。「同じ状態が与えられれば、レンダリングされる画面も同じになる」という前提のもとで、フロントエンドとバックエンドでレンダリングされる画面を同一にする(初期表示画面)ためには、HTML をレンダリングする時点でフロントエンドとバックエンドが同じ状態に達している必要がある。

一般的には、サーバーでのレンダリング時にグローバルな store を用意する:

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

そして、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 の特性上、View とインタラクションロジックがすべてフロントエンドに集約されるため、バンドルサイズが限界値に達しやすくなり、初期読み込みのパフォーマンスに影響を与え始める。このとき、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 を実現したい目的の 1 つは、より良いユーザー体験(ローディング画面を待たせないこと)と、より優れた SEO(ブラウザやクローラーから見える画面が単なるローディング表示にならないこと)にあるのだから。より良い方法は、グローバルな store を介してサーバーのデータをフロントエンドに送るか、Next.js 内の getStaticProps() のような仕組みを利用して補うことだ。

SSR があるかないかで、本当にそんなに差が出るのか?

この点は、SSR をどの角度から見るかによるし、ユースケースに応じて判断する必要がある。上述した 17LIVE の例で言えば、ページ内のコンテンツは動的な動画が大多数を占めているため、SSR 化するメリットがあまり大きくないか、あるいは移行コストが比較的高くなる。これらはすべて考慮すべきポイントだ。

また、管理画面システムなどを開発する場合、ユーザーのほとんどは社内スタッフであり、SEO を考慮する必要がない。大半のユーザーのデバイス性能が良好であると仮定できるため、わざわざ SSR を実装しなくても問題はない。SSR を実装する際には実際に考慮すべき事柄がかなり多く、当初から SSR を想定していなかった場合、後になればなるほど移行コストは高くなる。そのため、開発の初期段階で SSR を導入する必要があるかどうかを評価し、できる限り早期に準備しておくことが望ましい。ブログ、EC サイト、記事閲覧サイト、ランディングページなど、SEO への要求が高いアプリケーションを開発する場合、SSR は必須の考慮事項となるはずだ。

しかし僕としては、SSR が必要か否かを議論するよりも、ユーザーのニーズという観点から直接出発するほうが、より有意義な議論ができると考えている。どんな技術やプロダクトであっても、最も重要なのは人間に奉仕することなのだから。

例えば:

  • ユーザーは電子書籍リーダー、IoT デバイス、低スペックなデバイスから僕たちの Web サイトを閲覧するかもしれない。そのため、不要なパフォーマンスの浪費を極力減らす必要がある。
  • ユーザーは何らかの目的を達成するためにそのサービスを利用している可能性がある。それなら最適化すべきなのはフローかもしれないし、デザインかもしれない。そして可能な限りブラウザと JavaScript を活用して体験を強化していくべきだ。

その他の選択肢と考察

ここでは、いわゆるベストプラクティスにとらわれず、実際のシーンや制約の中でどのようなアプローチが取れるかを考えてみる。

  • 現在の開発状況において SSR を直接導入するのがコストパフォーマンスに合わない場合、バックエンド側でクローラー向けに簡略化したバージョンの View を返し、ユーザーが閲覧する際には JavaScript でレンダリングする仕組みにしても良いかもしれない。2 つの異なる View を保守する必要が生じる可能性はあるが、場合によってはその方がシンプルなこともある。
  • 毎回コンテンツが同一であるなら、純粋な静的 HTML を直接生成すれば良い。
  • 移行コストが非常に高い場合は、puppeteer などのヘッドレス Chrome を使用して直接 Web ページを巡回し、レンダリング済みの HTML をキャッシュしてサーバー側に保存しておくこともできる。バッチ処理などで定期的に更新すれば良い。
  • SPA を採用することは、フロントエンドの JavaScript の複雑さとバンドルサイズが不可避的に増大することを意味する場合が多い。従来のレンダリング方式に prefetch の仕組みを組み合わせるアプローチを選んだほうが、パフォーマンスや体験がより良くなることさえあるかもしれない。

関連記事

他のトピックを探索