Pinia 状态管理(@ubean/integrations/pinia)
@ubean/integrations/pinia 是 ubean 的内置状态管理集成,它把 Pinia——Vue 官方状态管理库——接入框架。该模块只是一层很薄的编排,负责把 Pinia 的开发态优化与 SSR 状态水合接到 ubean 的 Vite 流水线和 SSR 协议上。Pinia 本身仍直接从 pinia 包导入使用。
特性
- 一行启用:在
ubean.config.ts中写pinia: true - 开发态对
pinia做optimizeDeps预打包 —— dev 首屏加载更快 - 通过
defineApp({ serializeState, hydrateState })钩子实现 SSR 状态水合 - 零侵入:Pinia 的 API(
createPinia、defineStore、storeToRefs…)直接从pinia导入 - 通过
UbeanPiniaOptions获得类型安全配置 - 安全降级:取不到
$pinia时序列化返回{};state 为null或没有pinia字段时,水合是空操作
安装
@ubean/integrations/pinia 是内置集成(@ubean/integrations 的子路径)。在项目中与 pinia 一起安装:
pnpm add @ubean/integrations/pinia pinia
pinia是 peer dependency —— 版本由你控制。@ubean/integrations/pinia支持pinia@^2.0.0 || ^3.0.0。
配置
最小配置
最简配置只启用开发态的预打包优化:
// ubean.config.ts
import { defineConfig } from 'ubean';
export default defineConfig({
pinia: true
});然后在 src/app.ts 中注册 Pinia 与 SSR 水合钩子:
// src/app.ts
import { createPinia } from 'pinia';
import { serializePiniaState, hydratePiniaState } from '@ubean/integrations';
import { defineApp } from 'ubean';
export default defineApp({
plugins: [createPinia()],
serializeState: serializePiniaState,
hydrateState: hydratePiniaState
});这样做可以获得:
- dev 阶段
pinia已预打包,首屏加载更快 - 服务端状态被序列化进 HTML 的
__UBEAN_STATE__script 标签 - 客户端在
app.mount()之前完成水合(因此 store 里已经是 SSR 状态)
关闭模块
显式关闭该模块(等同于不写这个字段):
export default defineConfig({
pinia: false
});
// 或
export default defineConfig({
pinia: { disabled: true }
});关闭 dev 阶段的 optimizeDeps
如果你给 pinia 配了自定义 alias,或使用 monorepo 内的本地 pinia 源码,请关闭预打包:
export default defineConfig({
pinia: { optimizeDeps: false }
});用法
定义 store
store 的定义方式与常规 Pinia 完全一致:
// src/stores/counter.ts
import { defineStore } from 'pinia';
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Counter'
}),
getters: {
double: state => state.count * 2,
// 带类型推断
doubleCount(): number {
return this.count * 2;
}
},
actions: {
increment() {
this.count++;
},
async fetchInitial() {
const res = await fetch('/api/counter');
this.count = await res.json();
}
}
});Composition API 风格(setup store)同样完整支持:
import { ref, computed } from 'vue';
import { defineStore } from 'pinia';
export const useUserStore = defineStore('user', () => {
const name = ref('');
const isAdmin = ref(false);
const displayName = computed(() => name.value || 'Guest');
async function login(email: string, password: string) {
const res = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password })
});
const user = await res.json();
name.value = user.name;
isAdmin.value = user.role === 'admin';
}
return { name, isAdmin, displayName, login };
});在组件中使用 store
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useCounterStore } from '~/stores/counter';
const store = useCounterStore();
// 解构并保留响应性
const { count, double } = storeToRefs(store);
// action 可以直接解构
const { increment } = store;
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<p>Double: {{ double }}</p>
<SButton @click="increment">Increment</SButton>
</div>
</template>在 API 路由 / 数据加载器中使用 store
在 ubean 的服务端 handler 里,通常不会跨请求共享 Pinia 状态 —— 每个 SSR 请求都会创建全新的应用实例。若确实需要,请在 defineApp 的 onAppCreated 中,或通过 SSR 应用实例在数据加载器内部使用 Pinia store。
请求作用域的数据请优先使用 loader/action 数据协议而非 Pinia。Pinia 最适合承载客户端共享状态(UI 状态、缓存数据、用户偏好)。
工作原理
@ubean/integrations/pinia 是一层薄封装。当设置了 pinia: true 时:
模块系统加载
@ubean/integrations/pinia,并调用ubeanPiniaPlugin(options)(其中options来自extractBuiltinOptions(config.pinia)—— 对象原样透传,只剥离模块系统专用的disabled标记;true则得到{})。dev optimizeDeps:
ubeanPiniaPlugin把pinia加入 Vite 的optimizeDeps.include,确保 Pinia 被预打包,从而在 dev 首屏加载更快。这避免了首次请求时的依赖扫描延迟。SSR 序列化:当
serializePiniaState被配置为defineApp({ serializeState })时,ubean 的 SSR 渲染器会在renderToString(app)完成后调用它。该函数读取app.config.globalProperties.$pinia.state.value,返回{ pinia: ... }。渲染器把它序列化为 JSON,并以<script id="__UBEAN_STATE__" type="application/json">注入 HTML。客户端水合:当
hydratePiniaState被配置为defineApp({ hydrateState })时,ubean 客户端入口会在applyAppConfig(app, config, 'client')(该调用把createPinia()注册为插件)之后、app.mount()之前调用它。该函数从反序列化后的 state 中读取pinia字段,并赋值给pinia.state.value。
水合必须发生在
mount之前 —— 否则 store 已经用默认状态初始化完毕,水合就成了空操作。ubean 的客户端入口保证了这一顺序。
SSR 状态流转
┌─────────────────────────────────────────────────────────────────┐
│ 服务端 │
│ │
│ createSSRApp(initialPage) │
│ applyAppConfig → app.use(createPinia()) │
│ router.push(url) → renderToString(app) │
│ serializeState(app) → { pinia: pinia.state.value } │
│ HTML = shell.replace(STATE_MARKER, <script id=__UBEAN_STATE__>)│
└─────────────────────────────────────────────────────────────────┘
│
▼ (内嵌状态的 HTML)
┌─────────────────────────────────────────────────────────────────┐
│ 客户端 │
│ │
│ createApp() │
│ applyAppConfig → app.use(createPinia()) │
│ state = getInitialState() // 读取 __UBEAN_STATE__ │
│ hydrateState(app, state) → pinia.state.value = state.pinia │
│ app.mount('#app') // store 已完成水合 │
└─────────────────────────────────────────────────────────────────┘编程式 API
import { ubeanPiniaPlugin, definePiniaConfig } from '@ubean/integrations/pinia';
import { serializePiniaState, hydratePiniaState } from '@ubean/integrations';
import type { UbeanPiniaOptions, PiniaSerializedState } from '@ubean/integrations/pinia';ubeanPiniaPlugin(options?: UbeanPiniaOptions): Plugin[]
返回一组 Vite 插件。通常由模块系统自动调用;只有在 ubean.config.ts 之外集成时才需要手动调用。
definePiniaConfig(options: UbeanPiniaOptions): UbeanPiniaOptions
用于编写 UbeanPiniaOptions 的类型安全辅助函数,带自动补全。原样返回入参。
serializePiniaState(app: VueApp): PiniaSerializedState
SSR 序列化辅助函数。读取 app.config.globalProperties.$pinia.state.value,返回 { pinia: <深拷贝后的状态> }。检测不到 $pinia 时(例如未注册 createPinia())返回 {} —— 不会抛错。
hydratePiniaState(app: VueApp, state: Record<string, unknown> | null): void
客户端水合辅助函数。把 state.pinia 赋值给 pinia.state.value。state 为 null 或没有 pinia 字段时是空操作。在应用上检测不到 $pinia 时会向控制台告警(这是配置错误:配置了 hydrateState 却没注册 createPinia())。
UbeanPiniaOptions
export interface UbeanPiniaOptions {
/** 插件是否启用(默认 true)。注意:在 ubean.config.ts 中
* 模块系统的选项是 `disabled?: boolean`(语义相反)。 */
enabled?: boolean;
/**
* 是否把 `pinia` 加入 Vite 的 `optimizeDeps.include`(默认 true)。
*
* dev 阶段预打包可避免首次请求时的依赖扫描延迟。
* 若使用自定义 `pinia` alias 或 monorepo 内的本地 pinia 源码,请关闭。
*/
optimizeDeps?: boolean;
}PiniaSerializedState
export interface PiniaSerializedState {
/** Pinia 根状态(pinia.state.value) */
pinia?: Record<string, unknown>;
/** 允许扩展自定义字段 */
[key: string]: unknown;
}通过
ubean.config.ts配置时,框架读取的是pinia: true | UbeanPiniaOptions。模块系统会把选项直接传给ubeanPiniaPlugin。
最佳实践
pinia: true必须与 app.ts 里的钩子配套:pinia: true只开启 dev 预打包。SSR 状态水合需要在defineApp中配置serializePiniaState/hydratePiniaState。否则 SSR 渲染出的 store 会在客户端水合时被重置为默认值。每个应用一个 Pinia 实例:在
defineApp({ plugins: [createPinia()] })中只调用一次createPinia()。状态水合假定应用上只有一个$pinia。服务端 Pinia 状态保持在单请求作用域内:每个 SSR 请求都会创建全新的 Vue 应用(进而全新的 Pinia)。请让 Pinia 实例只属于该请求 —— 挂在模块作用域的实例会在请求之间泄漏状态。
请求作用域的数据用数据加载器(loader):随请求变化的数据(例如用户相关数据、路由参数)请优先使用 ubean 的页面 loader/action 协议。Pinia 更适合需要跨路由存活的客户端共享状态(UI 状态、缓存数据、用户偏好)。
在 mount 之前水合:ubean 客户端入口保证
hydrateState在app.mount()之前执行。如果你自定义了入口,请保留这一顺序 —— 否则 store 会用默认值初始化,水合结果丢失。Pinia 与群岛(islands):Pinia 状态存在于主 Vue 应用中。islands(
v-client.load、v-client.idle等)是独立的 Vue 子树,不会自动共享主应用的 Pinia 实例。如果某个 island 需要 Pinia,请在它自己的应用上安装,或通过 props 传入状态。
故障排查
水合之后 store 被重置为默认值
- 确认
serializePiniaState与hydratePiniaState都已在defineApp中配置 - 检查
createPinia()是否写在defineApp({ plugins: [...] })里(而不是事后通过onAppCreated注册) - 查看渲染出的 HTML 中是否含
<script id="__UBEAN_STATE__" type="application/json">—— 它应包含序列化后的状态 - 确认
hydrateState在app.mount()之前执行(默认客户端入口已保证)
告警:“hydrateState was called but no $pinia was detected on the app”
这说明 hydratePiniaState 被调用了,但 createPinia() 没有作为插件注册。修复方式:
// src/app.ts
import { createPinia } from 'pinia';
import { hydratePiniaState } from '@ubean/integrations';
export default defineApp({
plugins: [createPinia()], // <-- 这一行必需
hydrateState: hydratePiniaState
});dev 阶段 Pinia 没有被预打包
- 确认
ubean.config.ts中设置了pinia: true(或pinia: { ... }) - 运行
ubean dev,查看 Vite 的 optimizeDeps 输出 ——pinia应出现在optimizeDeps.include中 - 若使用了自定义 Vite 配置,确认
ubeanPiniaPlugin的输出没有被过滤掉
@ubean/integrations 报类型错误
- 运行
ubean prepare重新生成类型声明 - 确认
tsconfig.json包含了@ubean/integrations/pinia的类型 - 运行时辅助函数请确保从
@ubean/integrations(而不是@ubean/integrations/pinia)导入
示例
带 SSR 的计数器
<!-- src/pages/counter.vue -->
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useCounterStore } from '~/stores/counter';
const store = useCounterStore();
const { count, double } = storeToRefs(store);
</script>
<template>
<div class="flex flex-col items-center gap-4">
<h1>Counter</h1>
<p class="text-4xl font-bold">{{ count }}</p>
<p class="text-sm text-gray-500">Double: {{ double }}</p>
<div class="flex gap-2">
<SButton variant="outline" @click="store.count--">-</SButton>
<SButton @click="store.increment()">+</SButton>
</div>
</div>
</template>// src/stores/counter.ts
import { defineStore } from 'pinia';
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
getters: {
double: state => state.count * 2
},
actions: {
increment() {
this.count++;
}
}
});计数状态可以跨导航与 SSR 水合存活 —— 无需额外配置。
主题切换
// src/stores/theme.ts
import { defineStore } from 'pinia';
export const useThemeStore = defineStore('theme', {
state: () => ({
mode: 'light' as 'light' | 'dark'
}),
actions: {
toggle() {
this.mode = this.mode === 'light' ? 'dark' : 'light';
}
}
});<!-- src/layouts/default.vue -->
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useThemeStore } from '~/stores/theme';
const theme = useThemeStore();
const { mode } = storeToRefs(theme);
</script>
<template>
<div :class="mode">
<header>
<SButton variant="ghost" size="sm" @click="theme.toggle()">
{{ mode === 'light' ? '🌙' : '☀️' }}
</SButton>
</header>
<main><slot /></main>
</div>
</template>