Pinia 深度实践:Store 组合、模块化与持久化最佳实践
Pinia 上手简单,但真正在生产项目中用好它需要考虑很多:大型项目如何组织 store?如何避免 store 之间的循环依赖?如何优雅地实现持久化?如何在 SSR 中正确使用?本文覆盖这些实际项目中的关键问题。
Store 组织模式
按领域划分文件
src/
├── stores/
│ ├── user.ts # 用户相关
│ ├── cart.ts # 购物车
│ ├── article.ts # 文章
│ ├── settings.ts # 全局设置(主题、语言)
│ └── index.ts # 统一导出
// stores/index.ts
export { useUserStore } from './user'
export { useCartStore } from './cart'
export { useArticleStore } from './article'
export { useSettingsStore } from './settings'
大型项目:领域目录
当 store 逻辑复杂时,每个 store 可以拆成多个文件:
src/
├── stores/
│ ├── user/
│ │ ├── index.ts # defineStore 入口
│ │ ├── actions.ts # action 逻辑
│ │ ├── getters.ts # getter 逻辑
│ │ └── types.ts # 类型定义
│ ├── cart/
│ │ └── ...
// stores/user/types.ts
export interface UserState {
profile: UserProfile | null
token: string
permissions: string[]
}
export interface UserProfile {
id: number
name: string
email: string
role: 'admin' | 'user'
avatar: string
}
// stores/user/actions.ts
import type { useUserStore } from './index'
// 使用类型推导获取 store 类型
type UserStore = ReturnType<typeof useUserStore>
export function createActions(
state: { profile: Ref<UserProfile | null>, token: Ref<string>, permissions: Ref<string[]> }
) {
async function login(credentials: LoginCredentials) {
const res = await authApi.login(credentials)
state.profile.value = res.profile
state.token.value = res.token
state.permissions.value = res.permissions
}
async function logout() {
await authApi.logout()
state.profile.value = null
state.token.value = ''
state.permissions.value = []
}
async function updateProfile(data: Partial<UserProfile>) {
if (!state.profile.value) return
const updated = await userApi.updateProfile(
state.profile.value.id,
data
)
state.profile.value = updated
}
function hasPermission(perm: string): boolean {
return state.permissions.value.includes(perm)
}
return { login, logout, updateProfile, hasPermission }
}
// stores/user/index.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { createActions } from './actions'
export const useUserStore = defineStore('user', () => {
const profile = ref<UserProfile | null>(null)
const token = ref('')
const permissions = ref<string[]>([])
const actions = createActions({ profile, token, permissions })
return { profile, token, permissions, ...actions }
})
Getters 的高级用法
访问其他 Store
// stores/cart.ts
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useUserStore } from './user'
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
// getter 中访问其他 store
const totalPrice = computed(() => {
const userStore = useUserStore()
const baseTotal = items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
// VIP 用户 9 折
if (userStore.profile?.role === 'admin' || userStore.profile?.vip) {
return baseTotal * 0.9
}
return baseTotal
})
return { items, totalPrice }
})
参数化 Getter
Getter 本身不能接受参数(因为它是 computed),但可以返回一个函数:
export const useArticleStore = defineStore('article', () => {
const articles = ref<Article[]>([])
// 返回函数的 getter
const articlesByAuthor = computed(() => {
return (authorId: number) => articles.value.filter(a => a.authorId === authorId)
})
// 注意:参数化 getter 不会缓存计算结果
// 每次调用都会重新执行
const articleById = computed(() => {
return (id: number) => articles.value.find(a => a.id === id)
})
return { articles, articlesByAuthor, articleById }
})
// 使用
const articleStore = useArticleStore()
const myArticles = articleStore.articlesByAuthor(currentUser.id)
优化:手动缓存参数化 Getter
export const useArticleStore = defineStore('article', () => {
const articles = ref<Article[]>([])
// 使用 Map 手动缓存
const articleByIdCache = new Map<number, Article | undefined>()
const articleById = (id: number): Article | undefined => {
if (!articleByIdCache.has(id)) {
articleByIdCache.set(id, articles.value.find(a => a.id === id))
}
return articleByIdCache.get(id)
}
// articles 变化时清空缓存
watch(articles, () => {
articleByIdCache.clear()
}, { deep: true })
return { articles, articleById }
})
Store 间通信模式
模式 1:直接调用
适用于 A store 需要读取 B store 的状态:
// stores/order.ts
export const useOrderStore = defineStore('order', () => {
const orders = ref<Order[]>([])
async function createOrder() {
const userStore = useUserStore() // 获取
const cartStore = useCartStore() // 获取
if (!userStore.isLoggedIn) {
throw new Error('请先登录')
}
const order = await api.createOrder({
userId: userStore.profile!.id,
items: cartStore.items
})
orders.value.push(order)
cartStore.clear() // 调用 cart store 的 action
}
return { orders, createOrder }
})
模式 2:订阅模式
适用于解耦场景:
// stores/user.ts
export const useUserStore = defineStore('user', () => {
const profile = ref<UserProfile | null>(null)
// 暴露一个 action 给其他 store 订阅
function onUserChange(callback: (user: UserProfile | null) => void) {
watch(profile, callback, { immediate: true })
}
return { profile, onUserChange }
})
// stores/cart.ts
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const userId = ref<number | null>(null)
// 订阅 user store 的变化
function init() {
const userStore = useUserStore()
userStore.onUserChange((user) => {
userId.value = user?.id ?? null
// 用户切换时清空购物车
items.value = []
})
}
return { items, init }
})
// 在 App.vue 中初始化
const cartStore = useCartStore()
cartStore.init()
避免循环依赖
问题:A 引用 B,B 引用 A → 循环依赖
// ❌ 循环依赖
// store A
import { useBStore } from './b'
export const useAStore = defineStore('a', () => {
function doSomething() {
const bStore = useBStore() // A 引用 B
// ...
}
return { doSomething }
})
// store B
import { useAStore } from './a'
export const useBStore = defineStore('b', () => {
function doSomethingElse() {
const aStore = useAStore() // B 引用 A
// ...
}
return { doSomethingElse }
})
解法:提取共享逻辑到第三个 store,或用事件总线:
// stores/shared.ts - 第三个 store
export const useSharedStore = defineStore('shared', () => {
const eventBus = ref({})
function emit(event: string, data?: any) {
eventBus.value[event]?.forEach((cb: Function) => cb(data))
}
function on(event: string, callback: Function) {
if (!eventBus.value[event]) {
eventBus.value[event] = []
}
eventBus.value[event].push(callback)
}
return { emit, on }
})
// store A
export const useAStore = defineStore('a', () => {
function doSomething() {
const shared = useSharedStore()
shared.emit('a:done', { result: '...' })
}
return { doSomething }
})
// store B
export const useBStore = defineStore('b', () => {
function init() {
const shared = useSharedStore()
shared.on('a:done', (data) => {
// B 响应 A 的事件
})
}
return { init }
})
持久化方案
方案 1:插件化持久化(推荐)
// plugins/persist.ts
import type { PiniaPluginContext } from 'pinia'
// 声明持久化选项类型
declare module 'pinia' {
export interface DefineStoreOptionsBase<S, Store> {
persist?: boolean | PersistOptions
}
}
interface PersistOptions {
key?: string
storage?: Storage
paths?: string[]
serializer?: { serialize: Function; deserialize: Function }
}
export function createPersistPlugin() {
return ({ store, options }: PiniaPluginContext) => {
if (!options.persist) return
const persistOptions = typeof options.persist === 'boolean'
? {}
: options.persist
const {
key = store.$id,
storage = localStorage,
paths = Object.keys(store.$state),
serializer = JSON
} = persistOptions
// 恢复状态
const saved = storage.getItem(key)
if (saved) {
try {
const parsed = serializer.parse(saved)
store.$patch(parsed)
} catch (e) {
console.warn(`[Pinia Persist] Failed to restore ${key}`)
}
}
// 监听变化
store.$subscribe((_, state) => {
// 只保存指定路径
const toSave = paths.reduce((acc, path) => {
acc[path] = state[path]
return acc
}, {} as Record<string, any>)
storage.setItem(key, serializer.serialize(toSave))
})
}
}
// 注册插件
const pinia = createPinia()
pinia.use(createPersistPlugin())
方案 2:手动持久化
// 在 store 内部处理
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const profile = ref<UserProfile | null>(
JSON.parse(localStorage.getItem('profile') || 'null')
)
// 自动持久化
watch(token, (v) => localStorage.setItem('token', v))
watch(profile, (v) =>
localStorage.setItem('profile', JSON.stringify(v)),
{ deep: true }
)
function setAuth(t: string, p: UserProfile) {
token.value = t
profile.value = p
}
function clearAuth() {
token.value = ''
profile.value = null
localStorage.removeItem('token')
localStorage.removeItem('profile')
}
return { token, profile, setAuth, clearAuth }
})
方案 3:使用 pinia-plugin-persistedstate
pnpm add pinia-plugin-persistedstate
// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// store 中声明
export const useSettingsStore = defineStore('settings', {
state: () => ({
theme: 'light',
language: 'zh-CN',
sidebarCollapsed: false
}),
persist: {
key: 'app-settings', // 自定义 key
storage: localStorage, // 默认 localStorage
pick: ['theme', 'language'] // 只持久化部分字段
}
})
在 composable 中使用 store
封装业务 composable
// composables/useAuth.ts
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
export function useAuth() {
const userStore = useUserStore()
const { profile, token, isLoggedIn, isAdmin } = storeToRefs(userStore)
const { login, logout } = userStore
// 封装更高级的接口
async function ensureLogin() {
if (!isLoggedIn.value) {
const redirect = window.location.pathname
await navigateTo(`/login?redirect=${redirect}`)
return false
}
return true
}
return {
profile,
token,
isLoggedIn,
isAdmin,
login,
logout,
ensureLogin
}
}
组件外使用 store
// 注意:在组件外使用 store 时,pinia 实例必须已安装
// ❌ 在 main.ts 中 createApp 之前使用
// const userStore = useUserStore() // 报错!pinia 未安装
// ✅ 在 app.run() 之后
const app = createApp(App)
app.use(pinia)
const userStore = useUserStore() // ✅
// ✅ 在路由守卫中使用(app 已挂载)
router.beforeEach(() => {
const userStore = useUserStore()
if (!userStore.isLoggedIn) return '/login'
})
SSR(Nuxt)中的注意事项
状态共享问题
在 SSR 中,多个请求共享同一个 Node.js 进程。如果 store 是全局单例,会导致请求间状态污染。
Pinia 在 Nuxt 中自动处理了这个问题:每个请求创建独立的 pinia 实例。
// Nuxt 自动注册 pinia,无需手动配置
// server-side:在 plugin 或 middleware 中初始化数据
export default defineNuxtPlugin(async (nuxtApp) => {
const userStore = useUserStore()
const token = useCookie('token').value
if (token) {
userStore.token = token
userStore.profile = await $fetch('/api/me')
}
})
// 客户端:hydration 时自动继承 server 端状态
const userStore = useUserStore()
// 可以直接读取,不会重复请求
状态序列化
// 手动序列化和反序列化(适用于非 Nuxt 的 SSR 项目)
// server entry
import { createPinia } from 'pinia'
import { renderToString } from '@vue/server-renderer'
export async function render() {
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
const html = await renderToString(app)
// 序列化 store 状态
const state = JSON.stringify(pinia.state.value)
return `
<div id="app">${html}</div>
<script>window.__PINIA_STATE__ = ${state}</script>
`
}
// client entry
import { createPinia } from 'pinia'
const pinia = createPinia()
// 从服务端注入的状态恢复
if (window.__PINIA_STATE__) {
pinia.state.value = JSON.parse(window.__PINIA_STATE__)
}
const app = createApp(App)
app.use(pinia)
app.mount('#app')
调试技巧
使用 $subscribe 调试
// 开发环境全局监听
if (import.meta.env.DEV) {
const pinia = getActivePinia()
pinia._s.forEach((store, name) => {
store.$subscribe((mutation, state) => {
console.log(`[${name}] ${mutation.type}`, state)
})
})
}
DevTools 时间旅行
Pinia DevTools 支持时间旅行调试,可以在状态历史之间切换。但要注意:
- 直接修改
state.x = ...的时间旅行有效 $patch修改也有记录- 但 action 内的异步操作不影响时间旅行
总结
| 场景 | 推荐方案 |
|---|---|
| 小型项目 | 单文件 store,按领域划分 |
| 中大型项目 | 领域目录,拆分 actions/getters/types |
| 持久化 | pinia-plugin-persistedstate |
| Store 通信 | 直接调用(简单)→ 事件总线(解耦) |
| 参数化 getter | 返回函数 + 手动缓存 |
| SSR | Nuxt 自动处理 / 非 Nuxt 手动序列化 |
| 调试 | DevTools + $subscribe 日志 |
Pinia 的设计哲学是「简单的事情保持简单」,大部分场景直接用 ref + computed 就够了。只有在遇到特定问题时才引入插件或模式——不要过度设计。
Comments | 0条评论