SSR 完整分析与 Hydration 方案
一、为什么需要数据水合(Hydration)
SSR 的渲染流程分两个阶段:
- 服务端:Node.js 进程运行 Vue 应用,执行组件生命周期钩子,发起接口请求,将最终 HTML 字符串返回给浏览器
- 客户端:浏览器收到 HTML 后立即展示(用户看到内容),随后 Vue 重新在客户端"激活"同一套组件树,绑定事件监听器
客户端激活时,需要重新执行组件逻辑,如果没有将服务端的状态传递过来,会出现以下问题:
问题一:重复请求 服务端已经请求过的接口,客户端会再次发送相同请求。虽然 Nuxt 的 useAsyncData 可以解决这一点(它会把接口数据一并注入 payload),但状态管理库中的其他派生状态仍需恢复。
问题二:页面闪烁 客户端激活时,store 从默认初始值开始,页面先按默认值渲染(与服务端 HTML 不符),等数据加载完毕后再次更新,中间出现视觉闪烁(FOUC)。
问题三:服务端独有逻辑 某些代码只在服务端执行(如读取 Cookie、访问 Node.js API),客户端无法重现,导致状态不一致。
结论:需要把服务端渲染完成后的状态序列化,传递到客户端,让客户端直接从这个状态启动,跳过重新计算的过程。
二、Pinia 的 SSR 实现机制
核心设计
Pinia 的整个 SSR 支持建立在两个基础设计之上:
- 每个 store 有唯一字符串 ID:
defineStore('user', ...)的第一个参数,是序列化时的 key - 所有 state 集中在
pinia.state.value:这是一个扁平对象,结构为{ [storeId]: stateObject }
这两点使得序列化/反序列化极其简单——只需操作这一个对象,不需要遍历每个 store。
Vite SSR 场景
// 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.stringify、devalue)和把数据注入 HTML 的工作由 vite-ssr 框架负责,Pinia 只负责提供/消费 pinia.state.value。
Nuxt 场景(@pinia/nuxt)
// @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 的工作流程
服务端:
- Nuxt 在内部创建一个
payload对象(初始为{}) - 各插件(包括
@pinia/nuxt)向payload写入数据 useAsyncData将接口结果也写入payload.data- 渲染完成后,Nuxt 用
@nuxt/devalue将整个payload序列化 - 序列化结果以
<script>window.__nuxt_payload = ...</script>的形式注入 HTML(流式场景下可以分块插入)
客户端:
- Nuxt 启动时读取
window.__nuxt_payload,用@nuxt/devalue反序列化 - 恢复为结构化的
nuxtApp.payload对象 - 各插件从
nuxtApp.payload中读取自己的部分进行恢复 useAsyncData检测到payload.data中有缓存,不重复发请求
@nuxt/devalue vs JSON.stringify
// 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 中包含 Date、Map、Set 等非 JSON 安全类型的字段至关重要。
四、use-vue-service 的 SSR 现状
已解决的问题
请求间状态污染:已有明确规范。
- 有用户相关状态的服务 → 使用
declareAppProviders(每请求一个 App 实例,天然隔离) - 无用户相关状态的全局服务 → 可以使用
declareRootProviders - 无需新增 API,遵循规范即可
尚未解决的问题:客户端数据恢复
本库与 Pinia 的根本差异导致无法直接套用其方案:
| 对比项 | Pinia | use-vue-service |
|---|---|---|
| 状态容器 | 全局单例 pinia 实例 | 树状容器(组件级/App级/Root级) |
| 唯一标识 | 每个 store 有显式字符串 ID | 服务类没有全局唯一字符串 ID |
| 状态聚合 | pinia.state.value 一次性收集 | 需要遍历整个容器树 |
| 序列化边界 | 只序列化 state(不含 action/getter) | 没有类似的明确边界 |
潜在方案分析
方案 A:@Serializable('id') + @Persist 装饰器(推荐方向)
@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
@Injectable()
class UserService implements Hydratable {
name = 'Alice'
snapshot() { return { name: this.name } }
restore(data: any) { this.name = data.name }
}- 优点:灵活,支持 Date/Map 等复杂类型的自定义序列化
- 缺点:每个服务都要手写两个方法,模板代码多
推荐:方案 A 作为主路径(覆盖 80% 常见场景),方案 C 作为逃生舱口(供需要自定义序列化逻辑的场景使用),两者可以组合。