Pinia 进阶:Setup Store、插件系统与状态持久化,一篇全搞懂

4 阅读4分钟

前言

Pinia 已经是 Vue 3 项目的标配状态管理方案了。但说实话,我见过很多项目里的 Pinia 用法还停留在最基础的 state + getters + actions 阶段。

不是说这样不行,而是 Pinia 还有很多高级特性,用好了能让你的代码更优雅、更灵活。比如:

  • Setup Store —— 用 Composition API 的方式写 Store,逻辑复用更自然
  • 插件系统 —— 给所有 Store 统一注入能力,比如日志、持久化、权限控制
  • 状态持久化 —— 刷新页面状态不丢失,用户体验直接拉满

今天把这三个特性掰开了讲,顺便分享一些我在项目中的实战经验。


一、Setup Store:用 Composition API 写 Store

1.1 为什么需要 Setup Store?

Pinia 有两种写法:Option StoreSetup Store

// Option Store:传统的 options 风格
export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    doubleCount: (state) => state.count * 2
  },
  actions: {
    increment() { this.count++ }
  }
})

// Setup Store:Composition API 风格
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const doubleCount = computed(() => count.value * 2)
  function increment() { count.value++ }
  return { count, doubleCount, increment }
})

看起来差不多,但 Setup Store 有几个 Option Store 做不到的事情:

  1. 私有状态 —— 不 return 的变量就是私有的,外部访问不到
  2. 自由使用 Composition API —— watch、自定义 Hook、inject,随便用
  3. Store 之间互相组合 —— 可以在一个 Store 里直接调用另一个 Store

1.2 私有状态(Option Store 做不到)

这个特性我特别喜欢。有些内部状态你不想暴露出去,在 Setup Store 里不 return 就行了:

export const useAuthStore = defineStore('auth', () => {
  const token = ref('')           // 公开
  const refreshToken = ref('')    // 公开
  const _tokenExpiry = ref(0)     // 私有!外部访问不到

  // 内部用的方法,也不暴露
  function _checkTokenExpiry() {
    return Date.now() > _tokenExpiry.value
  }

  function login(username, password) {
    // 登录逻辑...
    _tokenExpiry.value = Date.now() + 7200 * 1000
  }

  // 只暴露需要公开的
  return { token, refreshToken, login }
})

在 Option Store 里,所有 state 都是公开的,没法做私有化。这一点 Setup Store 完胜。

1.3 Store 组合

在大型项目里,经常需要在一个 Store 里使用另一个 Store 的数据。Setup Store 做这件事特别自然:

export const useCartStore = defineStore('cart', () => {
  const items = ref([])
  const totalPrice = computed(() => 
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )

  return { items, totalPrice }
})

export const useOrderStore = defineStore('order', () => {
  // 直接使用另一个 Store
  const { items, totalPrice } = useCartStore()
  
  const orders = ref([])
  
  function createOrder() {
    orders.value.push({
      items: [...items.value],
      totalPrice: totalPrice.value,
      createdAt: Date.now()
    })
  }

  return { orders, createOrder }
})

1.4 两个容易踩的坑

坑一:必须暴露所有 state

Setup Store 里 return 的对象必须包含所有需要响应式的 state。如果漏了,Devtools 和 SSR 都会出问题。

// ❌ 忘记暴露 count
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const doubleCount = computed(() => count.value * 2)
  return { doubleCount }  // count 没暴露!Devtools 里看不到
})

// ✅ 全部暴露
return { count, doubleCount }

坑二:外部配置放第三个参数

Setup Store 的第二个参数是 setup 函数,如果需要配置(比如持久化),得放第三个参数:

export const useUserStore = defineStore(
  'user',
  () => {
    const name = ref('')
    return { name }
  },
  {
    persist: true  // ← 第三个参数,不是第二个
  }
)

这个我刚开始用的时候搞错过,配置死活不生效,排查了半天才发现放错位置了 😅


二、Pinia 插件系统

2.1 插件是什么?

Pinia 的插件就是一个函数,接收一个 context 对象,可以给 Store 添加全局能力。

// 最简单的插件
function myPlugin({ store }) {
  store.$hello = () => console.log('Hello from plugin!')
}

// 注册
const pinia = createPinia()
pinia.use(myPlugin)

注册之后,所有 Store 都能访问 $hello 方法。

2.2 插件的 Context 对象

每个插件都能拿到一个 context,里面有几个很有用的东西:

function myPlugin(context) {
  context.pinia    // Pinia 实例,可以跨 Store 操作
  context.app      // Vue 应用实例,可以访问 router、i18n 等
  context.store    // 当前正在创建的 Store 实例
  context.options  // defineStore() 的配置对象
}

其中 context.storecontext.options 是最常用的。通过 context.store,你可以给特定的 Store 添加能力;通过 context.options,你可以根据 Store 的配置做不同的事情。

2.3 实战:写一个日志插件

开发阶段,我经常用一个日志插件来追踪状态变化:

export function loggerPlugin({ store }) {
  // 监听状态变更
  store.$subscribe((mutation, state) => {
    console.log(
      `[${mutation.storeId}] ${mutation.type}`,
      mutation.events?.map(e => e.key).join(', ')
    )
  })

  // 监听 Action 调用
  store.$onAction(({ name, args, after, onError }) => {
    console.log(`Action "${name}" started`, args)
    
    after((result) => {
      console.log(`Action "${name}" succeeded`, result)
    })
    
    onError((error) => {
      console.error(`Action "${name}" failed`, error)
    })
  })
}

