☰
告别Vuex,拥抱Pinia:Vue3状态管理实战指南
2026/9/25 3:30:12 网站建设 项目流程

做后台管理系统这几年,我先后经历了 Vue2 + Vuex 到 Vue3 + Pinia 的切换,说实话刚开始是有点抗拒的,毕竟 Vuex 用了那么久,突然来个新东西总觉得又要重新学一遍。但真正把 Pinia 用进项目之后,最大的感受就是:早该换了。代码量直接砍掉三分之一,TypeScript 支持好到不像官方出的工具,而且那个 DevTools 的调试体验简直丝滑。如果你正在做 Vue3 项目,或者刚看完 Vue3 基础想进阶,又或者被 Vuex 的 mutations、modules 各种嵌套搞到头疼,这篇内容就是为你准备的。我会从零开始,把 Pinia 的安装、核心概念、登录态和主题切换两个实战场景完整过一遍,全程用真实项目里能直接跑的代码说话。

1. 为什么是 Pinia:Vuex 的时代真的过去了

先聊点实际的。我见过不少团队从 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 vite@latest 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 API,Pinia 还提供了一种 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 = ref<string[]>([]) 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 的响应式 API;options store 更经典、更好理解,适合初学者和习惯 Vuex 语法的老手。我个人的习惯是:简单场景用 options,涉及大量工具函数复用、或者需要根据接口动态拼接 store 内容场景时用 setup。两个都能打,根据团队的技术底色做选择就好。

3.3 组件里如何优雅地使用 store

store 定义好了,接下来就是组件里最常用的三件套:引用 store、解构 state、调用 actions。我先给一个基础示例:

<script setup lang="ts"> 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 @click="login({ username: 'admin', token: 'fake-token', roles: ['admin'] })"> 模拟登录 </button> <button @click="logout()">退出登录</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 里有 token,store 的初始登录态就直接是“已登录”。这是很多新手容易漏掉的细节——只写 save,忘了初始化时 read,一刷新就回到未登录状态。

关于持久化的方案,这里用的是最基本的原生 localStorage。如果你项目已经引用了pinia-plugin-persistedstate这类插件,也可以直接给 store 加persist: true配置,插件会自动完成序列化和恢复。两者效果类似,但少写不少代码。我用原生 localStorage 是为了让大家看清底层原理,实际项目里用插件更方便。

4.2 登录页、路由守卫和权限控制联动

有了 user store,登录页就变得异常简洁。核心逻辑就是:调用 login action、成功后跳转首页、失败弹错误提示。我用一个简单的登录组件来演示:

<script setup lang="ts"> 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.prevent="handleLogin"> <input v-model="username" placeholder="用户名" /> <input v-model="password" type="password" placeholder="密码" /> <p v-if="errorMsg" class="error">{{ errorMsg }}</p> <button :disabled="loading" type="submit"> {{ 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 lang="ts"> import { storeToRefs } from 'pinia' import { useUserStore } from '@/stores/user' const userStore = useUserStore() const { displayName, isLoggedIn, userRoles } = storeToRefs(userStore) </script> <template> <nav> <template v-if="isLoggedIn"> <span>{{ displayName }}</span> <button v-if="userRoles.includes('admin')">管理后台</button> <button @click="userStore.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-theme='light'] { --bg-color: #ffffff; --text-color: #1f2329; --primary-color: #409eff; --border-color: #dcdfe6; } [data-theme='dark'] { --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 lang="ts"> import { useThemeStore } from '@/stores/theme' const themeStore = useThemeStore() themeStore.initTheme() </script> <template> <router-view /> </template>

5.3 主题切换按钮与组件配合

最后在导航栏放一个切换按钮,和我们的 user store 组合使用,就组成了完整的顶部栏:

<script setup lang="ts"> import { useThemeStore } from '@/stores/theme' import { useUserStore } from '@/stores/user' const themeStore = useThemeStore() const userStore = useUserStore() </script> <template> <header class="navbar"> <div class="left"> <span>{{ userStore.displayName }}</span> </div> <div class="right"> <button @click="themeStore.toggleTheme()"> {{ themeStore.isDark ? '切换到亮色' : '切换到暗色' }} </button> <button @click="userStore.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 插件
主题切换后出现短暂白屏/闪烁切换前没有设置根元素的>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询