背景与问题界定
Vue3 生态中状态管理的主流方案已经从 Vuex 迁移到了 Pinia。Pinia 在 API 简洁性、TypeScript 支持和 DevTools 集成方面有着天然优势。然而,在实际的落地过程中,许多团队遇到了新的问题:Store 的职责边界模糊化——一个 UseStore 中同时包含了用户信息、页面配置、缓存数据和 WebSocket 连接状态,导致一个 Store 文件超过 500 行;组件与 Store 的耦合度过高——UI 组件直接引用 Store 中的深层嵌套状态,Store 的重构会直接触发组件层的修改;测试困难——Store 内部调用了 router、message 等全局服务,导致单测中不得不 mock 大量外部依赖。这些问题本质上是一个老问题在新工具上的重演:缺少架构层面的模块化指导。
目标拆解与工程约束
- Store 单一职责:每个 Store 只负责一个业务领域的数据和操作,领域划分与后端微服务或前端页面路由相对应,Store 文件不超过 200 行。
- 组件与 Store 的间接引用:组件不应直接导入
useXxxStore获取深层状态,必须通过computed或getter投影,或通过 Provider 模式注入接口。 - Store 的副作用分离:API 请求、本地存储读写等副作用不写在 Store actions 的内部,而通过 action 参数注入或组合函数组合。
- 测试无需真实 Pinia 实例:Store 的纯逻辑部分与 Pinia 框架解耦,使开发者可以在 Jest/Vitest 中直接实例化 Store 逻辑进行测试。
方案设计
Pinia 的模块化设计借鉴了领域驱动设计中的聚合根概念。我们定义了三类 Store:
领域 Store(Domain Store):对应一个业务领域。例如 useUserStore 负责用户身份信息、权限列表和登录态切换。领域 Store 之间允许通过 useXxxStore() 相互引用,但必须在 getter 中访问,避免循环依赖。
UI Store(UI Store):纯粹管理页面级别的 UI 状态——侧边栏折叠、表格筛选条件、弹窗可见性。UI Store 不引用任何领域 Store,也不包含副作用,纯同步操作。
缓存 Store(Cache Store):负责接口数据的本地缓存和过期管理,使用泛型约束保证类型安全。缓存 Store 不直接引用其他 Store,以 Map<string, { data: T; expiresAt: number }> 结构管理。
// 缓存 Store 示例
export const useCacheStore = defineStore('cache', () => {
const cache = ref(new Map<string, { data: any; expiresAt: number }>())
function getOrFetch<T>(key: string, fetcher: () => Promise<T>, ttlMs = 60000): Promise<T> {
const entry = cache.value.get(key)
if (entry && entry.expiresAt > Date.now()) {
return Promise.resolve(entry.data as T)
}
return fetcher().then(data => {
cache.value.set(key, { data, expiresAt: Date.now() + ttlMs })
return data
})
}
return { getOrFetch }
})
对于测试策略,Pinia 的 setActivePinia(createPinia()) 提供了便捷的测试初始化。但我们更进一步:将 Store 中容易变化的依赖(API 客户端、路由实例、通知服务)抽象为可注入参数。在 Store 定义时暴露一个 inject 选项,生产环境使用真实实例,测试环境注入 mock 对象。
实施路径与关键决策
- 按领域目录组织 Store 文件:
stores/user/下建立user.store.ts、user.types.ts、user.api.ts,将类型定义和 API 调用与 Store 逻辑分离。 - 所有 Store 采用 Setup Store 语法:Setup Store 天然支持组合函数复用,TypeScript 推断优于 Options Store,且单元测试更灵活。
- 建立 Store 依赖注入规范:Store 中的外部依赖(API、路由、通知)通过
injectSymbol注入,默认值与测试值分离。 - 定期 Store 审计:每两周执行一次 Store 复杂度扫描,对超过 200 行或引用超过 3 个其他 Store 的代码发起重构提议。
验证指标与可持续迭代
通过 SonarQube 持续监控 Store 文件的圈复杂度和文件行数,确保 95% 以上的 Store 文件行数控制在 200 行以内。测试覆盖率方面,核心领域 Store 的 action 分支覆盖率达到 100%,边缘分支不低于 80%。后续迭代方向:探索 Pinia Plugin 实现自动化埋点(action 执行耗时采集),以及 Store 状态的持久化与恢复(pinia-plugin-persistedstate 的深度定制)。
工程落地思考
Pinia 相比 Vuex 最大的进步不是体积更小或 TypeScript 支持更好,而是它去掉了强制规约——不再要求 mutations 和 actions 的二元划分,不再强制 tree-like 的 modules 结构。但这种自由也是危险的。团队的共识规约比框架的限制更重要:我们需要用 Architecture Decision Records(ADR)来记录和推广这些模式,让每个新成员都能快速理解"为什么这样分,而不是那样分"。最终,好的状态管理不是某个工具决定的,而是团队对"什么是状态、状态从哪里来、状态到哪里去"的统一理解决定的。