Pinia store 设计实践:模块化与代码分割的工程经验

Pinia store 设计实践:模块化与代码分割的工程经验

Pinia 作为 Vue 3 的官方推荐状态管理库,比 Vuex 简单很多,但用好它并不容易。store 怎么拆分、状态怎么组织、异步逻辑放哪、要不要用 setup 语法——这些问题在项目规模大了之后会变得很突出。

我维护过一个从 Vue 2 迁移到 Vue 3 + Pinia 的中后台项目,store 从 3 个变成了 20+ 个,踩了不少坑,最后总结出一套还算稳定的写法。

问题背景:store 为什么会变得难以维护

很多团队用 Pinia 之后会遇到这些问题:

  1. store 越来越大:一个 store 塞了所有相关状态,从用户信息到 UI 配置全放一起
  2. 异步逻辑散落:有的在 store 的 action 里,有的在组件的 onMounted
  3. 跨 store 调用混乱:store A 直接 import store B,形成复杂的依赖关系
  4. SSR 支持问题:在 Nuxt 或 SSR 项目里 store 状态互相污染

这些问题其实是组织方式的问题,不是 Pinia 本身的问题。

核心原则:store 按领域拆分

不要按数据类型拆分,要按业务领域拆分

错误的拆分方式(按数据类型):

stores/
  state.ts       // 所有状态
  actions.ts     // 所有操作
  getters.ts     // 所有 getter

这种方式把相关逻辑拆散了,维护时要来回跳。

正确的拆分方式(按业务领域):

stores/
  user.ts         // 用户相关
  order.ts        // 订单相关
  product.ts      // 产品相关
  common/         // 公共的
    ui.ts         // UI 状态(loading、modal 等)
    dict.ts       // 字典数据

每个 store 都是一个完整的业务领域,包含状态、操作、查询逻辑。

两种 store 写法对比:选项式 vs setup 语法

Pinia 支持两种写法:

选项式写法

export const useUserStore = defineStore('user', {
  state: () => ({
    id: '',
    name: '',
    token: '',
    permissions: [] as string[],
  }),

  getters: {
    isAdmin: (state) => state.permissions.includes('admin'),
    fullName: (state) => `${state.name} (${state.id})`,
  },

  actions: {
    async login(username: string, password: string) {
      const res = await api.login({ username, password })
      this.$patch({
        id: res.id,
        name: res.name,
        token: res.token,
        permissions: res.permissions,
      })
    },
    logout() {
      this.$reset()
    },
  },
})

setup 语法

export const useUserStore = defineStore('user', () => {
  // 状态
  const id = ref('')
  const name = ref('')
  const token = ref('')
  const permissions = ref<string[]>([])

  // Getter
  const isAdmin = computed(() => permissions.value.includes('admin'))
  const fullName = computed(() => `${name.value} (${id.value})`)

  // 操作
  async function login(username: string, password: string) {
    const res = await api.login({ username, password })
    id.value = res.id
    name.value = res.name
    token.value = res.token
    permissions.value = res.permissions
  }

  function logout() {
    id.value = ''
    name.value = ''
    token.value = ''
    permissions.value = []
  }

  return {
    id,
    name,
    token,
    permissions,
    isAdmin,
    fullName,
    login,
    logout,
  }
})

怎么选

用 setup 语法的情况

  • 业务逻辑复杂,状态之间有依赖
  • 需要更好的 TypeScript 类型推导
  • 习惯 Composition API 的写法

用选项式的情况

  • store 比较简单,状态不多
  • 团队成员更熟悉 Vue 2 的风格
  • 需要 Vue Devtools 的调试支持(两者都支持,但有些人觉得选项式更直观)

我的建议是:统一用 setup 语法,因为它对 TypeScript 支持更好,也更接近 Vue 3 的主流写法。

异步逻辑放哪里

方案一:全部放在 store action 里

// stores/order.ts
export const useOrderStore = defineStore('order', () => {
  const orders = ref<Order[]>([])
  const loading = ref(false)

  async function fetchOrders(params: OrderQuery) {
    loading.value = true
    try {
      orders.value = await api.getOrders(params)
    } finally {
      loading.value = false
    }
  }

  return { orders, loading, fetchOrders }
})

组件里调用:

<script setup>
import { useOrderStore } from '@/stores/order'

const orderStore = useOrderStore()

onMounted(() => {
  orderStore.fetchOrders({ page: 1, pageSize: 20 })
})
</script>

这是最简单的做法,适合业务不太复杂的场景。

方案二:fetch 逻辑抽到 composable 里

当同一个接口在多个组件里要用时,抽一个 composable:

// composables/useOrderList.ts
export function useOrderList() {
  const orders = ref<Order[]>([])
  const loading = ref(false)
  const total = ref(0)

  const query = reactive({
    keyword: '',
    status: '',
    page: 1,
    pageSize: 20,
  })

  async function fetchOrders() {
    loading.value = true
    try {
      const res = await api.getOrders(query)
      orders.value = res.list
      total.value = res.total
    } finally {
      loading.value = false
    }
  }

  function resetQuery() {
    query.keyword = ''
    query.status = ''
    query.page = 1
  }

  return {
    orders,
    loading,
    total,
    query,
    fetchOrders,
    resetQuery,
  }
}

