大多数人用 Pinia,只用到了它三成的能力。
定义 store、读取 state、调用 action,然后就没有然后了。
但 Pinia 真正的能量,藏在那些没人告诉你的地方。
一、$patch 函数模式:批量原子更新
大多数人这样用 $patch:
store.$patch({ count: 10, name: 'Alice' })
没问题。但遇到数组操作,就出问题了:
// ❌ 这会替换整个数组,触发多次响应式更新
store.$patch({ items: [...store.items, newItem] })
正确的做法是用函数模式:
// ✅ 直接操作,单次响应,性能更好
store.$patch((state) => {
state.items.push(newItem)
state.count += 1
state.loading = false
})
函数模式的本质是:在一次"事务"里完成所有修改,只触发一次响应式更新。
集合操作、条件判断、多字段联动,都应该用函数模式。
二、$subscribe 的 detached 模式:脱离组件的监听
$subscribe 默认行为:组件卸载,订阅自动销毁。
大多数场景够用。但持久化存储不行——你需要订阅在组件消失后继续存在。
加上 { detached: true } 就能做到:
const unsub = store.$subscribe(
(mutation, state) => {
// mutation.type: 'direct' | 'patch object' | 'patch function'
// mutation.storeId: 哪个 store 触发的
// mutation.events: 具体变更的 key(仅 direct 模式有效)
localStorage.setItem('store-cache', JSON.stringify(state))
},
{ detached: true } // 组件卸载后不停止
)
// 手动停止
unsub()
注意:detached 订阅不会自动清理,忘记调用 unsub() 就是内存泄漏。
三、$onAction 三段钩子:Action 全生命周期
$onAction 不只是"监听 action 被调用"。
它提供三个时间点:调用前、成功后、出错时。
store.$onAction(({
name, // action 名称
args, // 调用参数
after, // 成功后的钩子
onError, // 出错后的钩子
}) => {
const startTime = Date.now()
after((result) => {
const duration = Date.now() - startTime
// 性能埋点、日志上报
analytics.track('action_success', { name, duration })
})
onError((error) => {
// 错误监控
Sentry.captureException(error)
})
})
这是做埋点、性能监控、错误上报的最干净方式——业务 action 本身保持纯净,横切逻辑全部在这里处理。
四、storeToRefs:解构 store 的正确姿势
这是新手最容易踩的坑。
// ❌ 直接解构,响应性丢失
const { count, name } = useUserStore()
// count 和 name 变成了普通值,不会随 store 更新
正确做法:
// ✅ storeToRefs 只提取 state/getter 为 ref
const store = useUserStore()
const { count, name } = storeToRefs(store)
// action 直接从 store 取,不需要包裹
const { fetchUser } = store
原理:storeToRefs 会把每个 state 属性转成 toRef(store, key),保持与原 store 的双向绑定。
五、$dispose:手动销毁 Store
大多数人不知道 store 可以被销毁。
调用 $dispose() 会做三件事:停止所有订阅、从 pinia 实例中移除、释放内存。
async function logout() {
await authAPI.logout()
// 销毁所有用户相关 store
useUserStore().$dispose()
useCartStore().$dispose()
useOrderStore().$dispose()
router.push('/login')
}
登出场景、路由切换时清理临时 store,用这个。
六、Setup Store 手写 $reset
Options Store 自带 $reset(),一键恢复初始状态。
Setup Store 没有。需要自己实现:
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const name = ref('Eve')
function $reset() {
count.value = 0
name.value = 'Eve'
}
return { count, name, $reset }
})
如果初始值来自外部配置,可以用闭包把初始快照存起来:
export const useFormStore = defineStore('form', () => {
const initialState = { title: '', content: '', tags: [] }
const form = reactive({ ...initialState })
function $reset() {
Object.assign(form, initialState)
}
return { form, $reset }
})
七、getActivePinia:组合式函数外访问 Store
在组件的 setup() 里用 useXxxStore() 没问题。
但在 class、工具函数、setTimeout 回调里调用会报错:getActivePinia was called with no active Pinia.
解决方案:
import { getActivePinia } from 'pinia'
class ApiService {
getAuthToken() {
const pinia = getActivePinia()
if (!pinia) throw new Error('Pinia 未激活')
const authStore = useAuthStore(pinia)
return authStore.token
}
}
把 pinia 实例作为参数传给 useXxxStore(pinia),即可在任意上下文中安全访问。
八、Store 间引用:在 Action 内部懒加载
两个 store 互相引用是常见需求,也是循环依赖的高发区。
// ❌ 危险:文件顶层互相 import 可能导致循环依赖
const userStore = useUserStore() // 顶层调用
export const useOrderStore = defineStore('order', {
actions: {
async fetchOrders() { /* ... */ }
}
})
正确做法:把引用放到 action 内部,按需加载:
// ✅ action 内部懒加载,天然避免循环依赖
export const useOrderStore = defineStore('order', {
actions: {
async fetchOrders() {
const userStore = useUserStore() // 在这里调用,而不是顶层
const userId = userStore.id
return await api.getOrders(userId)
}
}
})
这不是 workaround,这是 Pinia 官方推荐的模式。
九、defineStore 自定义元数据:让插件感知 Store 意图
defineStore 的第三个参数 options 支持任意自定义字段。
插件通过 context.options 读取,实现声明式配置。
// 在 store 上声明配置
export const useSearchStore = defineStore(
'search',
{
state: () => ({ query: '', results: [] }),
actions: {
async search(q: string) { /* ... */ }
}
},
{
// 自定义元数据,供插件消费
debounce: { search: 300 },
persist: { key: 'search-cache', storage: sessionStorage },
}
)
// 插件中读取并处理
pinia.use(({ options, store }) => {
if (options.debounce) {
return Object.keys(options.debounce).reduce((acc, action) => {
acc[action] = debounce(store[action], options.debounce[action])
return acc
}, {})
}
})
这是实现"约定大于配置"的核心机制,持久化插件、防抖插件都依赖它。
十、acceptHMRUpdate:热更新不丢状态
用 Vite 开发时,修改 store 文件会导致页面状态重置。
加三行代码解决:
import { acceptHMRUpdate, defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
// ...
})
// 文件底部加上这三行
if (import.meta.hot) {
import.meta.hot.accept(
acceptHMRUpdate(useCounterStore, import.meta.hot)
)
}
修改 store 代码,状态保留,页面不刷新。调试体验直接上一个台阶。
十一、setActivePinia:单元测试中的 Store 隔离
测试 store 时最常见的问题:测试用例之间状态污染。
每个用例前创建全新 pinia 实例,彻底隔离:
import { setActivePinia, createPinia } from 'pinia'
import { beforeEach, test, expect } from 'vitest'
import { useCounterStore } from './counter'
beforeEach(() => {
// 每个测试用例前重置 pinia
setActivePinia(createPinia())
})
test('increments counter', () => {
const store = useCounterStore()
expect(store.count).toBe(0)
store.increment()
expect(store.count).toBe(1)
})
test('starts at zero', () => {
const store = useCounterStore()
// 不受上一个测试影响
expect(store.count).toBe(0)
})
配合 createTestingPinia 还可以 mock action,让测试更纯粹。
十二、$state 直接替换:SSR 注水与快照恢复
服务端渲染时,需要把服务端的状态"注入"到客户端 store。
const store = useUserStore()
// 从服务端传来的初始数据,批量恢复
store.$patch(window.__INITIAL_STATE__.user)
也可以直接给 $state 赋值(Pinia 内部会调用 $patch):
store.$state = JSON.parse(localStorage.getItem('user-snapshot'))
这在实现"页面快照保存与恢复"的功能时非常有用。
十三、markRaw:注入外部服务时阻止响应式
把 router、axios 等外部对象注入 store,是常见做法。
但 Vue 会尝试把它们转成响应式对象——这既浪费性能,有时还会引发错误。
用 markRaw 包裹,明确告诉 Vue:这个对象不需要响应式处理。
import { markRaw } from 'vue'
import { router } from './router'
pinia.use(({ store }) => {
store.router = markRaw(router)
store.axios = markRaw(axiosInstance)
})
这通常写在 Pinia 插件里,一次配置,所有 store 都能用 this.router 和 this.axios。
总结
这 13 个技巧,覆盖了 Pinia 从日常使用到插件开发的完整深度:
| 场景 | 技巧 |
|---|---|
| 状态更新 | $patch 函数模式 |
| 状态监听 | $subscribe + detached |
| Action 追踪 | $onAction 三段钩子 |
| 响应式解构 | storeToRefs |
| Store 销毁 | $dispose |
| 状态重置 | Setup Store 手写 $reset |
| 非 setup 访问 | getActivePinia |
| 跨 store 引用 | Action 内部懒加载 |
| 插件配置化 | defineStore 自定义元数据 |
| 开发体验 | acceptHMRUpdate |
| 测试隔离 | setActivePinia |
| SSR/快照 | $state 直接替换 |
| 外部服务注入 | markRaw |
大多数人停在了第一层。
真正用好 Pinia,需要知道这些。