Airi 中 Pinia 的服务端渲染实践:setup 中的 Store、状态水合与跨请求隔离
本文以 Airi 仓库中的 Pinia SSR 参考文档 advanced-ssr 为主体,完整讲解 Pinia 在 SSR 场景下的四条核心规则:在 setup 顶层调用 store、在组件外传入 pinia 实例、serverPrefetch / onServerPrefetch 的数据预取、以及基于 pinia.state.value 序列化与服务端到客户端的状态水合。读完之后,你可以掌握一套可直接落地的 SSR + Pinia 集成方案,并理解 Airi 仓库本身(createPinia 装配、跨窗口状态同步插件)是如何贯彻“每个运行时一个全新 Pinia 实例”这一隔离原则的。
文档定位与版本前提
该文档是 Airi 仓库 Pinia 技能 下 Advanced 分类中的 SSR 参考篇,与 Nuxt 集成篇 并列:如果你的项目使用 Nuxt,官方建议直接看 Nuxt 集成文档而非本篇;本篇面向原生 Vue + Vite(如 Vitesse 模板)或自行实现 SSR 的应用。
版本前提需要说明:
- Pinia 技能说明 标注该技能基于 Pinia v3.0.4(2026-01-28 生成),因此文档中的 API 约定以 v3 为基准;
- 而 Airi 工作区实际安装的是 pinia 4.0.3(见 pnpm-lock.yaml 中的
pinia@4.0.3(...)(vue@3.5.41)解析记录)。本文引用的createPinia、useStore(pinia)、pinia.state.value等 API 在两个版本中语义一致,但精确类型签名请以仓库锁文件中实际安装的版本为准。
基本用法:在 setup 顶层调用 store
SSR 环境下,Pinia 只有在组件的 setup 函数执行期间才知道“当前属于哪个应用实例、哪个请求上下文”。因此最基本的规则是:在 setup、getter、action 的顶层调用 store:
<script setup>
// ✅ Works - pinia knows the app context in setup
const main = useMainStore()
</script>
这一点与 Pinia 技能的核心建议 中“Call stores inside functions, not at module scope”相互呼应:模块顶层求值在 Node 服务进程中是全局一次性的,无法绑定到具体请求的 pinia 实例,这是 SSR 下最常见的上下文丢失来源。
在 setup() 之外使用 Store:显式传入 pinia 实例
路由守卫、中间件、插件等场景天然位于组件生命周期之外。此时必须把 pinia 实例作为第二个参数显式传给 useStore,让 Pinia 使用正确的 SSR 上下文:
const pinia = createPinia()
const app = createApp(App)
app.use(router)
app.use(pinia)
router.beforeEach((to) => {
// ✅ Pass pinia for correct SSR context
const main = useMainStore(pinia)
if (to.meta.requiresAuth && !main.isLoggedIn) {
return '/login'
}
})
这个装配顺序在 Airi 仓库的前端应用中可以得到印证。以 stage-web 的入口 为例,它的标准流程正是文档描述的模式:
const pinia = createPinia()
const synced = setupSynced()
pinia.use(synced.pinia)
if (import.meta.env.DEV)
pinia.use(piniaPluginTracing)
// ...
createApp(App)
.use(synced.vue)
.use(router)
.use(pinia)
// ...
注意两点仓库细节:
- Airi 的
router.beforeEach目前只承担 NProgress 进度条职责,尚未在守卫内直接读取 store;但这套createPinia()→app.use(pinia)的装配方式,正是文档中“守卫内useMainStore(pinia)”用法能够成立的前提——守卫闭包捕获的就是这个已安装到 app 上的实例。 - 同样的装配也出现在 Electron 桌面端渲染进程入口 stage-tamagotchi renderer main.ts,区别仅在于额外传入了
leadership策略(见下文“跨实例隔离”一节)。
serverPrefetch():Options API 下通过 this.$pinia 访问
Options API 组件的 serverPrefetch 钩子运行在服务端渲染之前,组件实例尚未完成完整挂载,但 Pinia 已通过插件安装到 app 上并注入为 $pinia:
export default {
serverPrefetch() {
const store = useStore(this.$pinia)
return store.fetchData()
},
}
这里的关键同样是“显式实例”原则:this.$pinia 由 Pinia 插件在 app 上提供,将其传给 useStore 即可在服务端安全地发起数据预取,返回的 Promise 会被 SSR 管线等待,保证首屏 HTML 已包含拉取到的数据。
onServerPrefetch():Composition API 的对等写法
<script setup> 中可以使用 Vue 的 onServerPrefetch,语义与 serverPrefetch 相同(仅服务端执行),store 则在 setup 顶层正常获取:
<script setup>
const store = useStore()
onServerPrefetch(async () => {
await store.fetchData()
})
</script>
两种写法的分工可以这样理解:
| 钩子 | API 风格 | store 获取方式 |
|---|---|---|
serverPrefetch() |
Options API | useStore(this.$pinia)(实例经组件注入) |
onServerPrefetch() |
Composition API | useStore()(setup 上下文内可直接调用) |
状态水合(State Hydration):服务端序列化 + 客户端恢复
SSR 的第二大主题是状态传递:服务端在渲染期间填充的 store 状态,必须序列化进 HTML,再由客户端在 hydration 阶段恢复,否则首帧会出现“服务端有数据、客户端 store 为空”的闪烁与不匹配。
服务端:XSS 安全的序列化
文档推荐使用 devalue 这类序列化库而非 JSON.stringify,原因是要防范状态内容被原样拼进 HTML 时产生 XSS 注入(JSON.stringify 对 </script> 等序列不转义):
import devalue from 'devalue'
import { createPinia } from 'pinia'
const pinia = createPinia()
const app = createApp(App)
app.use(router)
app.use(pinia)
// After rendering, state is available
const serializedState = devalue(pinia.state.value)
// Inject into HTML as global variable
注意 pinia.state.value 的形态:pinia.state 本身是一个 ref,其 .value 是“storeId → 该 store 的 state 对象”的映射。渲染完成后再读取,可确保 onServerPrefetch / serverPrefetch 中写入的状态全部落袋。
客户端:在任何 useStore() 调用之前水合
客户端装配时,必须在首次 useStore() 调用之前把状态写回,顺序错误会导致 store 以默认 state 创建后再被覆盖,或干脆丢失服务端状态:
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
// Hydrate from serialized state (e.g., from window.__pinia)
if (typeof window !== 'undefined') {
pinia.state.value = JSON.parse(window.__pinia)
}
typeof window !== 'undefined' 的判断保证同一份入口代码在 Node 端(SSR)与浏览器端都能运行。若服务端使用 devalue,客户端应对等使用其解析能力(文档示例为简化写作用 JSON.parse 示意,实际应与服务端序列化方式配对)。
跨请求状态污染:每请求一个全新 Pinia 实例
文档 Key Points 的最后一条直指 SSR + Pinia 最危险的坑:模块级单例 store 会在并发请求之间共享状态(用户 A 的会话数据泄漏到用户 B 的响应)。文档给出的对策是“为每个请求创建全新的 pinia 实例”,即:
- 调用 store 放在函数内部,而非模块作用域;
- 组件外使用 store 时始终传入
pinia实例; - 在任何
useStore()之前完成状态水合; - 使用
devalue或同类库做安全序列化; - 每请求(或每运行时)创建全新 pinia。
Airi 仓库自身是纯客户端(Web / Electron / Capacitor)应用,不存在 Node 服务端渲染路径,但从源码结构看,它把“每运行时一个全新 Pinia”这条原则落实到了多窗口隔离上,是同一原理的近亲:
- stage-web 入口 与 stage-tamagotchi renderer 入口 都各自执行
createPinia()。在 Electron 场景下每个渲染窗口是独立上下文,天然满足“一实例一上下文”,窗口之间不共享 store 内存; - 跨窗口共享需求则通过插件显式实现:setupSynced 基于
pinia-plugin-synced创建同步运行时,使用固定命名空间airi:stage:pinia并显式配置callTimeout: 5 * 60 * 1000(注释说明是因为聊天与图像生成类 action 可能超过插件默认 30 秒超时),同时把运行时通过injectKeyPiniaSynced提供给 Vue 组件、在pagehide/app.onUnmount时释放选举通道。从源码结构看,这是“隔离优先、同步按需”的设计:先保证各窗口 pinia 互不污染,再在受控通道上同步需要的状态。
Airi 的 Pinia 插件层:以 tracing 插件看插件在 SSR 语境下的行为边界
理解“pinia 实例绑定上下文”的最佳切入点之一是看插件如何被安装到实例上。Airi 在开发环境为每个 pinia 实例挂载 piniaPluginTracing:
export const piniaPluginTracing: PiniaPlugin = ({ store }) => {
// ...
store.$onAction(({ name, after, onError }) => {
// 记录 action 开始 / 完成 / 失败事件
})
}
该插件利用 Pinia 插件协议的 ({ store }) 回调获取当前实例的 store,通过 $onAction 的 after / onError 钩子广播 action 生命周期事件(started / completed / failed),并在开发态按 airi:debug:pinia-tracing 开关每 5 秒输出一次 action/mutation 速率摘要。两个细节与 SSR 主题相关:
- 插件回调里所有代码都只依赖传入的
store参数,不依赖全局单例——这与 SSR“一切以当前 pinia 实例为准”的规则同构,也是插件能同时跑在 Node 与浏览器两端的原因(源码中对typeof window === 'undefined'做了防护); - 其单元测试(pinia-plugin-tracing.test.ts)在 Vitest 环境中通过
createPinia()构造独立实例来驱动断言,验证的正是“实例级”而非“全局级”的行为。
关键要点速查
综合文档 Key Points 小节,SSR 场景下使用 Pinia 的五条铁律:
- 在函数内调用 store,禁止模块作用域顶层调用——避免 Node 进程中跨请求共享上下文;
- 组件外(守卫、插件、中间件)使用时传入
pinia实例——useStore(pinia)是正确上下文的唯一保证; - 水合先于一切
useStore()——客户端在首次取 store 前把pinia.state.value还原; - 使用
devalue或同类库做 XSS 安全序列化——不要裸奔JSON.stringify拼进 HTML; - 每请求创建全新 pinia——从根上杜绝跨请求状态污染。
进一步阅读的仓库路径
- Pinia SSR 参考文档:本文主体
- Pinia 技能入口:core-stores / plugins / testing / HMR 等其余参考篇导航
- Nuxt 集成参考:使用 Nuxt 时的替代方案
- 组件外使用 store 的最佳实践:守卫、中间件场景的姊妹篇
- stage-web 应用入口 与 stage-tamagotchi renderer 入口:Airi 实际的
createPinia+ 插件装配 - setupSynced 实现 与 piniaPluginTracing 实现:实例级插件机制的真实代码
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00