Skip to content

SSR 完整分析与 Hydration 方案

一、为什么需要数据水合(Hydration)

SSR 的渲染流程分两个阶段:

  1. 服务端:Node.js 进程运行 Vue 应用,执行组件生命周期钩子,发起接口请求,将最终 HTML 字符串返回给浏览器
  2. 客户端:浏览器收到 HTML 后立即展示(用户看到内容),随后 Vue 重新在客户端"激活"同一套组件树,绑定事件监听器

客户端激活时,需要重新执行组件逻辑,如果没有将服务端的状态传递过来,会出现以下问题:

问题一:重复请求 服务端已经请求过的接口,客户端会再次发送相同请求。虽然 Nuxt 的 useAsyncData 可以解决这一点(它会把接口数据一并注入 payload),但状态管理库中的其他派生状态仍需恢复。

问题二:页面闪烁 客户端激活时,store 从默认初始值开始,页面先按默认值渲染(与服务端 HTML 不符),等数据加载完毕后再次更新,中间出现视觉闪烁(FOUC)。

问题三:服务端独有逻辑 某些代码只在服务端执行(如读取 Cookie、访问 Node.js API),客户端无法重现,导致状态不一致。

结论:需要把服务端渲染完成后的状态序列化,传递到客户端,让客户端直接从这个状态启动,跳过重新计算的过程。


二、Pinia 的 SSR 实现机制

核心设计

Pinia 的整个 SSR 支持建立在两个基础设计之上:

  1. 每个 store 有唯一字符串 IDdefineStore('user', ...) 的第一个参数,是序列化时的 key
  2. 所有 state 集中在 pinia.state.value:这是一个扁平对象,结构为 { [storeId]: stateObject }

这两点使得序列化/反序列化极其简单——只需操作这一个对象,不需要遍历每个 store。

Vite SSR 场景

ts
// entry-server.ts
const pinia = createPinia()
app.use(pinia)
await renderToString(app)
// 渲染过程中所有被访问的 store 自动注册到 pinia.state.value
initialState.pinia = pinia.state.value  // 序列化交给外层框架(vite-ssr)

// entry-client.ts
const pinia = createPinia()
pinia.state.value = initialState.pinia  // 必须在 app.use(pinia) 之前恢复
app.use(pinia)
app.mount('#app')

序列化(JSON.stringifydevalue)和把数据注入 HTML 的工作由 vite-ssr 框架负责,Pinia 只负责提供/消费 pinia.state.value

