Pinia store 设计实践:模块化与代码分割的工程经验
Pinia store 设计实践:模块化与代码分割的工程经验
Pinia 作为 Vue 3 的官方推荐状态管理库,比 Vuex 简单很多,但用好它并不容易。store 怎么拆分、状态怎么组织、异步逻辑放哪、要不要用 setup 语法——这些问题在项目规模大了之后会变得很突出。
我维护过一个从 Vue 2 迁移到 Vue 3 + Pinia 的中后台项目,store 从 3 个变成了 20+ 个,踩了不少坑,最后总结出一套还算稳定的写法。
问题背景:store 为什么会变得难以维护
很多团队用 Pinia 之后会遇到这些问题:
- store 越来越大:一个 store 塞了所有相关状态,从用户信息到 UI 配置全放一起
- 异步逻辑散落:有的在 store 的 action 里,有的在组件的
onMounted里 - 跨 store 调用混乱:store A 直接 import store B,形成复杂的依赖关系
- 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 的设计关键点:
- 按业务领域拆分,不要按数据类型拆分
- 优先用 setup 语法,对 TypeScript 支持更好
- 异步逻辑先在 store 里简单写,重复了再抽 composable
- 跨 store 调用可以用,但注意不要循环依赖
- SSR 项目注意浏览器特有逻辑的隔离
- 统一从 index.ts 导出,方便维护
Pinia 本身很轻量,怎么组织是团队约定的事。保持风格统一比选哪种写法更重要。
