
做后台管理系统这几年我先后经历了 Vue2 Vuex 到 Vue3 Pinia 的切换说实话刚开始是有点抗拒的毕竟 Vuex 用了那么久突然来个新东西总觉得又要重新学一遍。但真正把 Pinia 用进项目之后最大的感受就是早该换了。代码量直接砍掉三分之一TypeScript 支持好到不像官方出的工具而且那个 DevTools 的调试体验简直丝滑。如果你正在做 Vue3 项目或者刚看完 Vue3 基础想进阶又或者被 Vuex 的 mutations、modules 各种嵌套搞到头疼这篇内容就是为你准备的。我会从零开始把 Pinia 的安装、核心概念、登录态和主题切换两个实战场景完整过一遍全程用真实项目里能直接跑的代码说话。1. 为什么是 PiniaVuex 的时代真的过去了先聊点实际的。我见过不少团队从 Vue2 升到 Vue3 之后还在硬扛 Vuex 4然后就撞上一堆体验问题TypeScript 推导困难、模块嵌套过深导致 getters 访问混乱、mutation 里写异步逻辑还要绕一圈 action、组件里 mapState 写一大串字符串数组容易拼错。当然这些问题 Vuex 5 时代的 Pinia 大部分都解决了而且解决得非常彻底。1.1 Vuex 和 Pinia 的核心差异Vuex 和 Pinia 解决的是同一个问题多个组件之间共享状态。购物车数量、用户登录信息、全局主题色这些数据散落在各个组件里就会有“状态不同步”的难题你需要把它们抽出来放到一个“全局仓库”里统一管理。但两者的设计哲学有明显区别。Vuex 是强约束派它强制你走state - mutation - action这条单向数据流同步操作必须 commit mutation异步操作必须 dispatch action。这个设计在团队协作时确实能起到约束作用但也带来了大量样板代码。尤其是当你只需要改一个loading状态时还得写一个 mutation 函数、在 mapMutations 里注册那种繁琐感是做业务的程序员最能切身感受到的。Pinia 换了个思路既然 Vue3 的 Composition API 已经能很好地组织逻辑为什么状态管理还要搞这么重于是 Pinia 直接删掉了 mutations把 action 同时承担同步和异步两种职责state 就是一个普通的响应式对象你可以直接store.xxx 新值修改。听起来好像“不安全”但配合 Vue3 响应式系统的自动追踪实际用下来并没有出现失控的场面反而代码肉眼可见地变短了。1.2 Pinia 在设计上解决了哪些实际问题我从实际项目视角列一下 Pinia 带来的关键改进第一TypeScript 支持是质的飞跃。Vuex 4 在 TS 下的类型推导一直是半吊子经常要手动声明 Module 类型。Pinia 整个就是围绕 TS 设计的store 定义一次组件里store.xxx自动就有类型提示不再需要任何额外类型体操。第二模块化变得自然。Vuex 的 modules 是嵌套结构而且还要处理 namespaced 这种命名空间概念。Pinia 是天然的扁平 store 体系每个 store 独立定义、独立引用store 之间也可以互相调用不需要注册到根实例上也没有那一层 module 对象的包裹。第三storeToRefs 和 composables 风格的完美融合。在组件里解构 Pinia store 的 state 时可以通过storeToRefs保持响应性这个 API 用起来顺手到飞起我再也不用写 computed 包一层了。第四开发体验上的细节。Pinia 有官方 DevTools 插件支持时间旅行调试、查看每个 store 的状态变更历史体验比 Vuex 插件时代流畅得多。而且 Vuex 4 官方文档都直接建议新项目用 Pinia这不叫“替代”叫什么。不要纠结 Vuex 会不会马上消失。事实上 Vuex 4 已经进入维护模式不会再增加新功能。从长远看用 Pinia 就是顺势而为没有任何历史包袱。2. 环境准备与安装配置零基础上手手册确定要用 Pinia 之后我建议直接在全新的 Vue3 项目中集成它避免各种环境冲突。如果你用的是 Vite 脚手架整个过程大概五分钟就能搞定。2.1 创建 Vue3 项目和安装 Pinia我用的是 npm 的方式先创建一个标准的 Vue3 项目npm create vitelatest pinia-demo -- --template vue-ts cd pinia-demo npm install npm install pinia这里我选择了vue-ts模板因为 Pinia 在 TS 项目里的优势实在太明显了。如果你暂时不想用 TypeScript选vue模板也一样能跑只是类型提示方面的体验会打折扣。安装完成后打开src/main.ts如果用 JS 就是main.js把 Pinia 实例挂到 Vue 应用上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()创建的是 Pinia 实例app.use(pinia)注册后所有组件里才可以通过useXxxStore()的方式访问 store。如果没有这一行组件里 import store 会直接报错提示没有活跃的 pinia 实例。我最初踩的第一个坑就是忘了挂载导致所有调用 store 的地方全红屏。2.2 设计项目的 store 目录结构很多刚接触 Pinia 的人会问目录结构怎么组织我的建议是不要照搬 Vuex 时代那种一个 modules 文件夹塞一堆 JS 的做法而是按领域或业务功能拆分一个文件就是一个 store。一个常规项目里我一般这样组织src/ ├── stores/ │ ├── index.ts // 统一导出所有 store方便引用 │ ├── user.ts // 登录状态、用户信息 │ ├── theme.ts // 主题切换 │ ├── cart.ts // 购物车 │ └── app.ts // 全局 UI 状态侧边栏折叠、加载态这样设计的核心好处是模块之间的边界清晰改动一个 store 不影响其他模块也方便在组件里按需引用。你可能会问“如果我有很多 store 文件会不会导致引用路径很长”其实不会因为每个 store 基本是自包含的组件里只需要import { useUserStore } from /stores/user一条引用就够了代码可读性反而提升了。我个人还习惯在stores/index.ts里做一个统一的二次导出export * from ./user export * from ./theme这样其他模块引用时可以直接从/stores导入书写更简洁。当然这个小设计看团队偏好不是 Pinia 的强制要求。3. 核心 API 与入门用法从零理解 Pinia 的设计Pinia 的核心概念其实就三个state、getters、actions。相比 Vuex 少了 mutations但增加了一个叫“setup store”的写法。我建议先理解传统的 options store选项式再对比 setup store这样能更清楚地看到 Pinia 的组件化思路。3.1 Options Store 写法和三个核心概念先看我最常用的一段 user store 代码里面覆盖了 state、getters、actions 的典型场景import { defineStore } from pinia export const useUserStore defineStore(user, { state: () ({ token: , userInfo: null as { name: string; avatar: string } | null, roles: [] as string[], }), getters: { isLoggedIn: (state) !!state.token, displayName: (state) state.userInfo?.name ?? 未登录用户, }, actions: { login(payload: { username: string; token: string; roles: string[] }) { this.token payload.token this.userInfo { name: payload.username, avatar: } this.roles payload.roles }, logout() { this.token this.userInfo null this.roles [] }, }, })这里我解释一下每个部分的作用state是仓库的数据源必须用箭头函数返回对象这样做是为了服务端渲染时避免多个请求共享同一个状态实例。它和 Vue 组件里的data()写法保持一致。getters相当于计算属性它依赖 state 自动缓存。注意 getters 里我用了箭头函数这样拿到的state参数就是强类型的如果某个 getter 要依赖其他 getter就不能用箭头函数要写成普通函数并通过this访问这点和 Vuex 的 getters 处理方式很相似。actions是核心中的核心它可以直接通过this.xxx xxx修改 state。有人会问“没有 mutations多人协作安全吗”我之前也有同样的怀疑后来发现只要团队规定“所有修改都走 actions组件里不直接改 state”基本不会出乱子。而且 actions 天然支持同步和异步你直接在 login 里发请求也完全没问题。3.2 Setup Store 写法更贴近 Composition API如果你习惯了 Vue3 的 Composition APIPinia 还提供了一种 setup store 的写法它的形式和组件里的setup()函数几乎一样用 ref 定义 state、computed 定义 getters、普通函数定义 actions。我给大家看同一个 user store 用 setup 写法实现import { ref, computed } from vue import { defineStore } from pinia export const useUserStore defineStore(user, () { const token ref() const userInfo ref{ name: string; avatar: string } | null(null) const roles refstring[]([]) const isLoggedIn computed(() !!token.value) const displayName computed(() userInfo.value?.name ?? 未登录用户) function login(payload: { username: string; token: string; roles: string[] }) { token.value payload.token userInfo.value { name: payload.username, avatar: } roles.value payload.roles } function logout() { token.value userInfo.value null roles.value [] } return { token, userInfo, roles, isLoggedIn, displayName, login, logout } })setup store 和 options store 各有利弊。setup store 的优点是可以自由组合 composables、逻辑复用更灵活缺点是要求大家熟悉 Vue3 的响应式 APIoptions store 更经典、更好理解适合初学者和习惯 Vuex 语法的老手。我个人的习惯是简单场景用 options涉及大量工具函数复用、或者需要根据接口动态拼接 store 内容场景时用 setup。两个都能打根据团队的技术底色做选择就好。3.3 组件里如何优雅地使用 storestore 定义好了接下来就是组件里最常用的三件套引用 store、解构 state、调用 actions。我先给一个基础示例script setup langts import { storeToRefs } from pinia import { useUserStore } from /stores/user const userStore useUserStore() const { token, userInfo, roles } storeToRefs(userStore) const { isLoggedIn, displayName } storeToRefs(userStore) const { login, logout } userStore /script template div p当前用户{{ displayName }}/p p登录状态{{ isLoggedIn ? 已登录 : 未登录 }}/p button clicklogin({ username: admin, token: fake-token, roles: [admin] }) 模拟登录 /button button clicklogout()退出登录/button /div /template这里有个非常关键的点也是 Pinia 新手最容易踩的坑直接从 store 解构出来的 state 会丢失响应性。因为 Pinia 内部用 reactive 包裹状态你结构出来的值只是一个快照后续 state 变化不会再驱动视图更新。解决办法就是使用storeToRefs包一层这样解构出来的每个属性仍然是独立的响应式 ref。至于 actions因为它是普通函数解构后直接调用不会影响this绑定所以不需要 storeToRefs 处理。你可以直接把login、logout从 store 实例上解构出来用。记住一个口诀state 和 getters 用 storeToRefs 解构actions 直接解构。这是我写 Pinia 两个月后总结出的第一准则没有之一。4. 实战项目一全局登录状态管理理论概念说完我们来点实战。这一节的内容都是我在真实后台管理系统里用过的方案包括模拟登录流程、token 持久化和基于角色的权限校验。虽然我们用本地 mock 数据演示但换到真实接口时逻辑几乎一模一样。4.1 完整登录状态 Store 实现含 token 持久化先补齐一个更接近生产环境的 user store。除了 state、getters、actions我还会加入 localStorage 持久化逻辑import { defineStore } from pinia const TOKEN_KEY app_token export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(TOKEN_KEY) ?? , userInfo: null as null | { name: string; avatar: string; roles: string[] }, }), getters: { isLoggedIn: (state) !!state.token, userRoles: (state) state.userInfo?.roles ?? [], }, actions: { async login(username: string, password: string) { // 实际项目中这里是 axios 调用后端接口 const mockToken token-${Date.now()} const mockUser { name: username, avatar: , roles: username admin ? [admin, editor] : [editor], } this.token mockToken this.userInfo mockUser localStorage.setItem(TOKEN_KEY, mockToken) return mockUser }, logout() { this.token this.userInfo null localStorage.removeItem(TOKEN_KEY) }, }, })我特意在 state 初始化时就用localStorage.getItem读取 token这样页面刷新后只要 localStorage 里有 tokenstore 的初始登录态就直接是“已登录”。这是很多新手容易漏掉的细节——只写 save忘了初始化时 read一刷新就回到未登录状态。关于持久化的方案这里用的是最基本的原生 localStorage。如果你项目已经引用了pinia-plugin-persistedstate这类插件也可以直接给 store 加persist: true配置插件会自动完成序列化和恢复。两者效果类似但少写不少代码。我用原生 localStorage 是为了让大家看清底层原理实际项目里用插件更方便。4.2 登录页、路由守卫和权限控制联动有了 user store登录页就变得异常简洁。核心逻辑就是调用 login action、成功后跳转首页、失败弹错误提示。我用一个简单的登录组件来演示script setup langts import { ref } from vue import { useRouter } from vue-router import { useUserStore } from /stores/user const router useRouter() const userStore useUserStore() const username ref() const password ref() const loading ref(false) const errorMsg ref() async function handleLogin() { if (!username.value || !password.value) { errorMsg.value 请输入用户名和密码 return } loading.value true errorMsg.value try { await userStore.login(username.value, password.value) router.push(/) } catch (e) { errorMsg.value 登录失败请检查账号密码 } finally { loading.value false } } /script template form submit.preventhandleLogin input v-modelusername placeholder用户名 / input v-modelpassword typepassword placeholder密码 / p v-iferrorMsg classerror{{ errorMsg }}/p button :disabledloading typesubmit {{ loading ? 登录中... : 登录 }} /button /form /template到这里都是常规操作但实战项目里往往还需要路由守卫来配合判断访问权限。我在router/index.ts里一般这样写import { createRouter, createWebHistory } from vue-router import { useUserStore } from /stores/user const router createRouter({ history: createWebHistory(), routes: [ { path: /login, component: () import(/views/Login.vue) }, { path: /, component: () import(/views/Home.vue), meta: { requiresAuth: true } }, { path: /admin, component: () import(/views/Admin.vue), meta: { requiresAuth: true, roles: [admin] } }, ], }) router.beforeEach((to) { const userStore useUserStore() if (to.meta.requiresAuth !userStore.isLoggedIn) { return { path: /login, query: { redirect: to.fullPath } } } if (to.meta.roles !to.meta.roles.some((role: string) userStore.userRoles.includes(role))) { return { path: /, replace: true } } return true })这里有个小提示在路由守卫里调用useUserStore()时不需要担心 pinia 实例未初始化的问题因为 Pinia 会在创建 app 时默认把实例注入到全局只要你在main.ts里执行了app.use(pinia)路由守卫里的调用就完全没问题。我在早期版本踩过这个坑当时是在 store 文件里直接调useUserStore()但没初始化 pinia导致报错后来改成守卫内调用才解决。4.3 登录状态在多个组件间的同步展示登录状态最大的痛点就是导航栏要显示头像和用户名个人中心要显示详细信息某些操作按钮要根据角色显隐。如果不用 Pinia每个组件各自维护一份登录状态登录成功后你得通知所有组件更新非常烦人。用了统一 store 后这些问题都不是问题!-- 导航栏组件 -- script setup langts import { storeToRefs } from pinia import { useUserStore } from /stores/user const userStore useUserStore() const { displayName, isLoggedIn, userRoles } storeToRefs(userStore) /script template nav template v-ifisLoggedIn span{{ displayName }}/span button v-ifuserRoles.includes(admin)管理后台/button button clickuserStore.logout()退出/button /template template v-else router-link to/login去登录/router-link /template /nav /template当 store 的isLoggedIn从 false 变成 true 时导航栏会立刻从“去登录 ”切到“用户名 退出登录”完全不需要任何事件总线或者父子组件通信。这就是“全局单一数据源”的最大价值——所有组件读同一个 store数据永远是同步的。5. 实战项目二主题切换的全局设置第二个实战场景是主题切换这也是后台管理系统里非常常见的一个需求。你肯定见过一些成熟框架里的“暗色模式”按钮一键把整个应用从亮色切换成暗色。这个功能用 Pinia 做起来其实非常干净核心思路是用 store 记住主题状态通过动态绑定 CSS 变量做到全局换肤再配合 localStorage 让选择持久化。5.1 主题 Store 实现与本地持久化我把主题相关的逻辑独立成一个theme.ts的 store。由于它只用两个状态不涉及复杂 getter我直接用 options 写法import { defineStore } from pinia type ThemeMode light | dark const THEME_KEY app_theme export const useThemeStore defineStore(theme, { state: () ({ mode: (localStorage.getItem(THEME_KEY) as ThemeMode) ?? light, }), getters: { isDark: (state) state.mode dark, }, actions: { toggleTheme() { this.mode this.mode light ? dark : light applyTheme(this.mode) localStorage.setItem(THEME_KEY, this.mode) }, initTheme() { applyTheme(this.mode) }, }, }) function applyTheme(mode: ThemeMode) { document.documentElement.setAttribute(data-theme, mode) }我单独抽了一个applyTheme函数作用是给html根元素设置>:root, [data-themelight] { --bg-color: #ffffff; --text-color: #1f2329; --primary-color: #409eff; --border-color: #dcdfe6; } [data-themedark] { --bg-color: #141414; --text-color: #e5eaf3; --primary-color: #409eff; --border-color: #363637; }然后把页面里所有硬编码的颜色值统一替换成 CSS 变量body { background-color: var(--bg-color); color: var(--text-color); } .card { border: 1px solid var(--border-color); background-color: var(--bg-color); }组件里只要切>script setup langts import { useThemeStore } from /stores/theme const themeStore useThemeStore() themeStore.initTheme() /script template router-view / /template5.3 主题切换按钮与组件配合最后在导航栏放一个切换按钮和我们的 user store 组合使用就组成了完整的顶部栏script setup langts import { useThemeStore } from /stores/theme import { useUserStore } from /stores/user const themeStore useThemeStore() const userStore useUserStore() /script template header classnavbar div classleft span{{ userStore.displayName }}/span /div div classright button clickthemeStore.toggleTheme() {{ themeStore.isDark ? 切换到亮色 : 切换到暗色 }} /button button clickuserStore.logout()退出登录/button /div /header /template你看登录状态和主题状态两个 store 在同一个组件里完美配合之间没有任何耦合。这也是 Pinia 扁平 store 结构带来的好处每个 store 专注自己的领域组件层按需组合。你甚至可以在某个模块里同时调用三个 store 的方法代码依然清晰。6. 常见问题与排查技巧踩坑实录速查表最后这部分是我最想分享的实战经验。无论是培训新人还是自己维护老项目总能在 Pinia 的使用中遇到一些“差一点就疯掉”的问题。我把高频问题整理一下做成速查表后续遇到可以直接翻这篇。问题现象根本原因解决方式getActivePinia()报错找不到 pinia在 store 文件里直接调用 useXxxStore但此时 vue 应用还没 mount确保app.use(pinia)先执行或者把调用延迟到组件 setup / 路由守卫内部从 store 解构 state 后数据不再响应式直接把普通变量从 reactive 状态上解构脱离了响应式代理使用storeToRefs解构刷新页面后登录状态丢失只在登录时写入内存没有做持久化初始化 state 时读 localStorage或者引入 persist 插件主题切换后出现短暂白屏/闪烁切换前没有设置根元素的>