メインコンテンツまでスキップ

Composable とサーバー描画 — SSR で壊れる書き方

Nuxt のようにサーバーでも描画する構成では、script setup のトップレベルと Composable の本体がサーバー上でも実行されます。ブラウザだけで動かす前提で書いた Composable は、ここで壊れます。

前章で作った Composable を題材に、壊れ方を 3 通り見ていきます。どれも「いつ実行されるか」の読み違いから起きます。

この章で学ぶこと

  • window を読むタイミングをどこまで遅らせる必要があるか
  • サーバーで登録が止まるものと、止まらないものを区別できる
  • モジュールスコープに置いた状態が利用者間で漏れる仕組みが分かる
  • 共有状態を Composable に持たせるかどうかを判断できる

window を呼び出し時点で読む

// サーバーでは window が無いので落ちる
export function useWindowWidth() {
const width = ref(window.innerWidth)
// ...
}

Node.js には window がないので、renderToString の時点で window is not defined が投げられます。

対象を引数で受け取る設計にしても、渡す時点で評価されるので同じです

// 対象を第 1 引数で受ける設計だとして
useEventListener(window, 'resize', sync)
// ^^^^^^ ここで window を読んでいる

8 章useEventListener が対象を引数に取らず内部で window を読んでいるのは、この評価を onMounted の内側に閉じ込めるためです。

回避は、読み取りをマウント後のフックまで遅らせることです。公式も「ブラウザ専用の API は onMounted のようなクライアント専用のフックの中で遅延して触る」形を挙げています。

composables/useWindowWidth.ts
import { computed, onMounted, ref } from 'vue'
import { useEventListener } from './useEventListener'

export function useWindowWidth(fallback = 1024) {
const width = ref(fallback)
const isMobile = computed(() => width.value < 768)

const sync = () => {
width.value = window.innerWidth
}

onMounted(sync)
useEventListener('resize', sync)

return { width, isMobile }
}

初期値を引数で受けているのは、サーバーが描く HTML にこの値が入るからです。クライアントで onMounted が走った時点で実際の幅に置き換わります。

サーバーで登録が止まるもの

サーバー側では onMounted の中身は走りません。より正確には、SSR のコンポーネント setup 中はフックがそもそも登録されません。走らないのではなく、登録の段で止まっています。

同じことが watcher にも起きます。 immediate を付けない watch(src, cb) はサーバーでは作られないので、描画後に source が変わっても呼ばれません。watchEffect は既定では 1 度走りますが、これは「初回に必ず走る」種類だからで、flush: 'post' を付けると同じく作られません。

フックの抑制は、共通の生成関数を通るものにかかります。マウント・更新・破棄の系列 (onBeforeMount から onUnmounted まで) と、デバッグ用の onRenderTracked / onRenderTriggered です。onServerPrefetch はサーバーで走るために例外として通り、onErrorCaptured / onActivated / onDeactivated はこの生成関数を通らないので抑制の対象外です。

onMounted に閉じ込めていない副作用

上の抑制はフックと watcher の登録にしか効きません。その外に書いた副作用は、サーバーでもそのまま動き出します。

8 章useTicker がこれに当たります。呼び出し時点で setInterval を始めるので、サーバー描画が終わった後もタイマーが動き続け、onScopeDispose は呼ばれません (スコープが止まらないため)。コンポーネント専用と割り切れるなら、開始を onMounted へ移します。

composables/useTickerClient.ts
import { onMounted, onScopeDispose } from 'vue'

export function useTickerClient(onTick: () => void, interval = 1000) {
let id: ReturnType<typeof setInterval> | undefined

onMounted(() => {
id = setInterval(onTick, interval)
})

onScopeDispose(() => {
if (id !== undefined) clearInterval(id)
})
}

こちらは effectScope() の中では始まりません。コンポーネントの外でも動かしたいか、サーバーで動かしたくないか、どちらを取るかの選択になります。両方が要る場合は実行環境で分岐することになりますが、その形は 19 章で扱います。

モジュールスコープに状態を置く

ref をモジュールのトップレベルに置くと、どのコンポーネントから呼んでも同じ状態を指します。

composables/useAuth.ts
import { computed, ref } from 'vue'

// モジュールのトップレベル
const user = ref<{ name: string } | null>(null)

export function useAuth() {
const userName = computed(() => user.value?.name ?? 'ゲスト')
const signIn = (name: string) => {
user.value = { name }
}
return { user, userName, signIn }
}

ブラウザだけで動かすならこれは意図どおりです。ページを開き直すたびにモジュールが初期化されるので、状態の寿命はタブと一致します。

サーバーでは違います。 モジュールはサーバーが起動したときに一度だけ初期化され、以降のリクエストで同じインスタンスが使い回されます。ある利用者のデータで書き換えると、別の利用者のリクエストに漏れます。公式はこれを cross-request state pollution と呼んでいます。

同じプロセスで 2 回描画すると再現します。1 回目に signIn('田中') を通すと、サインインしていない 2 回目の描画で「ゲスト」ではなく「田中」が出ます。

公式が挙げる回避策は、リクエストごとにアプリと store を作り、app レベルの provide で渡すことです。コンポーネントから直接 import しないようにします。具体形は 12 章17 章で扱います。

共有状態が要るなら Pinia を使うのが実務上の答えです。Composable のモジュールスコープに状態を置くのは、クライアント専用と割り切れる場合に限ります。

データ取得を Composable に持たせる場合

8 章useFetch をサーバーでも描画する構成に置くと、watchEffect はサーバーで 1 度走り、直後に後片付けが呼ばれますrenderToString は走り出した非同期処理を待たないので、サーバーが返す HTML には読み込み中の状態が入ります。

この挙動は flush を既定のままにしている場合のものです。 flush: 'post' を付けた場合や watchPostEffect を使った場合は、サーバーでは一度も走りません。DOM 更新後に動かす指定なので、DOM のないサーバーでは実行対象から外れます。

サーバー側でデータを埋めるには別の仕組みが要るので、18 章19 章で扱います。

まとめ

  • サーバーでも描画する構成では、script setup のトップレベルと Composable の本体がサーバー上でも実行されます
  • window は呼び出し時点で読まず、onMounted まで遅らせます。 引数として渡す形も、渡す側で読んでいることになります
  • 初期値を引数で受けると、サーバーが描く HTML にその値が入ります。クライアントで onMounted が走った時点で実際の値に置き換わります
  • SSR で登録が止まるのはフックと watcher です。 immediate を付けない watch(src, cb) は作られず、watchEffect は既定なら 1 度走ります (flush: 'post' は作られません)
  • フックの抑制には例外があります。onServerPrefetch は通り、onErrorCaptured / onActivated / onDeactivated は抑制の対象外です
  • 抑制の外に書いた副作用は素通りします。 呼び出し時点で始めたタイマーはサーバー描画の後も動き続けます
  • モジュールスコープに置いた状態はリクエスト間で漏れます (cross-request state pollution)。リクエストごとにアプリと store を作り、app レベルの provide で渡すのが公式の回避策です
  • 共有状態が要るなら Pinia を使います。Composable のモジュールスコープに置くのは、クライアント専用と割り切れる場合だけです
関連リファレンス

次に読む