第111课:Pinia 状态管理(下)——插件、持久化、与 TypeScript 集成、组合式 Store
上一课我们掌握了 Pinia 的核心三要素:state、getters 和 actions,并通过 Setup Store 语法构建了响应式的购物车模块。在实际项目中,状态管理还需要解决三个进阶问题:如何将关键状态持久化到本地存储以在刷新后恢复?如何为每个 Store 添加通用逻辑(如日志、错误处理)而不侵入业务代码?如何在 TypeScript 中获得从定义到使用的全链路类型安全?Pinia 通过插件系统、内置持久化中间件(pinia-plugin-persistedstate)以及对 TypeScript 的一流支持优雅地解决了这些问题。本节课将逐一深入这三个进阶主题,并展示如何将多个 Store 组合为可扩展的架构,最终通过一个带认证和购物车的实战案例将所有知识点串联。
1. Pinia 插件系统:为所有 Store 注入通用能力
Pinia 插件是一个函数,它接收一个 context 对象,可以在每个 Store 实例化时执行自定义逻辑。插件能做的事情包括:为所有 Store 添加公共属性或方法、监听 $patch 变更进行日志记录、与外部持久化库集成、甚至在 state 变化时触发副作用。插件通过 pinia.use(plugin) 注册。
1.1 插件的结构与注入
1 | // main.js |
关键机制:
- 插件函数在每个 Store 第一次被调用
useStore()时触发。它在 Store 实例创建后、暴露给组件之前执行。 store.$subscribe(callback)类似于 Vue 的watch,监听 state 变化。mutation.events包含本次变更的详细信息(key、newValue、oldValue)。store.$onAction(callback)是一个 action 拦截器。它在 action 执行前触发,通过after和onError回调可以分别在 action 成功或失败后执行逻辑。这比直接包装 action 更优雅,因为它与 action 的具体实现解耦。
1.2 通过插件为所有 Store 添加公共属性
插件可以直接向 store 对象上挂载属性或方法,这些属性会被添加到每个 Store 实例上。为了避免与 Store 自身的 state/getters/actions 命名冲突,社区约定插件注入的属性通常以 $ 前缀命名。
1 | // 插件:为所有 Store 添加一个 toast 方法 |
在任何 Store 内部即可通过 this.$toast 或在组件中通过 store.$toast 调用。
1.3 插件的典型应用场景
| 场景 | 实现方式 |
|---|---|
| 日志记录 | store.$subscribe + store.$onAction |
| 错误统一处理 | $onAction 的 onError 回调中上报错误 |
| 持久化 | store.$subscribe 将 state 写入 localStorage |
| 权限校验 | 在 action 执行前检查用户权限 |
| 全局状态快照 | 使用 store.$state 配合 watch 进行定期备份 |
2. 持久化:pinia-plugin-persistedstate
Web 应用中经常需要将某些 Store 的状态保存到 localStorage 或 sessionStorage,以便在页面刷新后恢复。手动在每个 Store 中添加 watch 和 localStorage 读写逻辑繁琐且重复。**pinia-plugin-persistedstate** 是 Pinia 官方推荐的持久化插件,通过简单的配置即可为任意 Store 开启自动持久化。
2.1 安装与启用
1 | npm install pinia-plugin-persistedstate |
1 | // main.js |
2.2 在 Store 中使用
在 Setup Store 中,通过 defineStore 的第三个参数(选项对象)配置持久化:
1 | // stores/auth.js |
默认情况下,persist: true 会将整个 Store 的所有 state 持久化到 localStorage 中,键名为 Store 的 ID(此处为 'auth')。
2.3 自定义持久化行为
通过 persist 对象可以精细控制持久化策略:
1 | { |
关键选项:
pick和omit用于精细控制哪些 state 被持久化。敏感信息(如临时 UI 状态、加载标志、错误信息)应该通过omit排除,避免刷新后显示过期的“加载中”或“错误提示”。storage可以传入sessionStorage(仅当前会话有效),或实现getItem/setItem/removeItem的自定义存储对象(如用于 React Native 的 AsyncStorage)。serializer允许自定义序列化逻辑,例如使用@nuxt/devalue处理 JSON 无法序列化的类型(Date、BigInt、undefined等)。
2.4 手动触发持久化与清空
插件为每个 Store 添加了 $hydrate() 和 $persist() 方法(可选使用):
1 | const store = useAuthStore(); |
此外,store.$reset() 会同时清空内存状态和持久化存储中的数据。
3. TypeScript 集成:从 Store 定义到组件使用的全链路类型安全
Pinia 专为 TypeScript 设计,Setup Store 语法中的 ref 和 computed 自动推导类型,几乎无需手动标注。然而,在以下场景中,你需要了解如何精确地提供类型信息。
3.1 Store 的完整类型定义
在 Setup Store 中,返回对象的类型就是 Store 的类型。Pinia 会自动从返回值推导出 Store 的类型。如果你希望显式导出类型(例如在 index.ts 中统一导出),可以使用 ReturnType:
1 | // stores/counter.ts |
3.2 在组件中使用时无需额外标注
1 | <script setup lang="ts"> |
由于 TypeScript 的类型推导能力,useCounterStore() 的返回值类型被自动推断为 Store<"counter", { count: Ref<number>; double: ComputedRef<number>; increment: () => void; }>(内部经过 Pinia 的类型转换,在组件中访问时 Ref 自动解包,counter.count 的类型为 number)。你不需要手动声明类型。
3.3 在 Action 中使用 this 的类型
Setup Store 中没有 this——所有变量都在 setup 函数的闭包内。因此不需要像 Options Store 那样关心 this 类型。这也是 Setup Store 在 TypeScript 下更自然的原因之一。
3.4 为 Getters 和 Actions 添加 JSDoc 类型注释
即使是纯 TypeScript 环境,JSDoc 注释也有助于编辑器提供更好的智能提示和文档。推荐为所有导出的函数添加 JSDoc:
1 | /** |
4. 组合式 Store:按业务领域拆分与组合
大型应用中,单个 Store 如果包罗万象,会变得臃肿且难以维护。Pinia 鼓励按业务领域拆分 Store,然后在需要的地方自由组合它们。这种“多 Store”架构天然避免了 Vuex 的模块嵌套和命名空间问题。
4.1 拆分原则
- 一个 Store 负责一个业务领域:
useAuthStore管理认证,useCartStore管理购物车,useProductStore管理商品列表。 - Store 之间可以互相引用:在
useCheckoutStore中可以直接调用useCartStore().totalPrice和useAuthStore().user。这比 Vuex 的rootGetters['cart/totalPrice']更直观、类型安全。 - 公共工具函数提取为 Composable:如果多个 Store 需要共享一段逻辑(如防抖、格式化),应提取为 composable 函数,而非放在某个 Store 中让其他 Store 引用。Store 应专注于状态管理,通用工具函数放在
composables/目录下。
4.2 示例:认证 Store + 购物车 Store 协作
1 | // stores/auth.ts |
1 | // stores/cart.ts |
1 | // stores/checkout.ts |
关键模式:
useCheckoutStore在 setup 函数中直接调用useCartStore()和useAuthStore()。这种 Store 间的组合是 Pinia 的常态——每个 Store 是一个独立的 JavaScript 模块,可以在任何地方被导入和使用。- 认证 Store 配置了持久化,仅保存
token和user(不保存其他临时状态),刷新后用户自动恢复登录态。 - 结算 Store 不暴露自己的 state(当前示例中仅返回 computed 和 action),它更像是一个“业务逻辑聚合器”,协调其他两个 Store。
5. 综合实战:带认证的购物车应用
以下代码整合了认证持久化、购物车管理和结算逻辑,展示了一个完整的 Pinia 多 Store 架构。
1 | // main.ts |
1 | <!-- App.vue --> |
测试持久化:
- 登录应用(
auth.login模拟登录)后,刷新页面——token和user被持久化,无需重新登录。 - 将商品加入购物车,刷新页面——购物车数据未持久化(
cartStore 未配置persist),购物车被清空。若需要持久化购物车,只需在cartStore 中添加{ persist: true }配置即可。
6. Pinia 架构最佳实践总结
| 实践 | 说明 |
|---|---|
| 优先使用 Setup Store | 与 Vue 3 Composition API 心智模型一致,TypeScript 类型推导最佳。 |
| 按业务领域拆分 Store | 一个 Store 一个职责,避免巨型 Store。 |
| Store 间自由组合 | 直接在 Store 的 setup 函数中调用其他 useXxxStore()。 |
敏感信息配置 pick/omit |
持久化时仅保存必要数据,排除 loading、error 等临时状态。 |
| 插件实现横切关注点 | 日志、错误上报、全局 toast 等跨 Store 逻辑应通过插件实现。 |
| 工具函数提取为 Composable | 避免 Store 之间通过“公共 Store”共享工具函数——应使用 composable。 |
| TypeScript 类型推导 | 无需手动导出 Store 类型,直接使用 useStore() 的返回值类型。 |
课后练习
一、概念自测(选择题 / 填空题)
(单选) 在 Pinia 插件中,要监听 action 的调用和结果,应使用哪个方法?
A.store.$subscribe
B.store.$patch
C.store.$onAction
D.store.$reset(单选)
pinia-plugin-persistedstate默认将 state 持久化到哪种存储介质?
A.sessionStorage
B.localStorage
C. IndexedDB
D. Cookie(填空) 在持久化配置中,要仅保存部分 state 属性,应使用
______选项指定白名单。(多选) 关于 Pinia 与 TypeScript 集成的描述,哪些是正确的?
A. Setup Store 中ref的类型会自动推导,无需手动标注。
B. 需要手动导出Store<"id", { ... }>类型才能在组件中使用。
C. 组件中通过useStore()获取的 Store 实例具有完整类型推导。
D. 在 action 中可以使用this访问 state,且类型安全。
二、AI 编程任务:编写面向 AI 的提示词
场景:你需要为 Pinia 创建一个全局错误处理插件。要求如下:
- 插件应监听所有 Store 的 action 调用。
- 当任何 action 抛出异常时,插件应:
- 在控制台输出
[StoreName] action "actionName" failed:+ 错误信息。 - 将错误存储到一个全局的错误队列 Store(
useErrorStore)中,供 UI 组件消费。
- 在控制台输出
- 同时,提供
clearErrors()action 用于清空错误列表。 - 使用 TypeScript 编写,插件注册在
main.ts中,useErrorStore独立为一个 Store 文件。
任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成符合上述要求的 Pinia 插件代码和 useErrorStore 的完整实现。提示词中需明确指定 $onAction 的回调参数、错误存储结构和清空逻辑。
三、Agent 模式下的提示词示例
你是一个资深前端开发 Agent。请为一个 Vue 3 + Pinia + TypeScript 项目创建全局错误处理插件和错误 Store。需要创建以下文件:
src/stores/error.ts:使用 Setup Store 语法定义useErrorStore。State 包含errors(ref<Array<{ id: number; storeId: string; actionName: string; message: string; timestamp: number }>>([]))。Actions 包含addError(storeId: string, actionName: string, message: string)(push 新错误对象,id 为Date.now())和clearErrors()(重置为[])。Getters 包含hasErrors(computed(() => errors.value.length > 0))和latestError(返回errors.value[errors.value.length - 1]或null)。src/plugins/errorLogger.ts:导出一个 Pinia 插件函数。使用({ store })参数,调用store.$onAction(({ name, onError }) => { onError((error) => { const errorStore = useErrorStore(); errorStore.addError(store.$id, name, error.message); console.error([${store.$id}] action “${name}” failed:, error); }); })。src/main.ts:引入 Pinia、piniaPluginPersistedstate(如有需要)和errorLogger插件。创建 Pinia 实例后pinia.use(errorLogger)。- 所有代码添加 JSDoc 注释,确保 TypeScript 类型安全。完成后列出所有文件内容。
四、面试真题与参考答案
题目(蚂蚁集团前端面试题):
请解释 Pinia 插件的设计模式和典型的应用场景。如果你需要实现一个“未保存更改”的全局拦截器(即当用户离开页面时,如果某个 Store 中有未保存的数据,弹出确认框),你会如何设计这个插件?请给出核心实现思路和关键代码片段。
参考答案:
Pinia 插件是一个接收 context 的函数,在每个 Store 创建时被调用。通过 context.store 可以访问当前 Store 实例,从而注入公共属性(如 $toast)、监听状态变化($subscribe)、拦截 action($onAction)或与外部系统交互。典型应用包括:日志记录、错误上报、持久化、权限校验、全局行为注入等。
设计“未保存更改”拦截器的核心思路:
- 在每个 Store 中统一维护一个
isDirty状态(标记是否有未保存的更改)。可以通过插件在 Store 创建时自动添加isDirty属性,或者让各 Store 自行声明(后者更灵活)。 - 在
store.$subscribe中,当 state 发生变化(且非通过$patch从外部恢复),将isDirty设为true。当 action 成功保存后,将isDirty设为false。 - 注册全局的
window.beforeunload事件(浏览器关闭/刷新拦截),以及 Vue Router 的beforeEach导航守卫(页面内路由切换拦截),遍历所有 Store 检查是否有isDirty === true。若有,弹出确认框。若用户确认离开,通过插件提供的全局方法将所有 Store 的isDirty设为false以避免多次提示。 - 插件暴露一个
registerStore(store)方法,由各 Store 在 setup 中主动注册自己。或者插件通过pinia.state.value遍历所有已创建的 Store 实例。
1 | // 插件核心代码示意 |
关键设计在于将“是否脏”的状态管理权交给插件,而不是在每个 Store 中重复编写相同逻辑。同时,通过 $subscribe 自动追踪变更,减少了开发者手动维护 isDirty 的心智负担。
课后练习答案
一、概念自测答案
C
- 解析:
$onAction用于监听 action 调用,可通过after和onError处理成功和失败。$subscribe监听 state 变化,$patch修改 state,$reset重置 state。
- 解析:
B
- 解析:默认使用
localStorage,可通过storage选项修改为sessionStorage或自定义存储。
- 解析:默认使用
pick- 解析:
pick指定白名单,仅持久化这些字段;omit指定黑名单,排除这些字段。
- 解析:
A、C
- 解析:B 错误,Pinia 自动推导类型,无需手动导出 Store 类型。D 错误,Setup Store 中没有
this,所有状态通过闭包访问。
- 解析:B 错误,Pinia 自动推导类型,无需手动导出 Store 类型。D 错误,Setup Store 中没有
二、AI 编程任务参考答案(提示词示例)
示例提示词:
“请用 TypeScript 编写一个 Pinia 插件和一个 useErrorStore,实现全局 action 错误捕获和存储。要求:
- useErrorStore:state 包含 errors 数组,actions addError 和 clearErrors,getters hasErrors 和 latestError。
- 插件:在 store.$onAction 的 onError 中调用 useErrorStore().addError 并 console.error。
- main.ts 中注册插件。
- 所有代码添加 JSDoc,确保类型安全。输出三个文件的内容。”