レンダリングから始めるNuxtのSSR

SSR?サーバサイド…?

Vue 3やVue 3に一定のルールを与えるNuxt 3には、(クライアントサイドではない)サーバサイドでレンダリングを行うサーバサイドレンダリング(以下、SSR)と呼ばれるモードを備えています。

Vue 3でもNuxt 3でも、SSRを前提とした ClientOnly コンポーネントや Server Components といった機能が提供されています。

https://www.youtube.com/watch?v=17zBODTpuoo

いづれSSRの理解が前提となっているため、公式ドキュメントを参考に周辺の知識を深堀りしてみます。

前提条件

  • この記事には、繰り返し「レンダリング」という言葉が登場します。コンテキストにより解釈幅があるようですが、ここでの意味はウェブで使われるそれに限定されます。
  • React等他のUIフレームワークでもSSRは実装されますが、本記事ではVue.jsを話の土台にします。
  • SSR理解してみた」系記事がたくさんある中で、SSRを使う理由はよく語られていることと、本記事の趣旨が異なるため、SSRはあるユースケースにおいて有用なものである前提に立ちます。

レンダリングから始めよ

SSRは、文字通り「サーバサイドでレンダリングする」技術です。一度でもウェブアプリケーションを書いたことがあれば「サーバサイドでレンダリング」した経験を持っているはずで、なんでわざわざ「サーバサイドレンダリング」というのでしょうか。

そもそも、筆者は記事作成前時点で「レンダリング」(と表示、描画)の差をあまり意識してこなかったことに気が付きました。そこで、理解のとっかかりとして「レンダリング」とはなにか、という話からはじめてみたいと思います。

レンダリング」とは

“render” という言葉には「描く」といったニュアンスが含まれます。”draw”に近い言葉のようです。英英辞書にはいくつかの定義がありますが、例えば以下のような定義が近いです。

to represent something in a work of art or a performance:

render

また、ここでは深く立ち入りませんが、ウェブに限らずコンテンツを生成する作業を指して「レンダリング」と呼ぶことがあるようです。表示や描画まで含むケースもあるようですが、いづれにしても表示する元ネタを生成するプロセスとして広くレンダリングという言葉は使われています。

レンダリング (コンピュータ))

一般的に、ウェブサーバはリクエストを受け取るとHTMLの文字列(以下、マークアップ)を作りクライアントに返します。HTMLを返す作業を描くプロセスになぞらえて「レンダリング」と慣習的に呼んでおり、「レンダリング」それ自体は必ずしもブラウザと直接関係がないものと言えます。

つまり、ブラウザがなくともレンダリングはできるということでもあります。

サーバは文字列のマークアップを送信し、ブラウザはこれを解釈、DOMツリーに変換することでブラウザにコンテンツを表示できる。レスポンスヘッダのContent-Typeがtext/htmlで指定されることで、受信したブラウザは文字列をDOMツリーの元ネタと認識できるのです。

マークアップを返すということ

マークアップの生成をサーバサイドでのみ行うレンダリングを、SSRと対比するため以後、独自に「テンプレートレンダリング」と呼ぶこととします。これはVue 3/Nuxt 3に馴染みがなくとも、古来から行われてきた伝統的なレンダリング手法で、ウェブアプリケーションの基本的な振る舞いでもあります。

たとえば、Express.jsによるテンプレートレンダリングの例です。

app.get('/', (req, res) => {
  res.render('index', { title: 'Hey', message: 'Hello there!' })
})

Express でのテンプレート・エンジンの使用

ブラウザから任意のエンドポイントにアクセスされたら、マークアップをすべて1から「レンダリング」しています。特に目新しいことはありません。

Express.js同様、Ruby on RailsでもSpringBootでも、マークアップを返却していればサーバサイドでレンダリングを行っていることになります。

例えば、Ruby on Railsでもマークアップを返す関数は render 関数で、これもレンダリングと呼ぶことができます。Ruby on Railsでは「ビューをレンダリングする」という言い回しをしていました。

レイアウトとレンダリング - Railsガイド

Controllerの redner 関数で ビューを返します。

class UsersController < ApplicationController
  def new
  end

  def create
    # ... 中略 ...
    render "new"
  end
end

オプションを指定しなければ Content-Typetext/html に指定されるので、受信したブラウザが文字列をマークアップと認識できます。

このように、ウェブの世界では昔から「マークアップを作って返す作業」を「レンダリング」と呼んできたのでした。そして、ウェブにおけるレンダリングマークアップの生成を指すケースが大半なので、ブラウザにおけるDOMツリーの構築やコンテンツの表示は厳密にはレンダリングと区別されるべきでしょう。

Vue 3におけるSSR

我々がさきほど、個別に認識しやすいようつけた「テンプレートレンダリング」に呼び方がとても似ていて、かつ初見では混同しやすい概念に「サーバサイドレンダリング」があります。

Vue 3はSSRについて、公式ドキュメントに丁寧な解説をつけています。

同じコンポーネントをサーバー上で HTML 文字列にレンダリングし、それをブラウザーに直接送信し、最終的にクライアント上で完全にインタラクティブなアプリケーション内に静的なマークアップを"hydrate(ハイドレート)"することもできます。

サーバーサイドレンダリング (SSR) | Vue.js

