Pinia 不为人知的 13 个高级技巧

0 阅读3分钟

大多数人用 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.routerthis.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,需要知道这些。