WSの小屋

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条评论