开篇先抛一个问题:如果你是从 Vue2 时代走过来的前端,今年被调去维护一个 Vue3 + TypeScript 的中后台项目,打开package.json发现状态管理库不是 Vuex 而是 Pinia,第一反应是不是“又要学新东西”?实际上,Pinia 不能只当成 Vuex 的替代品来学,它是 Vue3 组合式 API 时代下重新设计的状态方案,API 更简洁、类型推导更自然、对 devtools 的支持也更好。这篇文章我会从安装配置、核心 API、到登录状态与主题切换两个完整实战案例,把 Pinia 真正讲透,让你看完就能直接在自己项目里落地。
写这篇内容之前,我看了一下最近的搜索热词,很多人在问 Vue3 与 Pinia 相关的面试题、源码解析、若依框架的 TS 报错、以及动态表单这类具体场景。这些我都会在文章中穿插着讲,特别是 Pinia 与 Vuex 的取舍、setup store 与 options store 的选择,以及 persist 持久化这些高频问题,全部用实际代码说事。
1. 先聊聊为什么是 Pinia,而不是继续用 Vuex
1.1 Vuex 时代的痛点
用过 Vuex 的都知道,写一个模块需要state、mutations、actions、getters四件套,还要用mapState、mapMutations这类辅助函数来绑定。如果是中大型项目,模块文件一多,这些样板代码会占掉大量篇幅。更麻烦的是 TypeScript 支持:Vuex 4 在 TS 环境下的类型推导需要写一堆复杂的类型体操,比如Module<S, R>、createStore的泛型参数,稍微复杂一点就推断不准确,经常会出现你在state里明明定义了字段,组件里this.$store.state.xxx却还是any的情况。
还有一个我一直不太满意的点:Vuex 的 mutations 强制要求同步,任何异步操作都必须在 actions 里包一层,再 commit 到 mutations。这个设计本意是让状态变化可追踪,但实际开发里很多异步数据拉回来就是一个赋值操作,因为同步限制我不得不多写十几行 boilerplate。久而久之,团队里就会形成“反正都写到 actions 里算了”的偷懒习惯,mutations 就形同虚设,可追踪性反而变差了。
1.2 Pinia 的设计思路有什么不同
Pinia 的核心设计是“去掉 mutations,统一用 actions”。也就是说,你可以在 action 里直接做同步或异步操作,然后直接修改状态,不再需要 commit。刚开始用的时候你可能会有点不习惯,觉得“这不是违反单向数据流了吗”,但实际用下来你会发现,配合 devtools 的时间旅行,状态变化依然被完整记录下来,开发体验反而更顺。
另一个核心变化是模块化方式的简化。Vuex 用modules: {}把一个 root store 切成多个子模块,而 Pinia 直接建议你一个文件就是一个 store,通过defineStore('storeName', options)来创建。组件里用useStoreStore()拿实例,完全不像 Vuex 那样需要记住模块的命名空间路径。这个变化的好处是结构更清晰,拆文件、测试都更方便,一个 store 文件就是一个独立的逻辑域。
再来说类型推导。Pinia 整个源码是用 TypeScript 写的,它对 store、state、getters、actions 的类型推断非常自然。比如你定义了state: () => ({ count: 0 }),在组件里访问store.count,编辑器会自动补全并提示它是number类型,完全不需要手动标注泛型。如果你是用 Vue3 + TS 做中后台项目,这一点会显著减少焦虑。
1.3 什么时候可以不用 Pinia
写这篇文字前我也在想,是不是所有项目都该上状态管理?实际上,如果项目只是一个简单的展示页,状态大多停留在组件内部,用ref或reactive加provide/inject就够了,引入 Pinia 反而是过度设计。但要是有以下需求,Pinia 的价值就会体现出来:
- 多个页面/组件共享同一份数据,比如登录用户信息、购物车清单、主题配置;
- 状态需要被持久化,比如刷新后还保持登录状态或主题偏好;
- 需要跨组件触发操作,比如“登录成功后跳转并更新用户菜单”;
- 需要调试状态变化、做异常追踪,Pinia 的 devtools 支持很完整。
2. 环境准备与基础配置:三步把 Pinia 装进项目
2.1 安装依赖与初始化
在已有的 Vue3 项目里安装 Pinia,只需要一步:
npm install pinia # 或者 yarn add pinia然后在main.ts(或main.js)里注册,代码非常短:
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')有人问过:“为什么 Pinia 不像 Vuex 那样在 createApp 的配置里传?”可能是因为它把插件注册的方式统一成了app.use(),这样它和 vue-router、vue-i18n 这些生态库的用法就保持了一致,心智负担更小。
这里有一个小细节:如果你用的是 Vite 工程,按照官方脚手架创建的模板,只需在src/main.ts里加上面这几行即可。但如果你项目里用了很多依赖beforeEach路由守卫的插件,比如一些权限控制类库,要注意 Pinia 必须在路由代码之前注册,否则在路由守卫里调用useStore()会报getActivePinia()的错误。
2.2 创建第一个 store 文件
我用一个计数器的例子来展示最基础的 store 写法。在src/stores下新建counter.ts:
// stores/counter.ts import { defineStore } from 'pinia' export const useCounterStore = defineStore('counter', { state: () => ({ count: 0 }), getters: { double: (state) => state.count * 2 }, actions: { increment() { this.count++ } } })然后在组件里使用:
<script setup lang="ts"> import { useCounterStore } from '@/stores/counter' const store = useCounterStore() </script> <template> <div> <p>count: {{ store.count }}</p> <p>double: {{ store.double }}</p> <button @click="store.increment()">+1</button> </div> </template>这里可以看到,state、getters、actions 在组件里都直接挂到 store 实例上,不需要任何mapState。而且store.double虽然是 getter,但在模板里并不需要加括号,它会自动解包。对刚接触的人来说,这套 API 比 Vuex 的$store.state/$store.getters要直观太多。
2.3 使用前的几个小细节
- 文件命名:建议 store 文件统一放在
src/stores目录下,按业务域划分,比如user.ts、theme.ts、cart.ts,例外情况下可以放入modules/子目录。 - useStore 的命名:
useXxxStore是一个约定俗成的命名方式,用 hooks 的形式,和 Cursor 的use设计一致,能和 Vue3 的 hook 体系无缝衔接。 - store 实例是 reactive 的:store 本身是一个被
reactive包裹的对象,所以可以直接在模板中使用。但解构时需要小心,如果直接const { count } = store,count 会失去响应性。要解构时应该用storeToRefs()。
另外强烈建议安装 Pinia 官方的 devtools 插件(如果是 Vue Devtools 插件内置的组件,那也行)。调试时能在 Timeline 里看到每次 state 变化的记录,甚至可以直接拖拽时间线回滚状态,排查问题效率提升不是一点半点。
3. 核心概念拆解:State、Getters、Actions 的使用细节
3.1 State:状态管理的底座
State 本质上就是 store 里的响应式数据源。与 Vuex 不同,Pinia 的 state 在创建时是函数形式,这有利于服务端渲染时避免状态共享冲突。直接修改 state 在 Pinia 里是允许的,比如store.count++,或者批量更新时用$patch:
store.$patch({ count: store.count + 1, name: 'newName' })$patch会在一次更新中合并多个字段,devtools 会把这次 patch 记录为一条日志,这在状态更新频繁的场景下非常有用。如果更新逻辑复杂,也可以传一个函数:
store.$patch((state) => { state.items.push({ id: 1, name: 'vue' }) state.total += 1 })3.2 Getters:不要重复计算
Getters 是带缓存的“计算属性”,和 Vue 的 computed 类似。它接收 state 作为参数,还可以通过this访问当前 store 的其他 getter:
export const useCounterStore = defineStore('counter', { state: () => ({ count: 0, base: 10 }), getters: { double: (state) => state.count * 2, triple: (state) => state.count * 3, total: (state) => state.base + state.count, sum: (state): number => { // 通过 this 访问当前 store 的 state return state.base + this.double } } })需要注意,如果用this访问 getter,需要标注返回类型,否则 TS 会推断失败(在 TS 下报的错很常见,后面会单独说)。大多数情况下,定义 getter 时直接依赖 state 参数会更安全。
3.3 Actions:异步逻辑的唯一入口
Actions 可以包含任意异步逻辑,也可以调用其他 actions。比如一个典型的用户加载流程:
export const useUserStore = defineStore('user', { state: () => ({ userInfo: null as null | UserInfo, token: '' }), actions: { async login(payload: LoginParams) { const res = await api.login(payload) this.token = res.token this.userInfo = res.userInfo return res }, async fetchUserInfo() { const res = await api.getUserInfo() this.userInfo = res return res }, logout() { this.token = '' this.userInfo = null } } })这里的关键点是:Pinia 的 actions 直接修改 state,不需要 commit。对于团队里从 Vuex 转过来的同学,第一次看到可能有点慌,但你就把它当成一个“方法”,内部可以同步可以异步,这就是全部规则。
3.4 用 setup store 做更灵活的封装
Pinia 还提供了一种基于组合式 API 的写法,叫 setup store,风格和 Vue3 的<script setup>非常搭:
import { ref, computed } from 'vue' import { defineStore } from 'pinia' export const useCounterStore = defineStore('counter', () => { const count = ref(0) const double = computed(() => count.value * 2) function increment() { count.value++ } return { count, double, increment } })这个写法比较适合需要复用组合式函数,或者在 store 内部做更复杂逻辑的场景。类比的例子:你从“对象配置”切换成“函数调用”的方式,就像用 options API 和 composition API 的区别。两种写法可以混用,官方也允许,但我个人建议一个项目里只选一种,避免混乱。
4. 实战一:登录状态管理与用户信息全局共享
4.1 需求分析与设计思路
中后台项目几乎都有一个刚性需求:登录页提交表单,成功后保存用户信息,顶部导航栏显示头像和昵称;刷新页面后,登录状态还需要保持;如果 token 失效,要被路由守卫拦回登录页。
如果不用状态管理,最粗暴的做法是把用户信息存 localStorage,刷新后再从 localStorage 里读出来,但这样组件之间无法响应式地感知登录状态变化,比如某个菜单要根据角色动态控制显隐,就很不方便。所以用 Pinia 来管理用户状态,同时搭配 localStorage 做持久化,是标准做法。
4.2 完整代码实现
先定义一个UserInfo类型。假设后端返回的数据如下:
// types/user.ts export interface UserInfo { id: number username: string nickname: string avatar: string roles: string[] }然后创建src/stores/user.ts:
import { defineStore } from 'pinia' import type { UserInfo } from '@/types/user' import { loginApi, getUserInfoApi } from '@/api/user' interface LoginParams { username: string password: string } export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('token') ?? '', userInfo: null as UserInfo | null, loginLoading: false }), getters: { isLoggedIn: (state) => !!state.token, displayName: (state) => state.userInfo?.nickname || state.userInfo?.username || '未登录', avatarText: (state) => { const name = state.userInfo?.nickname || state.userInfo?.username || '' return name ? name.charAt(0).toUpperCase() : '?' }, roleList: (state) => state.userInfo?.roles ?? [] }, actions: { async login(params: LoginParams) { this.loginLoading = true try { const res = await loginApi(params) this.token = res.token localStorage.setItem('token', this.token) await this.fetchUserInfo() return res } catch (e) { throw e } finally { this.loginLoading = false } }, async fetchUserInfo() { if (!this.token) return null this.userInfo = await getUserInfoApi() return this.userInfo }, logout() { this.token = '' this.userInfo = null localStorage.removeItem('token') localStorage.removeItem('userInfo') } } })这里有几个设计上的经验:
- token 同步到 localStorage:刷新时初始化 state 会从 localStorage 读 token,保证登录状态不丢失。
- loginLoading 放在 store 里:登录按钮的 loading 状态如果只放在组件里,当登录逻辑被多个地方复用(比如弹窗登录、页面登录)时就会重复维护。放在 store 里,所有用到它的组件都能响应。
- fetchUserInfo 单独抽出来:有些项目在路由守卫里只需要校验 token,不需要拉用户信息;但有些页面又必须依赖用户信息才能渲染,所以把拉取用户信息独立成 action,按需调用,更灵活。
4.3 与路由守卫配合使用
在src/router/index.ts里搭配 Pinia 使用,一个基础版本长这样:
import { createRouter, createWebHistory } from 'vue-router' import { useUserStore } from '@/stores/user' const router = createRouter({...}) router.beforeEach((to, from, next) => { const userStore = useUserStore() if (to.meta.requiresAuth && !userStore.isLoggedIn) { next({ name: 'login', query: { redirect: to.fullPath } }) } else { next() } })注意:在路由守卫里使用 useUserStore 时,要确保 Pinia 已经通过app.use(pinia)注册,否则会报getActivePinia was called with no active Pinia。常见写法是把createPinia和createRouter的调用顺序安排好:先创建 pinia,再创建 router,最后 app.use。如果遇到这个报错,检查一下main.ts的初始化顺序准没错。
经常有人问:“为什么我在路由守卫里调useUserStore()报错,但在组件里不报错?”原因就是 Pinia 实例还没被安装在 app 上就调用它了。一个简单的修复是,在main.ts里用顶层 await 或显式创建,保证pinia在router.beforeEach执行前已注册。
4.4 持久化方案的演变
上面代码里手动localStorage.setItem是一种简单方案。个人项目或者快速 Demo 够用,但稍微复杂一点,比如你需要同时存token、userInfo、theme、tabs等多个数据,手动管理容易漏掉。
社区里有一个很常见的库叫pinia-plugin-persistedstate,用起来像这样:
import { createPinia } from 'pinia' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' const pinia = createPinia() pinia.use(piniaPluginPersistedstate)然后在 store 里加一个选项:
export const useUserStore = defineStore('user', { state: () => ({...}), persist: { key: 'user-store', storage: localStorage, paths: ['token', 'userInfo'] } })这里paths可以指定白名单,只持久化 token 和 userInfo,其他临时状态(比如 loginLoading)不持久化,避免把不必要的 UI 状态写入 localStorage。如果你是正式项目,我建议优先用这个库,而不是手动写一堆setItem。
5. 实战二:主题切换方案(从静态 CSS 到响应式变量)
5.1 方案选型:CSS 变量 + Pinia
主题切换是一个典型的“状态影响全局样式”的场景。实现方式有几种:一个是切换<html>上的class,另一个是切换 CSS 变量值。CSS 变量的方式最干净,不用写大量覆盖样式,而且“亮色 / 暗色”模式下,只要修改变量值,组件里使用var(--bg-color)的地方全都会自动更新。
所以 Pinia 在这件事上负责的是“存储当前主题,并提供切换 action”,样式层面只依赖变量本身。这样做还有一个好处:将来如果要做“跟随系统”的自动主题(prefers-color-scheme),逻辑只要放在 store 或一个 composable 里,改动量很小。
5.2 实现代码
先定义主题数据结构:
// stores/theme.ts type ThemeMode = 'light' | 'dark' interface ThemeState { mode: ThemeMode isAuto: boolean } export const useThemeStore = defineStore('theme', { state: (): ThemeState => ({ mode: (localStorage.getItem('theme-mode') as ThemeMode) || 'light', isAuto: localStorage.getItem('theme-auto') === 'true' }), actions: { toggleMode() { this.mode = this.mode === 'light' ? 'dark' : 'light' this.applyTheme() }, setMode(mode: ThemeMode) { this.mode = mode this.applyTheme() }, applyTheme() { const root = document.documentElement root.style.setProperty('--bg-color', this.mode === 'light' ? '#f5f5f5' : '#0d1117') root.style.setProperty('--text-color', this.mode === 'light' ? '#1f2328' : '#e6edf3') root.style.setProperty('--border-color', this.mode === 'light' ? '#d0d7de' : '#30363d') localStorage.setItem('theme-mode', this.mode) } } })然后需要在项目入口处初始化,通常是在App.vue的onMounted或setup里:
<script setup lang="ts"> import { onMounted } from 'vue' import { useThemeStore } from '@/stores/theme' const themeStore = useThemeStore() onMounted(() => { themeStore.applyTheme() }) </script>启动时会自动读取 localStorage 中的主题模式,然后应用 CSS 变量。页面中的任意一个<div>都可以使用background: var(--bg-color),切换主题时所有依赖这些变量的组件都会自动更新,不需要任何额外操作。
5.3 把主题切换图标做成组件
顶部导航栏的“切换主题”按钮,常见的是一个日/月图标。这里我展示组件怎么写,同时注意如果主题状态变化了,button 的 title 也要跟着变:
<script setup lang="ts"> import { useThemeStore } from '@/stores/theme' const themeStore = useThemeStore() </script> <template> <button class="theme-toggle" :aria-label="themeStore.mode === 'light' ? '切换到深色模式' : '切换到浅色模式'" @click="themeStore.toggleMode()" > <span v-if="themeStore.mode === 'light'">🌙</span> <span v-else>☀️</span> </button> </template>注意,themeStore.mode在模板中是响应式的,所以点击后图标会立即变化。实战中,这个组件可能被放在多个地方,比如侧边栏底部、顶部导航甚至登录页,但所有实例都共享同一个 store,所以状态无论在哪里切换,所有组件都会同步更新,这是 Pinia 跨组件通信最直观的价值。
5.4 主题状态和持久化的联动
如果你使用了pinia-plugin-persistedstate,上面手动localStorage.setItem的部分可以省掉,直接加上persist: true就行。但要注意,主题和用户信息不同,如果用户切换了系统自动模式,isAuto也需要持久化。官方插件的paths配置依然有效,比如我只想持久化mode,不持久化isAuto,可以这么写:
persist: { key: 'theme-store', storage: localStorage, paths: ['mode'] }这种方式更符合中后台项目的实际需求,避免把不必要的状态塞进存储。
6. 常见问题与排查技巧实录
6.1 新手最容易踩的坑
这里整理一份我平时答疑时高频出现的问题清单,照着排查能省大量时间。
问题 1:getActivePinia was called with no active Pinia.
- 原因:在 Pinia 安装之前就调用了
useStore(),最常见于router.beforeEach守卫。 - 解决:调整初始化顺序,先
createPinia(),再app.use(pinia),最后安装 router。如果是测试环境,还需要手动setActivePinia(createPinia())。
问题 2:storeToRefs与store解构混乱
- 原因:直接
const { count, name } = store后,变量变成了普通值,不再响应式。 - 解决:需要解构响应式属性时用
storeToRefs(store),需要解构方法时直接用store对象,例如const { count } = storeToRefs(store),而store.login()不用解构。
问题 3:TS2339: Property 'xxx' does not exist on type
- 原因:getter 或 action 的类型推导失败,尤其是 getter 里使用
this时,TS 无法确定返回类型。 - 解决:在 getter 上加显式返回类型,比如
total: (state): number => state.base + this.double。这个在 TS 项目里非常常见,遇到就加类型。
问题 4:持久化后的状态不生效,刷新后 state 被重置
- 原因:多半是没引入持久化插件,或者持久化配置写错了 storage 参数,比如
sessionStorage和localStorage混淆。 - 解决:检查是否在
main.ts里pinia.use(piniaPluginPersistedstate),并确认persist.key没有与其他 store 冲突。
问题 5:在<script setup>里直接调用 action,页面不更新
- 原因:没意识到 store 本身的响应性。组件里一般用
storeToRefs解构状态,但如果解构后没有用store.xxx而是直接store.xxx(),那没问题;也有可能是你错误地把 state 当成普通 ref 用了。 - 解决:模板中直接用
store.count,如果是<script>里使用,用const { count } = storeToRefs(store),然后操作时用store.increment()。
6.2 从热搜问题看实际应用场景
搜一下网络上的 Vue3 话题,出现频率很高的问题有“vue3 登录不跳转”、“vue3 动态添加删除 form 表单一行数据”、“vue3 sortable 未生效”。虽然不全是 Pinia 的责任,但这几个问题有一个共性:它们都绕不开响应式状态。
比如登录不跳转,常见的排查思路是:先看 Pinia 里的isLoggedIn是否更新;然后看路由守卫是否把“已登录但访问登录页”的用户重定向回首页;如果都正常,再看router.push是否被拦截。如果你把 token 状态放到 Pinia 管理,那调试链路就清爽很多。
动态表单那一类问题,如果你需要跨组件共享“当前正在编辑的行数据”或“表单状态”,同样可以用 Pinia 管理一个formStore,把校验规则和行数据统一放进去。虽然不如组件内部ref那么直接,但在“多个弹窗同时编辑同一份 data”的场景下,Pinia 是更可靠的方案。
6.3 我自己的调试技巧
推荐一个我常用的排查方式:在 devtools 的 Vue tab 里,选中“Pinia”这个面板,能看到当前 store 的完整 state、getters 和 actions,甚至可以直接修改 state 值来测试 UI 响应。这在排查“点击按钮后为什么页面没变化”这类问题时特别管用,你直接在面板改 state,如果 UI 跟着变,说明 store 本身没问题,问题出在组件事件;如果 UI 不变,那八成是你在模板里没绑定对。
另外一个经验:别把所有东西都塞到一个 store 里。见过有些项目一个useAppStore里既有用户信息、又有主题色、还有侧边栏折叠状态、甚至还有面包屑配置,store 文件上千行。这样确实少写几个文件,但后续维护会让你怀疑人生。标准做法是一个业务域一个 store,比如 user、theme、tabs、appConfig 分开,通过组合式函数或普通函数再聚合,结构清楚,测试也容易。
写在最后
花了一下午把最近项目里从 Vuex 切到 Pinia 的踩坑整理了一遍,包括登录状态、主题切换、持久化、路由守卫联动这些真实场景。从我的体验来说,Pinia 的学习成本比 Vuex 低一截,但它对应的思维转变是“直接改状态 + 组合式设计”,这对老 Vuex 用户是个需要适应的点。建议你用一个小项目或中后台的某个页面模块先试点,不要一上来就把全部状态迁移过去。遇到问题优先看 devtools,再看main.ts的初始化顺序,这两个方向能解决大部分入门期的意外状况。