前言
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 Store 和 Setup Store。
javascript
// 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 做不到的事情:
- 私有状态 ------ 不 return 的变量就是私有的,外部访问不到
- 自由使用 Composition API ------ watch、自定义 Hook、inject,随便用
- Store 之间互相组合 ------ 可以在一个 Store 里直接调用另一个 Store
1.2 私有状态(Option Store 做不到)
这个特性我特别喜欢。有些内部状态你不想暴露出去,在 Setup Store 里不 return 就行了:
javascript
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 做这件事特别自然:
javascript
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 都会出问题。
javascript
// ❌ 忘记暴露 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 函数,如果需要配置(比如持久化),得放第三个参数:
javascript
export const useUserStore = defineStore(
'user',
() => {
const name = ref('')
return { name }
},
{
persist: true // ← 第三个参数,不是第二个
}
)
这个我刚开始用的时候搞错过,配置死活不生效,排查了半天才发现放错位置了 😅
二、Pinia 插件系统
2.1 插件是什么?
Pinia 的插件就是一个函数,接收一个 context 对象,可以给 Store 添加全局能力。
javascript
// 最简单的插件
function myPlugin({ store }) {
store.$hello = () => console.log('Hello from plugin!')
}
// 注册
const pinia = createPinia()
pinia.use(myPlugin)
注册之后,所有 Store 都能访问 $hello 方法。
2.2 插件的 Context 对象
每个插件都能拿到一个 context,里面有几个很有用的东西:
javascript
function myPlugin(context) {
context.pinia // Pinia 实例,可以跨 Store 操作
context.app // Vue 应用实例,可以访问 router、i18n 等
context.store // 当前正在创建的 Store 实例
context.options // defineStore() 的配置对象
}
其中 context.store 和 context.options 是最常用的。通过 context.store,你可以给特定的 Store 添加能力;通过 context.options,你可以根据 Store 的配置做不同的事情。
2.3 实战:写一个日志插件
开发阶段,我经常用一个日志插件来追踪状态变化:
javascript
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(比如搜索)需要防抖,可以在插件层面统一处理:
javascript
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() |
| 拦截 Action | store.$onAction() |
| 状态持久化 | $subscribe + localStorage |
| 跨标签页同步 | 监听 storage 事件 |
| 加密存储 | 自定义 serializer |
| 动态权限控制 | Action 执行前校验 |
三、状态持久化
3.1 为什么需要持久化?
默认情况下,Pinia 的状态存在内存里,刷新页面就没了。但有些状态你希望刷新后还在:
- 用户的登录信息(token)
- 主题设置(暗色/亮色)
- 语言偏好
- 购物车数据
3.2 pinia-plugin-persistedstate
社区标准方案是 pinia-plugin-persistedstate,用起来很简单:
安装:
bash
npm install pinia-plugin-persistedstate
注册:
javascript
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
使用:
javascript
export const useUserStore = defineStore('user', {
state: () => ({
name: 'Alice',
token: 'xxx'
}),
persist: true // 一行搞定
})
就这么简单,刷新页面后 name 和 token 都还在。
3.3 高级配置
实际项目中,你可能需要更精细的控制:
javascript
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 插件,做了两件事:
- 保存 :通过
store.$subscribe()监听状态变化,序列化后写入 localStorage - 恢复 :Store 初始化时,从 localStorage 读取数据,通过
store.$patch()恢复状态
javascript
// 简化版原理
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 的外部配置要放第三个参数:
javascript
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 项目:
javascript
// plugins/pinia.client.ts ← 注意 .client.ts 后缀,只在客户端加载
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
export default defineNuxtPlugin(({ $pinia }) => {
$pinia.use(piniaPluginPersistedstate)
})
通用 Vue 3 SSR:
javascript
export const useUserStore = defineStore('user', () => {
// ...
}, {
persist: typeof window !== 'undefined' && {
storage: localStorage
}
})
四、总结
| 特性 | 核心价值 | 关键点 |
|---|---|---|
| Setup Store | 用 Composition API 写 Store,更灵活 | 私有状态、Store 组合、配置放第三个参数 |
| 插件系统 | 给所有 Store 统一注入能力 | Context 对象、 <math xmlns="http://www.w3.org/1998/Math/MathML"> s u b s c r i b e 、 subscribe、 </math>subscribe、onAction |
| 状态持久化 | 刷新页面状态不丢失 | paths 选择性持久化、SSR 兼容 |
我的实践经验:
- 新项目优先用 Setup Store,灵活性和可维护性都更好
- 插件别写太多,一两个核心的就够(比如持久化 + 日志)
- 持久化一定要用
paths指定字段,别把所有状态都存 localStorage - SSR 项目记得处理 localStorage 不存在的问题
有问题评论区交流,觉得有帮助点个赞 👍