首页
/ Airi 中 Pinia 的服务端渲染实践:setup 中的 Store、状态水合与跨请求隔离

Airi 中 Pinia 的服务端渲染实践:setup 中的 Store、状态水合与跨请求隔离

2026-09-05 18:02:45作者:俞予舒Fleming

本文以 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) 解析记录)。本文引用的 createPiniauseStore(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)
  // ...

注意两点仓库细节:

  1. Airi 的 router.beforeEach 目前只承担 NProgress 进度条职责,尚未在守卫内直接读取 store;但这套 createPinia()app.use(pinia) 的装配方式,正是文档中“守卫内 useMainStore(pinia)”用法能够成立的前提——守卫闭包捕获的就是这个已安装到 app 上的实例。
  2. 同样的装配也出现在 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 实例”,即:

  1. 调用 store 放在函数内部,而非模块作用域;
  2. 组件外使用 store 时始终传入 pinia 实例;
  3. 在任何 useStore() 之前完成状态水合;
  4. 使用 devalue 或同类库做安全序列化;
  5. 每请求(或每运行时)创建全新 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,通过 $onActionafter / 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 的五条铁律:

  1. 在函数内调用 store,禁止模块作用域顶层调用——避免 Node 进程中跨请求共享上下文;
  2. 组件外(守卫、插件、中间件)使用时传入 pinia 实例——useStore(pinia) 是正确上下文的唯一保证;
  3. 水合先于一切 useStore()——客户端在首次取 store 前把 pinia.state.value 还原;
  4. 使用 devalue 或同类库做 XSS 安全序列化——不要裸奔 JSON.stringify 拼进 HTML;
  5. 每请求创建全新 pinia——从根上杜绝跨请求状态污染。

进一步阅读的仓库路径

登录后查看全文
热门项目推荐
相关项目推荐