hacomono TECH BLOG

フィットネスクラブやスクールなどの顧客管理・予約・決済を行う、業界特化型SaaS「hacomono」を提供する会社のテックブログです!

Nuxt.jsのSSRについて改めて学んでみた

プロダクト開発チームのまっつんです。

今期から機能開発のチームではなくパフォーマンス改善の部隊にジョインすることになりました。

初手のタスクはフロント側の改善タスクでした。フロント側は今までなんとなくで開発しちゃってたところがあったので、これを機にフロント側の理解をさらに深めようと思い試してみたことを記事にしてみました。

今回は、何番煎じだという感じですがSSRについて自分の言葉でまとめました。

前提知識の確認

SSRについて

クライアントがサーバーにリクエストすると、サーバー側でHTML(①)をレンダリングして返します。

クライアント側は①とJavascriptを評価し、静的な①をインタラクティブにするためhydrationを実行し、クライアント側で生成した実DOMに差分を反映していきます。

この一連の処理をSSRと呼びます。

nuxt.com

mountedというライフサイクルフックがありますが、これはCSRだけで実行されます。このあたりの挙動もSSRと合わせて比較しつつ確認していきたいと思います。

nuxt.com

検証

検証してみた環境

  • node: v18.7.0
  • nuxt: 3.1.2

    • 公式のInstallationを参考にサンプルプロジェクトを作成。

    nuxt.com

      export default defineNuxtConfig({
        ssr: true
      })
    
  • Google Chrome: 109.0.5414.119

①SSRで実行された結果を確認してみる

app.vueの中身を以下のように変更します。

<script setup lang="ts">
const title = ref<string>('タイトル')
</script>

<template>
  <div>
    <h1>{{ title }}</h1>
  </div>
</template>

ブラウザで Chrome dev tools を開き Network タブを開いてアクセスして、PreviewとResponse、実際にブラウザに表示されているものを確認してみます。

ブラウザアクセス時の html のレスポンスのプレビュー

<!DOCTYPE html>
<html >
<head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><link rel="modulepreload" as="script" crossorigin href="/_nuxt/Users/matsuo/sandbox/nuxt-sample/lifecycle-test/node_modules/nuxt/dist/app/entry.mjs"></head>
<body ><div id="__nuxt"><div><h1>タイトル</h1></div></div><script>window.__NUXT__={data:{},state:{},_errors:{},serverRendered:true,config:{public:{},app:{baseURL:"\u002F",buildAssetsDir:"\u002F_nuxt\u002F",cdnURL:""}}}</script><script type="module" src="/_nuxt/@vite/client" crossorigin></script><script type="module" src="/_nuxt/path/to/node_modules/nuxt/dist/app/entry.mjs" crossorigin></script></body>
</html>

ブラウザでの表示

サーバー側でレンダリングされたものがそのままブラウザに表示されていることが分かりました。

②onMountedでtitleの値を変更してみる

onMountedはクライアントサイドでのみ実行されるライフサイクルフックです。ここでタイトルを、「タイトル」から「タイトル(CSR)」に変更してみます。

app.vue を以下のように変えます。

<script setup lang="ts">
const title = ref<string>('タイトル')

onMounted(() => {
  title.value = 'タイトル(CSR)'
})
</script>

<template>
  <div>
    <h1>{{ title }}</h1>
  </div>
</template>

①のときと同様にブラウザで Chrome dev tools を開き Network タブを開いてアクセスして、PreviewとResponseを確認してみます。

ブラウザアクセス時の html のレスポンスのプレビュー

<!DOCTYPE html>
<html >
<head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><link rel="modulepreload" as="script" crossorigin href="/_nuxt/Users/matsuo/sandbox/nuxt-sample/lifecycle-test/node_modules/nuxt/dist/app/entry.mjs"></head>
<body ><div id="__nuxt"><div><h1>タイトル</h1></div></div><script>window.__NUXT__={data:{},state:{},_errors:{},serverRendered:true,config:{public:{},app:{baseURL:"\u002F",buildAssetsDir:"\u002F_nuxt\u002F",cdnURL:""}}}</script><script type="module" src="/_nuxt/@vite/client" crossorigin></script><script type="module" src="/_nuxt/path/to/node_modules/nuxt/dist/app/entry.mjs" crossorigin></script></body>
</html>

