1. 为什么 Pinia 会成为 Vue3 项目的默认选择
在 Vue3 + Vite 项目里,数据流一旦过了“父子组件互传”这个阶段,几乎每个人都会遇到同一种尴尬:props像瀑布一样往下漏,$emit像接力棒一样往回传,中间再掺和几个provide/inject、ref、computed,代码很快就变成一团乱麻。这时候把共享状态抽离出来,交给一层独立仓库统一维护,是最直接也最成熟的解法。到了 Vue3 时代,这个解决方案已经有了明确答案:Pinia。它不是 Vuex 换个马甲,而是从 API 设计、类型推导、模块组织到开发体验,都为“组合式 API”重新设计的一套状态管理架构。
Pinia 设计里最有意思的一点,是打破了 Vuex 时代“全局一个仓库、按模块切片”的固定套路。Pinia 天生是多仓库的,每个 store 都是一个独立的useXxxStore()函数,组件里想用哪个就调用哪个。这带来的架构收益非常实在:可以按业务域收敛状态,拿到某个模块的代码,视觉范围内就能看到这块业务的数据、变更方法、异步请求全在一起,不需要在store/modules/a、store/actions/b、store/getters/c之间来回跳。这一条直接改变了多人协作时的心智负担,也是我后来在新项目里彻底放弃 Vuex 的最大原因。
这篇文章按“架构”的角度来讲,不会逐行翻译文档,而是讲清楚三件事:Pinia 的设计逻辑是什么、在 Vite 工程里怎么组织多 store 才不乱、以及上了生产环境之后会遇到哪些文档里不写的坑。适合正在做后台管理系统、中台项目或者任何需要长期迭代的 Vue3 项目的前端开发者参考。
1.1 对比 Vuex:不是改版,是推倒重来
很多从 Vue2 过来的同学,第一反应是拿 Pinia 和 Vuex 做等价对比。实际上两者的差距不能只靠“新增了 setup store”来概括,我整理了这几个关键维度:
| 对比项 | Vuex 4 | Pinia |
|---|---|---|
| store 形态 | 单一 store + modules 嵌套 | 多 store 平铺,每个 store 独立 |
| 修改状态 | commit mutation,同步限制严格 | 直接改 state,或调用 action,无严格约束 |
| 异步处理 | action 中可异步,仍需 commit | action 本身就是普通函数,随意 await |
| TypeScript 推导 | 需要大量手写类型与辅助函数 | store 创建即获得完整类型推导 |
| 嵌套模块 | 支持 modules 嵌套,复杂度高 | 不支持嵌套,但可以互相调用,结构更扁平 |
| 插件生态 | 官方插件较少,写法笨重 | 持久化等插件轻量易配 |
最关键的是 Mutation 没了。Vuex 当年引入 Mutation,是为了配合 DevTools 做时间旅行调试,但在简化心智和类型推导面前,这件事的收益越来越低。Pinia 直接告诉开发者:想改状态就改,想写异步就写,DevTools 依然能记录每一次变更。这非常符合“组合式 API”的哲学,把复杂机制留给框架,把简单留给业务代码。
1.2 底层原理:reactive 与 computed 撑起整座仓库
Pinia 说到底是 Vue3 响应式 API 的一层封装。一个 store 的state本质上是reactive(obj),getters是computed的集合,actions就是普通函数。理解这一点,很多使用时的“直觉”就有了依据:
为什么直接修改
store.count++是可以的?因为store对象本身是被reactive处理的,属性访问会被响应式系统捕获,界面自然更新。
为什么解构
store里的 state 会丢失响应式?因为reactive对属性拦截的效果依赖对象引用,你把原始值拿出去,就脱离了代理对象。
为什么
$patch能批量更新?因为$patch内部会对整个变更对象做一次合并,再一次性触发依赖更新,比连续多次赋值性能更好,也更利于 DevTools 记录为单次变更。
源码层面,Pinia 在创建 store 时会给每个仓库生成一个唯一id,并把 state、getters、actions 挂到一个通过reactive构建的上下文中。所谓 action 里的this,指向的就是这个上下文的代理对象。所以你在 action 里写this.count = 2和写store.count = 2,最终执行路径几乎一样。理解这层机制后,你会自然明白为什么 setup store 里用ref声明状态也行,用reactive声明状态也行,因为他们最后都会转换成响应式数据。
1.3 两种 store 写法:Option Store 与 Setup Store 怎么选
Pinia 提供两套定义 API,这是初学者最容易纠结的点。我把这两者的取舍说透:
Option Store:用
state/getters/actions三个字段定义,结构最接近 Vuex,写起来规整。优点是代码一眼能认出哪些是数据、哪些是计算属性、哪些是行为,适合团队里新人多、希望约定强的场景。Setup Store:写法和组件里的
setup()几乎一样,用ref/reactive/computed声明状态,用普通函数定义方法。优点是可以自由使用watch、computed、自定义组合式函数,逻辑复用更方便。
我的建议是:老项目迁移或团队规范强调可读性时用 Option Store;新项目、业务逻辑重的场景用 Setup Store。实际上大型项目里两种混用也完全没问题,Pinia 不限制。真正要注意的是风格统一,别一个模块一种写法,会让后来维护的人拿着代码无所适从。
2. 工程化落地:在 Vite 项目里初始化 Pinia
光看理念没用,得落到代码里。这一节从零开始,讲 Vite 项目里怎么接入 Pinia、目录怎么摆、第一个 store 怎么写才规范。
2.1 安装与入口配置
Vite 创建的项目默认没有 Pinia,安装只需要一条命令:
npm install pinia # 或者 pnpm add pinia安装完成后,修改项目的入口文件main.ts(或main.js),把 Pinia 挂到应用实例上:
import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' const app = createApp(App) const pinia = createPinia() app.use(pinia) app.mount('#app')这里有一个容易被忽略的细节:createPinia()生成的实例是一个 Vue 插件,但它在内部会注册一个全局的provide,把 pinia 实例注入到所有组件。所以组件里调用useStore()时不需要手动传 pinia 实例——只要你在app.use(pinia)之后,通过组件渲染链访问,它都能自动找到。但如果要在组件外使用 store(比如路由守卫、工具函数),就得手动把 pinia 实例传进去,这个坑后面专门讲。
2.2 目录结构:多 store 的推荐组织方式
Pinia 没有强制的目录规范,我基于多个中后台项目的实践,推荐这样组织:
src/ ├── stores/ │ ├── index.ts # 统一导出,方便引用 │ ├── modules/ │ │ ├── user.ts # 用户信息、登录态 │ │ ├── app.ts # 侧边栏、主题、布局状态 │ │ ├── permission.ts # 权限、路由表 │ │ ├── tabs.ts # 多标签页导航 │ │ └── settings.ts # 系统设置 │ └── plugins/ │ └── persist.ts # 持久化插件封装这里的关键思路是:按业务域而不是按数据类型来划分文件。比如不要搞state.ts、actions.ts这种按角色分的目录,因为一个业务变更通常会同时改数据、方法和计算属性,拆开反而增加查找成本。按模块拆分后,每个文件内部再按state/getters/actions组织内容,读起来才顺。
2.3 第一个完整 store:以用户登录态为例
拿中后台最常见的用户模块来展示一个接近生产质量的 store:
// src/stores/modules/user.ts import { defineStore } from 'pinia' import { loginApi, getUserInfoApi, logoutApi } from '@/api/auth' import { storage } from '@/utils/storage' export const useUserStore = defineStore('user', { state: () => ({ token: storage.get('token') || '', userInfo: {} as UserInfo, roles: [] as string[], }), getters: { isLogin: (state) => !!state.token, displayName: (state) => state.userInfo?.nickname || state.userInfo?.username || '未登录', }, actions: { async login(payload: LoginParams) { const { token } = await loginApi(payload) this.token = token storage.set('token', token) }, async fetchUserInfo() { const data = await getUserInfoApi() this.userInfo = data.userInfo this.roles = data.roles ?? [] }, async logout() { await logoutApi() this.reset() }, reset() { this.token = '' this.userInfo = {} this.roles = [] storage.remove('token') }, }, })这个例子里有几个值得展开的点:
- state 用函数返回初始值,和组件 data 保持一致,好处是每次创建 store 都能拿到新的初始状态,测试和复用更安心。
- getters 里返回新对象或经过计算的值,比如
displayName汇总了多个字段,组件里拿到它就不需要再写一遍if else。 - action 里直接访问
this,Option Store 约束下的this就是 store 实例,类型推导在编辑器中会直接提示,不需要额外声明返回值类型。 reset()方法做成 action,可以一次性把所有状态恢复为初始值。登录过期、切换账号这类场景,只要调用一次store.reset(),不会遗漏任何字段。
3. 模块化状态管理架构实操
到了这一步,多 store 之间如何协动、数据流怎么设计,就是架构的核心了。这一节用真实后台系统的拆法做案例。
3.1 典型后台系统的拆法:一份可复用的模块对照表
后台管理系统是 Pinia 最常见的应用场景。我一般按下面的方式拆模块:
| 模块名称 | 负责的状态 | 典型 actions | 典型 getters |
|---|---|---|---|
| user | token、用户信息、角色 | login / fetchUserInfo / logout | isLogin、displayName |
| permission | 路由表、按钮权限、菜单树 | generateRoutes / reset | accessibleRoutes |
| tabs | 多标签页列表、当前激活页 | addTab / removeTab / closeOthers | cachedViews |
| app | 侧边栏展开收起、设备类型、全局 loading | toggleSidebar / setDevice | sidebarOpened |
| settings | 主题色、布局模式、语言、水印 | setTheme / setLayout / setLanguage | themeStyle |
这个划分有个核心原则:每个模块只关心自己的一组内聚状态,模块之间可以通过调用对方的方法协作,但不直接修改对方 state。比如登录成功后,user store 拿到了角色信息,接下来要把角色同步给 permission store 去生成可访问路由。正确的做法是在 user 的 action 里调用 permission store 的 action,或者由页面组件编排两者的调用顺序,而不是 user 直接把 permission 的数据给写了。这样每一块状态都有唯一“责任人”,排查问题时能迅速锁定。
3.2 store 之间互相调用与依赖顺序
一个 store 内部调用另一个 store,在 Pinia 里非常自然,直接useOtherStore()即可:
// src/stores/modules/permission.ts import { useUserStore } from './user' export const usePermissionStore = defineStore('permission', { state: () => ({ routes: [] as RouteRecordRaw[], }), actions: { async generateRoutes() { const userStore = useUserStore() const roles = userStore.roles // 根据角色过滤动态路由 const accessedRoutes = filterAsyncRoutes(asyncRoutes, roles) this.routes = accessedRoutes }, }, })注意这里有一个store 必须已被创建的时序问题:在 script 顶层或组件setup()里调用usePermissionStore()时,组件渲染链已经能提供 pinia 实例,所以没问题。但如果是在 store 的state初始化函数里直接调用另一个 store,就会报“getActivePinia was called with no active Pinia”的错。解决办法是:不要在 state 初始化里调用其他 store,应该放到 action 或 getter 里再取。getter 因为执行时机晚,反而安全。
3.3 Setup Store 高阶用法:复用组合式函数
前面提到 Setup Store 可以自由使用组合式函数,这里给一段实际代码:
// src/stores/modules/device.ts import { ref } from 'vue' import { useEventListener } from '@vueuse/core' import { defineStore } from 'pinia' export const useDeviceStore = defineStore('device', () => { const width = ref(window.innerWidth) const isMobile = ref(false) function update() { width.value = window.innerWidth isMobile.value = width.value < 768 } useEventListener(window, 'resize', update) update() return { width, isMobile } })在这个文件里,useEventListener会在 store 创建时自动注册监听,销毁时自动清理。这要比在组件里写一堆addEventListener/removeEventListener干净得多。更重要的是,width和isMobile天然是响应式的,任何组件调用useDeviceStore()都能同步获得最新的窗口状态。
3.4 数据流的职责边界:store 与组件该各管什么
状态管理架构最模糊的边界在于“哪些数据该进 store,哪些留在组件里”。我的判断标准很简单:多组件共享的数据进 store,纯组件内部 UI 状态留在组件内。比如弹窗的visible、表单的临时输入值、列表的筛选条件,这些通常不需要全局共享,放进 store 只会徒增噪音。反过来,登录态、权限列表、用户偏好、全局主题这类被多处依赖的状态,放到 store 是合理的。
另外注意,store 不该被当成“万能请求层”。一份只服务于某个表格的数据,直接在组件里的onMounted请求并存在ref中就好;除非数据在多个不相关的组件里都要用,或者需要缓存复用,才放进 store。这个原则能帮整个项目保持轻盈,避免 store 文件越来越大、越来越杂。
4. 持久化、HMR 与生产环境进阶
状态管理到了生产环境,躲不开几个现实问题:刷新页面后状态不能丢、开发时的热更新不能把 store 状态弄丢、敏感数据不能明文存在 localStorage。这一节讲透。
4.1 持久化方案:使用 pinia-plugin-persistedstate
官方没有默认带上持久化能力,社区最常用的是pinia-plugin-persistedstate。安装配置很简单:
npm install pinia-plugin-persistedstate// src/stores/index.ts import { createPinia } from 'pinia' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' const pinia = createPinia() pinia.use(piniaPluginPersistedstate) export default pinia然后在具体 store 里开启:
export const useUserStore = defineStore('user', { state: () => ({ token: '', userInfo: {} }), persist: { key: 'my-app-user', storage: sessionStorage, // 按需选择 localStorage / sessionStorage pick: ['token', 'userInfo'], // 只持久化指定字段 }, // ... })这里有三个经验之谈:
- pick 字段时,只保留真正需要“跨刷新”的数据。比如
token和userInfo需要保留,但临时的下拉列表数据没必要。 - 考虑用
sessionStorage而不是localStorage存敏感信息。localStorage会永久保留,而用户关闭浏览器后sessionStorage自动清理,安全边界更好。 - 不要在 store 里直接存大体积数据(比如完整的用户列表、文件 base64),持久化插件会把它们序列化到浏览器存储里,刷新一次就占一次内存,性能和隐私都受影响。
4.2 HMR 热更新:开发时不丢 state
Vite 的开发服务器天然支持热更新,但 store 模块的热更新有讲究。Pinia 官方提供了一个模式,在 store 文件底部加上:
import { acceptHMRUpdate, defineStore } from 'pinia' if (import.meta.hot) { import.meta.hot.accept(acceptHMRUpdate(useUserStore, import.meta.hot)) }这段代码的作用是:当user.ts文件被修改时,让 Vite 只替换 user store 的定义,而不是销毁整个应用再重新渲染。如果不加,HMR 可能会重建所有 store,导致你手动修改过的状态(比如登录态)在编辑器保存瞬间被清空,调试成本立刻飙升。建议每个 store 文件都加上这段,不要偷懒。
4.3 组件外使用 store:路由守卫与工具函数
在路由守卫里判断登录态、在 axios 拦截器里读取 token,都是很常见的需求。但这些代码不在组件渲染链里,直接useUserStore()会找不到 pinia 实例。解决办法是在应用入口创建一个共享 pinia 实例,然后在外部使用时手动传入:
// src/main.ts export const pinia = createPinia() app.use(pinia)// src/router/guard.ts import { pinia } from '@/main' import { useUserStore } from '@/stores/modules/user' router.beforeEach((to, from, next) => { const userStore = useUserStore(pinia) if (!userStore.token && to.path !== '/login') { next('/login') } else { next() } })这种写法的要点是useUserStore(pinia)显式传入实例。虽然有点啰嗦,但避免了“组件外拿不到 store”的报错,也方便单元测试时注入不同的 pinia 实例。
5. 常见问题与排查技巧实录
最后把我在项目中反复踩过的坑集中列一下,这些问题文档里很少写清楚,但实际遇到时非常影响开发效率。
5.1 解构 store 导致响应式丢失
这是 Pinia 最经典的坑,症状是“页面第一次渲染正常,数据一变页面不动”。原因我已经在前文提过:reactive代理的属性一旦被解构成普通变量,就脱离了响应式拦截。解决办法是使用storeToRefs:
import { storeToRefs } from 'pinia' const userStore = useUserStore() const { token, userInfo } = storeToRefs(userStore) // 保持响应式 const { login, logout } = userStore // action 直接解构没问题注意storeToRefs只对 state 和 getters 有效,actions 是普通函数,直接解构即可。千万不要在storeToRefs里传 actions,会得到一个毛都没有的响应式对象。
5.2 $patch 与整体替换 state
有些同学喜欢写store.$state = newObject来做整组替换,这在 Pinia 里会报错或触发警告。因为 store 的 state 对象在创建时就已经被reactive代理,整体替换引用等于试图打破这个代理关系。规范做法有两种:
// 方式一:$patch 批量修改 store.$patch({ token: 'new-token', userInfo: { ...store.userInfo, nickname: '新名字' }, }) // 方式二:逐个赋值 store.token = 'new-token' store.userInfo = { ...store.userInfo, nickname: '新名字' }$patch还有一个回调形式,方便写更复杂的逻辑:
store.$patch((state) => { state.tabs = state.tabs.filter((tab) => tab.path !== targetPath) state.activeTab = lastTab })用回调形式时,体内的state是响应式的,直接改属性没问题。这比拼出一个完整的 state 对象再覆盖要安全得多。
5.3 刷新后 store 状态错乱:区分“持久化状态”和“临时状态”
很多中后台项目会遇到“F5 一闪回到登录页”或“刷新后菜单路由不对”的问题。多数情况下不是代码逻辑错,而是持久化配置没覆盖到该保留的状态。典型场景是:用户登录后动态生成了可访问路由,但这个动态路由列表没有持久化,刷新后权限 store 回到空数组,于是路由守卫把用户踢回登录页。解决方法就是把permission.routes加入持久化的pick配置里,或者结合本地存储重建用户信息和路由。排查时建议开 DevTools 的 Application 面板,直接看 localStorage / sessionStorage 里到底有没有你要的数据,比在代码里瞎猜高效得多。
5.4 警惕多实例 Pinia
如果项目里不止一个createPinia()调用,或者代码里在多个地方手动实例化 pinia,store 的“单例效果”会被打破,同一个useUserStore()可能在不同地方拿到不同实例,最直接的影响是 A 组件改了状态,B 组件看不到。解决办法是保证全应用只有一个createPinia(),并且通过app.use(pinia)注册。这个 bug 排查起来特别折磨人,因为代码没有任何报错,只在运行时表现异常。
5.5 关于测试:Store 的单测思路
给 store 写单测不需要启动浏览器。思路是创建一个全新的 pinia 实例,再通过setActivePinia激活它,然后调用 store 方法:
import { createPinia, setActivePinia } from 'pinia' import { useUserStore } from '@/stores/modules/user' beforeEach(() => { setActivePinia(createPinia()) }) it('login should set token', async () => { vi.mock('@/api/auth', () => ({ loginApi: vi.fn().mockResolvedValue({ token: 'mock-token' }), })) const store = useUserStore() await store.login({ username: 'admin', password: '123456' }) expect(store.token).toBe('mock-token') })关键点是setActivePinia保证每个测试用例之间 store 状态互相隔离,避免环境污染。
6. 一些个人建议
如果你正在从 Vuex 迁移到 Pinia,或者刚接触 Vue3 状态管理,我最想强调的一点是:先把 option store 写熟,再深入 setup store。option store 的结构有迹可循,团队协作、代码 review 时更不容易失控;等你对响应式原理融会贯通后,setup store 的组合式写法会成为你的利器。Vue3 的状态管理不是越复杂越好,而是让数据流和组件逻辑的边界足够清晰。
另外,配置完持久化和 HMR 后,记得建一个“状态变更自查清单”:登录后该持久化的字段有没有持久化、退出登录后所有 store 是否都 reset、刷新后路由是否还保持原样。这些自动化难以覆盖的场景,靠一套明确的手工核对流程,往往事半功倍。Pinia 的架构能力很强,但真正决定项目长期健壮的,还是团队如何使用它的习惯。