资讯动态

Vue3 + Pinia 状态管理实战:从登录状态到主题切换的完整指南

发布时间:2026/9/26 0:40:27 来源:尧图企业网站定制
开篇先抛一个问题如果你是从 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而不是继续用 Vuex1.1 Vuex 时代的痛点用过 Vuex 的都知道写一个模块需要state、mutations、actions、getters四件套还要用mapState、mapMutations这类辅助函数来绑定。如果是中大型项目模块文件一多这些样板代码会占掉大量篇幅。更麻烦的是 TypeScript 支持Vuex 4 在 TS 环境下的类型推导需要写一堆复杂的类型体操比如ModuleS, 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 langts import { useCounterStore } from /stores/counter const store useCounterStore() /script template div pcount: {{ store.count }}/p pdouble: {{ store.double }}/p button clickstore.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 } storecount 会失去响应性。要解构时应该用storeToRefs()。另外强烈建议安装 Pinia 官方的 devtools 插件如果是 Vue Devtools 插件内置的组件那也行。调试时能在 Timeline 里看到每次 state 变化的记录甚至可以直接拖拽时间线回滚状态排查问题效率提升不是一点半点。3. 核心概念拆解State、Getters、Actions 的使用细节3.1 State状态管理的底座State 本质上就是 store 里的响应式数据源。与 Vuex 不同Pinia 的 state 在创建时是函数形式这有利于服务端渲染时避免状态共享冲突。直接修改 state 在 Pinia 里是允许的比如store.count或者批量更新时用$patchstore.$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 的其他 getterexport 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.tsimport { 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 langts 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 langts import { useThemeStore } from /stores/theme const themeStore useThemeStore() /script template button classtheme-toggle :aria-labelthemeStore.mode light ? 切换到深色模式 : 切换到浅色模式 clickthemeStore.toggleMode() span v-ifthemeStore.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 新手最容易踩的坑这里整理一份我平时答疑时高频出现的问题清单照着排查能省大量时间。问题 1getActivePinia was called with no active Pinia.原因在 Pinia 安装之前就调用了useStore()最常见于router.beforeEach守卫。解决调整初始化顺序先createPinia()再app.use(pinia)最后安装 router。如果是测试环境还需要手动setActivePinia(createPinia())。问题 2storeToRefs与store解构混乱原因直接const { count, name } store后变量变成了普通值不再响应式。解决需要解构响应式属性时用storeToRefs(store)需要解构方法时直接用store对象例如const { count } storeToRefs(store)而store.login()不用解构。问题 3TS2339: 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的初始化顺序这两个方向能解决大部分入门期的意外状况。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