Nuxt 场景(@pinia/nuxt

ts
// @pinia/nuxt 插件源码(packages/nuxt/src/runtime/plugin.vue3.ts)
const pinia = createPinia()
nuxtApp.vueApp.use(pinia)

if (import.meta.server) {
  nuxtApp.payload.pinia = toRaw(pinia.state.value)  // 服务端:写入 payload
} else if (nuxtApp.payload?.pinia) {
  pinia.state.value = nuxtApp.payload.pinia          // 客户端:从 payload 恢复
}

与 vite-ssr 相比,代码结构几乎相同,差别只是用 nuxtApp.payload 替代了 initialState


三、Nuxt Payload 机制详解

什么是 nuxtApp.payload

payload 是 Nuxt 管理的一个结构化数据对象,专门用于从服务端向客户端传递状态。它不是一个普通变量,而是由 Nuxt 框架完整接管序列化、注入、读取全流程的机制。

与"直接注入 script 标签"的区别

维度直接注入 <script>window.__state = ...</script>Nuxt Payload 机制
序列化格式手动选择(JSON.stringify 有限制,无法处理 Date、Map、Set、循环引用等)自动使用 @nuxt/devalue,支持复杂 JS 类型
注入位置固定在 HTML 某处,可能阻塞渲染支持流式(streaming)传输,随 HTML 分块下发
Streaming SSR 兼容性不兼容(必须等所有数据就绪才能注入)兼容,payload chunk 可以在 HTML stream 中间插入
安全性需要手动做 XSS 转义(< > & 需要转义)框架自动处理
客户端读取手动读取 window.__state,需要约定字段名框架自动读取,插件通过 nuxtApp.payload 直接访问
useAsyncData 集成无法集成useAsyncData 的结果本身也写入 payload,统一管理
Devtools 支持Nuxt Devtools 可以检查 payload 内容

Nuxt Payload 的工作流程

服务端

  1. Nuxt 在内部创建一个 payload 对象(初始为 {}
  2. 各插件(包括 @pinia/nuxt)向 payload 写入数据
  3. useAsyncData 将接口结果也写入 payload.data
  4. 渲染完成后,Nuxt 用 @nuxt/devalue 将整个 payload 序列化
  5. 序列化结果以 <script>window.__nuxt_payload = ...</script> 的形式注入 HTML(流式场景下可以分块插入)

客户端

  1. Nuxt 启动时读取 window.__nuxt_payload,用 @nuxt/devalue 反序列化
  2. 恢复为结构化的 nuxtApp.payload 对象
  3. 各插件从 nuxtApp.payload 中读取自己的部分进行恢复
  4. useAsyncData 检测到 payload.data 中有缓存,不重复发请求

@nuxt/devalue vs JSON.stringify

ts
// JSON.stringify 的局限
JSON.stringify(new Date())   // → '"2024-01-01T00:00:00.000Z"'(字符串,类型丢失)
JSON.stringify(new Map())    // → '{}'(数据丢失)
JSON.stringify(undefined)   // → undefined(丢失)

// devalue 支持
devalue(new Date())          // 保留 Date 类型
devalue(new Map([['a', 1]])) // 保留 Map 类型
devalue({ a: undefined })   // 保留 undefined
devalue(circular)           // 处理循环引用

这对 store 中包含 DateMapSet 等非 JSON 安全类型的字段至关重要。


四、use-vue-service 的 SSR 现状

已解决的问题

请求间状态污染:已有明确规范。

  • 有用户相关状态的服务 → 使用 declareAppProviders(每请求一个 App 实例,天然隔离)
  • 无用户相关状态的全局服务 → 可以使用 declareRootProviders
  • 无需新增 API,遵循规范即可

尚未解决的问题:客户端数据恢复

本库与 Pinia 的根本差异导致无法直接套用其方案:

对比项Piniause-vue-service
状态容器全局单例 pinia 实例树状容器(组件级/App级/Root级)
唯一标识每个 store 有显式字符串 ID服务类没有全局唯一字符串 ID
状态聚合pinia.state.value 一次性收集需要遍历整个容器树
序列化边界只序列化 state(不含 action/getter)没有类似的明确边界

潜在方案分析

方案 A:@Serializable('id') + @Persist 装饰器(推荐方向)

ts
@Serializable('user-service')  // 提供全局唯一 ID
@Injectable()
class UserService {
  @Persist() name = 'Alice'    // 只有标记的字段参与序列化
  @Persist() age = 18
  tmpCache = {}                // 不参与序列化
}
  • 序列化:遍历容器树,收集所有 @Serializable 实例,按 ID 聚合 @Persist 字段
  • 反序列化:客户端实例初始化时,按 ID 查找 payload,逐字段恢复
  • 优点:粒度细,显式,不污染无关字段,语义清晰
  • 缺点:需要两个新装饰器;多层容器中同一类有多个实例时需要设计 key 的区分策略

方案 B:Angular 风格的 TransferState + makeStateKey

参考 Angular 的实现:用户手动创建 key,手动读写,框架只负责传递 map 对象。

  • 优点:实现简单,用户完全控制
  • 缺点:心智负担重,key 管理完全交给用户,复杂场景下容易冲突

方案 C:服务自定义 snapshot/restore

ts
@Injectable()
class UserService implements Hydratable {
  name = 'Alice'
  snapshot() { return { name: this.name } }
  restore(data: any) { this.name = data.name }
}
  • 优点:灵活,支持 Date/Map 等复杂类型的自定义序列化
  • 缺点:每个服务都要手写两个方法,模板代码多

推荐:方案 A 作为主路径(覆盖 80% 常见场景),方案 C 作为逃生舱口(供需要自定义序列化逻辑的场景使用),两者可以组合。


五、参考资料