// 使用
const pinia = createPinia()
pinia.use(loggerPlugin)

注意 $subscribe 的第二个参数 { detached: true }。如果不加这个,插件会在组件卸载时被清理掉。加了之后,插件的生命周期就独立于组件了。

2.4 实战:Action 防抖插件

有些 Action(比如搜索)需要防抖,可以在插件层面统一处理:

import { debounce } from 'lodash-es'

export function debouncePlugin({ store }) {
  const originalActions = {}
  
  // 遍历 store 的 actions,给标记了 _debounce 的加上防抖
  for (const [name, action] of Object.entries(store.$state)) {
    // 这里需要根据 options 来判断哪些 action 需要防抖
  }
}

实际项目中,更常见的做法是在 Action 内部直接用 lodash.debounce,或者写一个自定义 Hook 来处理。

2.5 插件能做什么?

总结一下,Pinia 插件可以做的事情很多:

功能实现方式
给 Store 添加全局方法store.$method = ...
监听状态变更store.$subscribe()
拦截 Actionstore.$onAction()
状态持久化$subscribe + localStorage
跨标签页同步监听 storage 事件
加密存储自定义 serializer
动态权限控制Action 执行前校验

三、状态持久化

3.1 为什么需要持久化?

默认情况下,Pinia 的状态存在内存里,刷新页面就没了。但有些状态你希望刷新后还在:

  • 用户的登录信息(token)
  • 主题设置(暗色/亮色)
  • 语言偏好
  • 购物车数据

3.2 pinia-plugin-persistedstate

社区标准方案是 pinia-plugin-persistedstate,用起来很简单:

安装

npm install pinia-plugin-persistedstate

注册

import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

使用

export const useUserStore = defineStore('user', {
  state: () => ({
    name: 'Alice',
    token: 'xxx'
  }),
  persist: true  // 一行搞定
})

就这么简单,刷新页面后 nametoken 都还在。

3.3 高级配置

实际项目中,你可能需要更精细的控制:

export const useUserStore = defineStore('user', {
  state: () => ({
    name: 'Alice',
    age: 25,
    token: 'xxx',
    tempSearchKeyword: ''  // 临时数据,不需要持久化
  }),
  persist: {
    key: 'my-app-user',        // 自定义 localStorage 的 key
    storage: sessionStorage,    // 默认是 localStorage,可以换成 sessionStorage
    paths: ['name', 'token'],  // 只持久化指定字段
    // 自定义序列化(比如加密)
    serializer: {
      serialize: (value) => btoa(JSON.stringify(value)),
      deserialize: (value) => JSON.parse(atob(value))
    }
  }
})

paths 这个配置特别实用。有些临时数据(搜索关键词、loading 状态)没必要持久化,用 paths 指定需要持久化的字段就行。

3.4 工作原理

这个插件本质上就是一个 Pinia 插件,做了两件事:

  1. 保存:通过 store.$subscribe() 监听状态变化,序列化后写入 localStorage
  2. 恢复:Store 初始化时,从 localStorage 读取数据,通过 store.$patch() 恢复状态
// 简化版原理
function persistPlugin({ store, options }) {
  const persist = options.persist
  
  // 1. 初始化时恢复
  const stored = localStorage.getItem(persist.key)
  if (stored) {
    store.$patch(JSON.parse(stored))
  }
  
  // 2. 状态变化时保存
  store.$subscribe((mutation, state) => {
    localStorage.setItem(persist.key, JSON.stringify(state))
  })
}

3.5 Setup Store 中的持久化

前面提到过,Setup Store 的外部配置要放第三个参数:

export const useThemeStore = defineStore(
  'theme',
  () => {
    const theme = ref('light')
    const fontSize = ref(14)
    
    function toggleTheme() {
      theme.value = theme.value === 'light' ? 'dark' : 'light'
    }
    
    return { theme, fontSize, toggleTheme }
  },
  {
    persist: {
      key: 'my-app-theme',
      paths: ['theme', 'fontSize']
    }
  }
)

3.6 SSR 注意事项

在 SSR 环境下,localStorage 不存在,直接用会报错。

Nuxt 3 项目

// plugins/pinia.client.ts  ← 注意 .client.ts 后缀,只在客户端加载
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

export default defineNuxtPlugin(({ $pinia }) => {
  $pinia.use(piniaPluginPersistedstate)
})

通用 Vue 3 SSR

export const useUserStore = defineStore('user', () => {
  // ...
}, {
  persist: typeof window !== 'undefined' && {
    storage: localStorage
  }
})

四、总结

特性核心价值关键点
Setup Store用 Composition API 写 Store,更灵活私有状态、Store 组合、配置放第三个参数
插件系统给所有 Store 统一注入能力Context 对象、subscribesubscribe、onAction
状态持久化刷新页面状态不丢失paths 选择性持久化、SSR 兼容

我的实践经验

  1. 新项目优先用 Setup Store,灵活性和可维护性都更好
  2. 插件别写太多,一两个核心的就够(比如持久化 + 日志)
  3. 持久化一定要用 paths 指定字段,别把所有状态都存 localStorage
  4. SSR 项目记得处理 localStorage 不存在的问题

有问题评论区交流,觉得有帮助点个赞 👍