Skip to content

09|SSR 下的组合式函数 ​

第四章概览 / 09。目标:把“每次 composable 调用隔离”扩大为“每次 HTTP 请求隔离”,并明确服务端、首轮水合与挂载后的浏览器边界。

同一组件会在两个环境执行 ​

SSR 首次请求在服务器执行模块与 setup,生成 HTML;浏览器加载同一应用后再次执行并水合现有 DOM。两次运行共享组件代码,却不共享平台能力:服务器没有 window / document,浏览器也没有服务端请求上下文。

阶段会执行不会执行或不能假设
模块求值import、模块顶层常量与可变单例不能直接读取浏览器全局
服务端 setup / rendercomposable、inject、onServerPrefetch、模板渲染onMounted / onUnmounted 不执行,没有真实 DOM
客户端首次 render / hydrationsetup、模板计算,与服务端 DOM 对齐首屏数据和随机值不能任意变化
客户端 mounted 以后DOM ref、事件监听、ResizeObserver、localStorage这部分不会补进服务端 HTML

服务端渲染不会动态更新 DOM,Vue 默认也会关闭不必要的服务端响应式追踪。不要在 setup 根部启动需要卸载清理的永久计时器:服务端不会调用卸载钩子。

最小可运行例子:并行请求不串用户 ​

把下面文件放进已安装 Vue 与 @vue/server-renderer 的项目,执行 node ssr-isolation.mjs。

js
import assert from 'node:assert/strict'
import {
  createSSRApp,
  h,
  inject,
  readonly,
  ref
} from 'vue'
import { renderToString } from '@vue/server-renderer'

const RequestStateKey = Symbol('request-state')

function createRequestState(userName) {
  const user = ref(userName)
  return { user: readonly(user) }
}

function useRequestState() {
  const state = inject(RequestStateKey)
  if (!state) throw new Error('缺少请求级状态')
  return state
}

const Root = {
  setup() {
    const { user } = useRequestState()
    return () => h('p', `hello ${user.value}`)
  }
}

async function renderRequest(userName) {
  const app = createSSRApp(Root)
  const state = createRequestState(userName)
  app.provide(RequestStateKey, state)
  return renderToString(app)
}

const [aliceHtml, bobHtml] = await Promise.all([
  renderRequest('Alice'),
  renderRequest('Bob')
])

assert.match(aliceHtml, /Alice/)
assert.doesNotMatch(aliceHtml, /Bob/)
assert.match(bobHtml, /Bob/)
assert.doesNotMatch(bobHtml, /Alice/)
console.log('SSR request isolation passed')

隔离来自 renderRequest() 每次创建 app 和 state,而不是来自 ref 这个名字。真实项目还应每请求创建 router 与 store,并把最终状态安全序列化给客户端。

反例:模块单例会跨请求活着 ​

ts
// 错误:服务器启动时创建一次,随后所有请求复用。
const currentUser = ref<string | null>(null)

export function useCurrentUser() {
  return currentUser
}

在纯客户端页面中,模块单例可用于显式共享;在 SSR 服务进程里,A 请求写入后 B 请求可能读取同一对象。不能靠“每次 render 前清空”修补并发竞态,应把状态创建放进每请求 app 工厂,再 provide / inject。

浏览器边界:首屏确定,挂载后接入 ​

ts
import { onMounted, onScopeDispose, readonly, ref } from 'vue'

export function useViewport() {
  const width = ref(0)

  function update() {
    width.value = window.innerWidth
  }

  onMounted(() => {
    update()
    window.addEventListener('resize', update)
  })
  onScopeDispose(() => {
    if (typeof window !== 'undefined') {
      window.removeEventListener('resize', update)
    }
  })

  return { width: readonly(width) }
}

服务器和客户端第一次 render 都看到 0,因此初始 HTML 可对齐;mounted 后才读取真实宽度。若首屏必须按断点输出,应由服务器可知的请求信息或框架策略提供确定初值,而不是在两端各猜一次。

typeof window 守卫只避免抛错,不自动保证水合一致,也不证明功能在服务端执行。把平台实现作为依赖传入,通常比在 composable 各处散落守卫更容易测试。

服务端取数与水合 ​

组件可用 onServerPrefetch() 在服务端等待数据;客户端如果不是由 SSR 创建,通常还要在 onMounted() 做 fallback:

ts
const data = shallowRef<Item[] | null>(null)

onServerPrefetch(async () => {
  data.value = await loadItems()
})

onMounted(async () => {
  if (data.value === null) data.value = await loadItems()
})

真正的 SSR 应把服务端结果序列化并恢复到客户端 store,否则客户端首次 render 仍是 null,会造成重复请求或水合差异。序列化还要防 XSS;优先使用 Nuxt 等框架提供的数据与 payload 机制,不手写一套不完整协议。

水合差异不是“客户端会修”的借口 ​

常见来源包括无效 HTML 嵌套、两端不同随机数、时区格式化和认证状态不一致。Vue 会尝试恢复,但会丢弃或重建节点,既有性能成本也可能隐藏数据错误。

Vue 3.5+ 的 data-allow-mismatch 只用于已知、不可避免且局部的差异;它不提供请求隔离,也不修复 DOM。认证、权限、列表长度、随机 ID 等结构差异应回到数据来源解决。

同步注册在 SSR 中仍成立 ​

onServerPrefetch、inject 与 composable 调用同样依赖当前组件实例,应在 setup 同步调用链中登记。回调本身可以返回 Promise,但不能等一次无关的 await 后再决定要不要注册。每个请求 app 创建自己的实例树,钩子归属这棵树。

练习:证明请求隔离与浏览器边界 ​

扩展 ssr-isolation.mjs:

  • 请求状态增加 requestId 与 userName,并行渲染 A / B,各自 HTML 只能包含自己的两个值。
  • 故意把 state 移到模块顶层,使用可控延迟稳定复现污染;恢复工厂后测试通过。
  • 在根组件调用 useViewport(),服务端 HTML 必须输出宽度 0,且 Node 不出现 window is not defined。
  • 解释 mounted 后宽度变化为何不是水合错误;若服务端与客户端首次 render 初值不同,测试必须失败。
  • 若加入取数,数据按请求注入并序列化;Abort 或请求失败不能写进其他请求的 state。

参考答案:查看第 09 题。

用心记录每一步成长