ブラウザでの表示

SSRされた結果(PreviewとResponse)は①と同じですが、実際にブラウザに表示されている文字は「タイトル(CSR)」になっています。

これは初回描画時は「タイトル」が表示されて、クライアント側でライフサイクルメソッドであるonMountedが実行されて、titleの値が「タイトル(CSR)」に変わったことを意味します。

※ 性能が良いPCだと一瞬なので最初から「タイトル(CSR)」が表示されているように見えてしまうことがありますが、Networkタブで速度をSlow 3Gにして試してみると挙動がよりわかりやすいと思います。

③windowオブジェクトにアクセスしてみる

window.location.hrefを画面に表示させたいとします。試しに素直に書いてみます。

<script setup lang="ts">
const location = ref<Location>()
location.value = window.location
</script>

<template>
  <div>
    {{ location?.href }}
  </div>
</template>

すると、 ReferenceError: window is not defined というエラーが出ると思います。これはSSR側(node.js)ではグローバルにwindowオブジェクトが無いためです。

windowオブジェクトにアクセスして何かしたい場合はクライアント(ブラウザ)側で評価する必要があるので、onMountedでwindowを参照するように変更してみます。

<script setup lang="ts">
const location = ref<Location>()

onMounted(() => {
  location.value = window.location
})
</script>

<template>
  <div>
    {{ location?.href }}
  </div>
</template>

これで無事画面にlocation.hrefの値を表示させることができます。

ブラウザでの表示

④ClientOnlyコンポーネントを試してみる

その名の通りクライアントサイドでのみ評価されるコンポーネントの書き方があります。

nuxt.com

app.vueを次のように変えて試してみます。

<script setup lang="ts">
const title = ref<string>('タイトル')
</script>

<template>
  <ClientOnly fallback-tag="h1" fallback="Loading...">
    <div>
      <h1>{{ title }}</h1>
    </div>
  </ClientOnly>
</template>

これまでと同様にブラウザで Chrome dev tools を開き Network タブを開いてアクセスして、PreviewとResponseを確認してみます。

ブラウザアクセス時の html のレスポンスのプレビュー

<!DOCTYPE html>
<html >
<head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><link rel="modulepreload" as="script" crossorigin href="/_nuxt/Users/matsuo/sandbox/nuxt-sample/lifecycle-test/node_modules/nuxt/dist/app/entry.mjs"></head>
<body ><div id="__nuxt"><h1>Loading...</h1></div><script>window.**NUXT**={data:{},state:{},_errors:{},serverRendered:true,config:{public:{},app:{baseURL:"\u002F",buildAssetsDir:"\u002F_nuxt\u002F",cdnURL:""}}}</script><script type="module" src="/_nuxt/@vite/client" crossorigin></script><script type="module" src="_nuxt/path/to/node_modules/nuxt/dist/app/entry.mjs" crossorigin></script></body>
</html>

ブラウザでの表示

fallbackに指定した「Loading…」がサーバーサイドでレンダリングされており、ClientOnlyコンポーネントの内側の要素はクライアント側でのみ評価されたことが分かります。

感想

各ライフサイクルフックに log を仕込んでどのタイミングで発火するか確認してみるのもよいと思いますが、実際にどのような挙動になるのかをイメージしたくこのような検証をしてみました。

ライフサイクルについてはなんとなく理解はしてましたが、「他のページでもこの処理をmountedのところに書いてあるから、このページもそうしよ!」みたいに書いてしまうことがありました。これからはなぜmountedに書くのか、mountedに書いたらSSRされないけど本当に大丈夫か、というような観点等も考慮して機能開発・改修を進めようと思います。