サーバで初期表示に使うマークアップレンダリングをするところまではテンプレートレンダリングと同じです。

いっぽうで、テンプレートレンダリングと違い、JavaScriptはバンドルせずにマークアップのみをブラウザに返却します。

少しだけ、実装に近い概念に踏み込んでみます。Vue 3では createSSRApp でAppインスタンスを作ることでSSRに対応したレンダリングを実行できます。

Application API | Vue.js

Express.jsと組み合わせた、簡易的なSSRのサンプルコードが公開されています。とてもわかりやすいのでそのまま引用します。

const server = express();

server.get('/', (req, res) => {
  const app = createApp();
  
  renderToString(app).then((html) => {
    res.send(`
    <!DOCTYPE html>
    <html>
      <head>
        <title>Vue SSR Example</title>
        <script type="importmap">
        {
          "imports": {
            "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
          }
        }
        </script>
        <script type="module" src="/client.js"></script>
      </head>
      <body>
        <div id="app">${html}</div>
      </body>
    </html>
    `);
  });
});

server.listen(3000, () => {
  console.log("server listening ...");
});

get 関数のコールバック冒頭に来ている createApp 関数はクライアントサイドと共有しているファクトリメソッドのようなものです。なお、クライアントサイドと生成するマークアップを共有していることはSSRの重要な概念であるので、念頭をおいておくと良いです。

export const createApp = () => {
  return createSSRApp({
    data() {
      return {
        count: 0
      };
    },
    template: `
    <button @click="count++">increment</button>
    <p>count: {{ count }}</p>
    `,
  });
};

renderToString からつながる then 関数のコールバック引数はcreateAppが返すマークアップです。標準出力に出してみると以下のようになります。

<!--[--><button>increment</button><p>count: 0</p><!--]-->

ブラウザで見ると、同じように[]で囲まれた箇所がHTMLとして解釈されていることを確認できます。

スクリーンショット 2023-05-10 21.07.37.png

SSRはテンプレートレンダリングではないのか

ここまでで、SSRはVue 3を使ったサーバサイドにおけるレンダリング技術だとわかりました。いっぽうで、それではさきほど定義したテンプレートレンダリングとなんら変わらないという疑問が浮かびます。

そこで、ここからはSSRとテンプレートレンダリングを分けるものがなにかを追いかけてみます。

テンプレートレンダリングは、JavaScriptおよびマークアップをひとまとめにして返却するレンダリングで、かつクライアントサイドではレンダリングされたマークアップの読み込み以外のことはしない。

ハイドレーション

そもそも、Vue 3/Nuxt 3といった宣言的なUIフレームワークは根底に「クライアントサイドでDOMを構築できる」という強烈な特徴を持っているのでした。SRモードにおいてもその特徴は活用されます。

さて、SSRモードを使う場合、、サーバから受信したマークアップはHTMLとして解釈できる状態になっているものの、リアクティビティや動的DOMの変更を含まない静的な構造になっているのみです。

試みに、さきほど引用したボタンのマークアップを、ブラウザでクリックしてみます。なにも起きないことがわかります。

<button @click="count++">increment</button>

つまり、静的なマークアップに対して各種イベントハンドラを組み込むステップが必要となります。

SSRモードでは、サーバから受信したマークアップをもとに、ブラウザ上でDOMツリーを構築し、さらにJavaScriptを使ってリアクティビティや仮想DOMの構築を行うプロセスを行い、動的なDOMに変換するそうしたプロセスをハイドレーションと呼びます。

Vue 3をベースにしたUIフレームワークであるQuasarにもハイドレーションの説明があります。

Client Side Hydration | Quasar Framework

Hydration refers to the client-side process during which Vue takes over the static HTML sent by the server and turns it into dynamic DOM that can react to client-side data changes.

Since the server has already rendered the markup, we obviously do not want to throw that away and re-create all the DOM elements. Instead, we want to “hydrate” the static markup and make it interactive.

テンプレートレンダリングと比較すると、Vue.jsにおけるSSRは受信したDOMを仮想的なものとして動的に扱えるようにするのが大きく異なる。

おまけ: Hydrationの語源

クライアントサイドで動的なDOMにするプロセスを”hydration”と呼ぶのが、一般的な言い回しでなくてわかりづらかったのですが、ReactやVue 3といったUIフレームワーク固有の用語のようです。少なくとも他の用法は観測できませんでした。

Hydration (web development))

他のSSRの解説記事に引用されていた、こちらの回答がイメージ持ちやすそうです。

急速真空乾燥したインスタント麺を、熱湯三分でもとどおりの味と香りにするようなものです。

Reactである「Hydrate」って何でしょうか?

その他

SSRにおけるライフサイクルフック

ここまでで述べたように、SSRモードではサーバサイドで静的なマークアップレンダリングするため、初期レンダリング時点ではDOMの更新やリアクティビティが発生されない状態となります。そのため、SSRを利用する場合 onMountedonUpdated といったライフサイクルフックはトリガーされません。

setup() あるいは <script setup> のルートスコープでクリーンアップが必要になるような副作用を発生させるようなコードは避けるべきでしょう。

サーバーサイドレンダリング (SSR) | Vue.js

実業務でSSRを使う場合は、それら初期化型ライフサイクルフックがトリガーされないことに留意する必要があります。