组件里:

<script setup>
import { useOrderList } from '@/composables/useOrderList'

const { orders, loading, total, query, fetchOrders, resetQuery } = useOrderList()

onMounted(fetchOrders)
</script>

方案三:用 pinia-plugin-fetch 之类的插件

对于复杂的异步管理,有些人喜欢用 pinia-plugin-fetch 或自己封装:

// 这种方式适合超复杂的异步状态管理
// 大部分项目不需要,用方案一或二就够了

我的建议是:先用方案一,当发现同一个逻辑在多个组件里重复时,再抽成方案二。不要过早抽象。

跨 store 怎么调用

直接 import

// stores/user.ts
export const useUserStore = defineStore('user', () => {
  const permissions = ref<string[]>([])

  function hasPermission(perm: string) {
    return permissions.value.includes(perm)
  }

  return { permissions, hasPermission }
})

// stores/order.ts
import { useUserStore } from './user'

export const useOrderStore = defineStore('order', () => {
  async function createOrder(data: OrderData) {
    const userStore = useUserStore()

    // 权限校验
    if (!userStore.hasPermission('order:create')) {
      throw new Error('没有创建订单权限')
    }

    return await api.createOrder(data)
  }
})

这是可以的,但要注意不要形成循环依赖

store A import store B
store B import store C
store C import store A  // 循环依赖,会出问题

跨 store 共享逻辑:用 composable 过渡

如果跨 store 调用变多,可以把共享逻辑抽成 composable:

// composables/usePermission.ts
export function usePermission() {
  const userStore = useUserStore()

  function hasPermission(perm: string) {
    return userStore.permissions.includes(perm)
  }

  function hasAnyPermission(perms: string[]) {
    return perms.some(p => userStore.permissions.includes(p))
  }

  return {
    permissions: computed(() => userStore.permissions),
    hasPermission,
    hasAnyPermission,
  }
}

// stores/order.ts
import { usePermission } from '@/composables/usePermission'

export const useOrderStore = defineStore('order', () => {
  const { hasPermission } = usePermission()

  async function createOrder(data: OrderData) {
    if (!hasPermission('order:create')) {
      throw new Error('没有创建订单权限')
    }
    return await api.createOrder(data)
  }
})

SSR 场景下的注意事项

如果你的项目用了 Nuxt 或其他 SSR 框架,store 需要特别处理。

问题:服务端和客户端状态污染

默认情况下,服务端每个请求都会创建新的 Pinia 实例,但如果你的 store 有 localStorage 之类的浏览器特有逻辑,会报错。

解决:浏览器特有逻辑延迟到客户端

export const useUserStore = defineStore('user', () => {
  const token = ref('')

  // 这个函数只在客户端执行
  function initFromStorage() {
    if (typeof window === 'undefined') return
    token.value = localStorage.getItem('token') || ''
  }

  return { token, initFromStorage }
})

Nuxt 3 的做法

Nuxt 3 里 Pinia 是 useCookie 配合使用:

// stores/auth.ts
export const useAuthStore = defineStore('auth', () => {
  const token = useCookie('token', {
    maxAge: 60 * 60 * 24 * 7, // 7 天
    secure: true,
  })

  const user = ref<User | null>(null)

  async function login(username: string, password: string) {
    const res = await api.login({ username, password })
    token.value = res.token
    user.value = res.user
  }

  return { token, user, login }
})

useCookie 代替 localStorage,好处是 SSR 和 CSR 都能用同一套 API。

推荐的 store 文件结构

综合以上建议,这是我在实际项目里的结构:

src/
  stores/
    index.ts              # 统一导出
    user.ts               # 用户
    order.ts              # 订单
    product.ts            # 产品
    common/
      ui.ts               # UI 状态(loading、modal)
      dict.ts             # 字典
  composables/            # 可复用的组合逻辑
    usePermission.ts
    usePagination.ts
    useTableList.ts
  types/
    store.d.ts            # store 类型声明

stores/index.ts 统一导出所有 store:

export { useUserStore } from './user'
export { useOrderStore } from './order'
export { useProductStore } from './product'
export { useUIStore } from './common/ui'
export { useDictStore } from './common/dict'

这样调用方只需要:

import { useUserStore } from '@/stores'

总结

Pinia store 的设计关键点:

  1. 按业务领域拆分,不要按数据类型拆分
  2. 优先用 setup 语法,对 TypeScript 支持更好
  3. 异步逻辑先在 store 里简单写,重复了再抽 composable
  4. 跨 store 调用可以用,但注意不要循环依赖
  5. SSR 项目注意浏览器特有逻辑的隔离
  6. 统一从 index.ts 导出,方便维护

Pinia 本身很轻量,怎么组织是团队约定的事。保持风格统一比选哪种写法更重要。

最后更新 4/20/2026, 12:15:46 AM