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 のようなクライアント専用のフックの中で遅延して触る」形を挙げています。
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 へ移します。
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 をモジュールのトップレベルに置くと、どのコンポーネントから呼んでも同じ状態を指します。
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 のモジュールスコープに置くのは、クライアント専用と割り切れる場合だけです
- Vue — Server-Side Rendering — cross-request state pollution とプラットフォーム固有 API
- Vue — Composables — 副作用を
onMountedに置く指針 - Vue — SSR API —
renderToStringとdata-allow-mismatch
次に読む
- コンポーネント間の v-model —
defineModelで親子の双方向を宣言する - Nuxt — サーバー描画を前提にしたデータ取得とディレクトリ構成