
第三阶段前面写了差不多四十天路由这块如果是一路跟过来的朋友应该已经从静态路由玩到了动态路由也从嵌套路由、路由守卫这些细节里踩过不少坑。到了第39天正式开始接触 Vuex这个节点其实非常关键前面几十天里组件通信靠 props、emit、provide/inject 还能撑住一旦页面复杂起来兄弟组件、深层嵌套组件之间的数据传递就会变得非常难受。Vuex 的核心概念看着就五个——State、Getter、Mutation、Action、Module但把这五个概念真正用顺不是背概念就能解决的问题得把它放在真实项目里去理解。这篇文章我就按第39天的学习节奏来写不堆官方文档式的内容直接把每个概念拆开讲清楚为什么这么设计、实际怎么用、以及我在这阶段踩过哪些坑。刚入门 Vuex 的朋友或者学了几天想梳理清楚的朋友跟着过一遍再手写一个小案例基本就能摆脱“代码能跑但不理解”的尴尬状态。1. 为什么偏偏在第39天引入状态管理到了这个阶段项目已经不是单个组件里写写 data 那么简单了。用户登录信息、购物车列表、菜单权限、接口返回的公共数据这些东西散落在不同的组件里。最老套的方案是 props 层层往下传但传个两三层的组件确实还能忍一旦页面结构变深改动一个字段就需要沿着组件树一路改下去非常消耗精力。另一个常见场景是兄弟组件之间需要共享数据。比如购物车页面左侧是商品列表右侧是结算汇总两个组件都需要知道当前的购物车数量。如果用事件总线event bus来通知项目初期还能维护到了中期事件一多根本不好追踪是谁发了事件、谁接收了事件。Vuex 解决的正是这种跨组件、跨页面的共享状态问题。1.1 状态到底是什么初学时最容易混淆的就是“状态”这个词。说白了状态就是“页面需要记住的数据”比如用户的登录名、购物车里的商品数组、当前用户是否 VIP。普通组件里用 data() 定义的也是状态只不过那个状态是组件私有的。Vuex 做的是把一些“多个组件都要用”的私有状态提升到一个集中的地方让所有组件都能访问并且以可追踪的方式去修改。第一次听可能觉得这就是“全局变量”但严格说Vuex 是一个带有响应式约束的“全局数据中心”。1.2 不用 Vuex 也能写的场景这里要泼一盆冷水不是所有项目都该上 Vuex。一个简单的活动页两三个组件自己内部写点 props 就够了非要引入 Vuex 只会让代码更繁琐。我在第39天练习时就犯过这个毛病明明只是两个组件共享一个姓名硬是搭了 Vuex结果代码量翻倍维护起来更麻烦。所以第39天的第一步应该是学会判断如果一个数据只在一个组件内部使用留在组件里如果一个数据要跨组件、跨页面访问或者同一个数据会被多个操作流程修改进入 Vuex 也不迟。2. 核心概念底层逻辑State 与 Getter2.1 State 的创建与访问State 就是 Vuex 仓库里的“数据源”一般定义在 store 的 state 对象中。最基础的写法是把 state 定义成一个函数尤其在服务端渲染环境里有讲究但普通客户端项目直接写对象也不会出问题。// src/store/index.js import Vue from vue import Vuex from vuex Vue.use(Vuex) export default new Vuex.Store({ state: { userInfo: null, cartCount: 0, permissionRoutes: [] } })组件里访问 State 的常规方式是通过 this.$store.state 直接读取或者使用官方提供的 mapState 辅助函数。这里有一个细节在模板里直接用 $store.state.userInfo 读是可以的但如果一个组件里要读多个 state 字段每处都写 $store.state 就太脏了于是 mapState 的价值就体现出来了。import { mapState } from vuex export default { computed: { ...mapState([userInfo, cartCount]) } }要注意 mapState 返回的是一个对象每个值都是一个计算属性所以一定要写在 computed 里展开。我自己初学时的错误是把 ...mapState 写在 methods 里结果页面直接报错因为 methods 里放的是按钮点击后的逻辑计算属性要求的是必须有返回值。2.2 响应式规则与主动注意点State 响应式依赖 Vue 的响应式系统。也就是说直接在 state 对象上新增一个属性不会自动更新视图但替换整个对象是可以响应式的。这一点和 Vue 2 里的 data 响应式规则一脉相承。// 可以生效 this.$store.state.userInfo { name: 张三 } // 这样也能生效 this.$store.state.userInfo { ...this.$store.state.userInfo, age: 18 }在 Vuex 4 Vue 3 的环境里因为基于 Proxy新增属性也能触发响应式。但如果你还在用 Vue 2 的项目就得留意对象新属性的坑尽量提前把字段声明好别等到运行过程中再往 state 里塞新字段。2.3 Getter 的定位和用法Getter 可以理解成“从 State 里派生出来的计算属性”。比如购物车列表是 state 里的原始数据而“商品总价”就是基于购物车列表派生出来的值就没必要每次在组件里自己写计算属性直接在 Getter 里定义。state: { cart: [ { id: 1, name: 键盘, price: 300, count: 1 }, { id: 2, name: 鼠标, price: 150, count: 2 } ] }, getters: { totalPrice(state) { return state.cart.reduce((sum, item) sum item.price * item.count, 0) } }组件里通过 mapGetters 来映射用法和 mapState 类似。Getter 可以返回一个函数这样就能传参比如通过 id 找商品getters: { getCartItemById: (state) (id) { return state.cart.find(item item.id id) } }注意这种返回函数的 Getter 会导致每次访问都重新计算不会缓存结果。所以在模板里频繁使用时要考虑性能问题不要在一个 v-for 里大量调用。2.4 为什么 State 不能直接改刚接触几天的人经常会问既然 state 是对象组件里拿到的也是同一个对象直接改不就行了直接改在功能上当然能生效因为 Vue 的响应式机制会捕获这个改动。问题在于“不可追踪”。开发一个小项目时直接改一下很方便但想象一下一个项目里有几十个组件大家都直接动 state出问题时根本不知道是哪一个组件在什么时候改的。DevTools 里的时间旅行调试、状态快照、操作记录全部依赖“通过 Mutation 修改”这个约束。说简单点直接把 state 当作普通全局对象去改等于绕过了 Vuex 的审计机制后续排错会非常痛苦。这也是面试时经常提到的“不要在组件里直接修改 state 的数据”的核心原因。3. Mutation 与 Action同步修改与异步处理的边界3.1 Mutation 的定义模型Mutation 是 Vuex 官方指定的“唯一修改 State 的手段”。它类似于事件注册每个 Mutation 都有一个字符串类型的事件类型type和一个回调函数回调函数接收 state 作为第一个参数。mutations: { SET_USER_INFO(state, payload) { state.userInfo payload }, INCREMENT_CART_COUNT(state, num 1) { state.cartCount num } }组件里要触发 Mutation使用 committhis.$store.commit(SET_USER_INFO, { name: 张三, age: 18 })最初不熟悉的时候总觉得这是脱裤子放屁后来结合 Vue DevTools 看才明白每次 commit 会生成一条记录代码里出现 bug 时能清晰地看到“哪个状态被谁改成了什么”。这个记录能力正是把简单的数据操作包装成 Mutation 的原因。3.2 Mutation 必须保持同步Mutation 回调函数里不允许做异步操作这是 Vuex 官方明确规定的。原因是 Mutation 执行期间DevTools 需要记录状态变更前后快照如果里面放了异步操作状态的最终时机就无法保证调试时看到的快照可能和实际不一致。初学时我觉得这个限制太严格后来把定时器放进去试了一次果然出了奇怪的结果先触发了一个 mutation里面 setTimeout 改了 state但是界面上并没有立刻更新等我重新刷新页面才看到数据变了。原因不在 React/Vue 本身而在于异步操作让 commit 后的状态记录失效了。所以同步的数据修改放到 mutation 里异步的事情完全交给 Action。3.3 Action 的作用域和 dispatchAction 是处理异步逻辑并在异步结束后提交 Mutation 的一个中转层。Action 接收一个 context 对象context 里包含 state、commit、dispatch、rootState 等能力。actions: { async fetchUserInfo({ commit }) { const response await api.getUserInfo() commit(SET_USER_INFO, response.data) return response.data }, login({ commit, dispatch }, payload) { // 模拟登录流量 return new Promise((resolve) { setTimeout(() { commit(SET_USER_INFO, payload) resolve(payload) }, 1000) }) } }组件里通过 dispatch 去触发 Actionthis.$store.dispatch(fetchUserInfo)有一点值得专门拿出来说Action 不是替代 Mutation而是流程编排者。真正去改 state 的是 MutationAction 只负责“获取数据/处理逻辑/然后 commit”。这样做的好处是如果有多处异步操作最终都要修改 userInfo只要各自 dispatch 到同一个 Action修改逻辑仍然集中在 mutation 里不会散落在各组件。3.4 为什么第39天写代码前要先设计好这些边界很多人学 Vuex 是一边写一边叫“怎么这么麻烦”但我后来在实际项目里发现麻烦其实是你把异步逻辑写在 mutation 里、同步逻辑又到处乱写造成的。设计好 Action/Mutation 的边界后面做代码 review 时非常受益组件里的 handlers 只负责 dispatch action不直接操作数据action 里只做数据获取、临时的数据变换、调用 commitmutation 里只做 state 赋值操作这样一个单向数据流就非常清晰组件 → dispatch → action → commit → mutation → state → 视图重新渲染。4. Module 模块化单文件大仓库的拆分4.1 为什么要拆模块项目一复杂所有 state、getters、mutations、actions 全部堆在一个 store 文件里文件可能有上千行找起来非常麻烦。Module 就是允许把 store 拆成多个小模块每个模块自己拥有 state、getters、mutations、actions。比如一个后台管理项目可以拆成 user 模块、cart 模块、permission 模块。// src/store/modules/user.js export default { namespaced: true, state: () ({ token: , userInfo: {} }), getters: { isLogin(state) { return !!state.token } }, mutations: { SET_TOKEN(state, value) { state.token value }, SET_USER_INFO(state, value) { state.userInfo value } }, actions: { login({ commit }, userData) { return new Promise((resolve) { setTimeout(() { commit(SET_TOKEN, mock-token) commit(SET_USER_INFO, userData) resolve() }, 500) }) } } }根 store 里导入并注册// src/store/index.js import user from ./modules/user import cart from ./modules/cart export default new Vuex.Store({ modules: { user, cart } })4.2 namespaced 到底解决了什么问题默认情况下模块内部的 mutation 和 action 仍然注册在全局命名空间下这样多个模块之间容易重名。给模块开启 namespaced: true 后内部 action、mutation 的调用方式就变了需要在前面加上模块名。this.$store.commit(user/SET_TOKEN, mock-token) this.$store.dispatch(user/login, { name: 张三 })在 mapState / mapGetters 这类辅助函数的写法也会变化必须多传第一层参数指向模块名。export default { computed: { ...mapState(user, [token, userInfo]), ...mapGetters(user, [isLogin]) }, methods: { ...mapActions(user, [login]) } }初学 Module 时最容易搞混的地方就在这里不加 namespaced 的话模块内 action 在组件里调用的路径和根级别完全相同加上之后又容易出现路径少了模块名前缀而报错。有一个经验可以分享从第一天开始就给每个模块都写上 namespaced: true后期扩展不会因为重名问题而返工。4.3 模块之间的数据访问有时候 user 模块里需要读取 cart 模块的数据或者 action 里想触发另一个模块的 action。核心做法就是使用 rootState 和 rootGetters 参数。actions: { checkout({ commit, rootState }) { // rootState.cart 就能拿到 cart 模块的数据 if (rootState.cart.items.length 0) { return false } commit(CLEAR_CART, null, { root: true }) } }commit 被调用的第三个参数如果是 { root: true }则表示这个 mutation 是注册在根级别的。dispatch 同理。这一块最需要练的其实是想象力多个模块之间并不是完全隔离的它们共享同一个仓库只是通过命名空间来区分层次。理解成“部门办公区划分”就挺好每个部门有自己的办公室和文件柜但整栋楼都归公司统一管理跨部门协作就得走申报流程。5. 第39天的实操小案例登录状态 动态路由既然阶段标题是“Vue 路由与状态管理”光看概念等于没学真正把 Vuex 和路由结合起来才能看到这两个东西是怎么协同工作的。我建议动手做一个最经典的后台登录场景用户输入用户名密码 → 调用登录接口 → 获取用户角色 → 根据角色动态生成可访问的路由 → 刷新页面后通过 token 重新拉取用户信息和路由。5.1 项目结构与依赖基于 Vue CLI 或者 Vite 创建项目安装 vue-router 和 vuex。这里假设使用 Vue 3Vuex 4 的写法会有对应区别但核心概念一致。# 使用 Vite 创建 vue 项目 npm create vitelatest vuex-demo -- --template vue cd vuex-demo npm install vue-router4 vuex45.2 路由侧准备工作先定义一份常量路由表所有人都能访问比如登录页、首页。再定义一份异步路由表存放只有特定权限的用户能访问的页面比如用户管理、订单管理。// src/router/index.js import { createRouter, createWebHashHistory } from vue-router // 基础路由不需要登录常驻 export const constantRoutes [ { path: /login, component: () import(/views/Login.vue) }, { path: /, component: () import(/layout/index.vue) } ] // 动态路由后续根据权限加入 export const dynamicRoutes [ { path: /users, component: () import(/views/UserList.vue), meta: { roles: [admin] } }, { path: /orders, component: () import(/views/OrderList.vue), meta: { roles: [admin, editor] } } ] const router createRouter({ history: createWebHashHistory(), routes: constantRoutes }) export default router5.3 Vuex 里维护权限模块在 permission 模块里记录当前用户可访问的路由并提供生成动态路由的 action。// src/store/modules/permission.js import { dynamicRoutes } from /router const state () ({ routes: [] }) const mutations { SET_ROUTES(state, routes) { state.routes routes } } const actions { generateRoutes({ commit }, roles) { const accessedRoutes dynamicRoutes.filter(route { if (!route.meta || !route.meta.roles) return true return route.meta.roles.some(role roles.includes(role)) }) commit(SET_ROUTES, accessedRoutes) return accessedRoutes } } export default { namespaced: true, state, mutations, actions }5.4 登录流程完整链路登录页里点击登录后await this.$store.dispatch(user/login, loginForm) const roles this.$store.getters[user/roles] const accessedRoutes await this.$store.dispatch(permission/generateRoutes, roles) accessedRoutes.forEach(route router.addRoute(route)) router.push(/)这里就把 Vuex 的 Action、Getter、Mutation 全走了一遍login 是异步 Action里面 commit 用来保存 token 和用户信息roles 通过 Getter 读取generateRoutes 在另一个模块里触发最后利用 vue-router 的动态 addRoute 方法把路由注册进去。刷新页面时需要从持久化存储里恢复 token再拿着 token 调接口获取用户信息重新生成路由。这一整套流程做通了Vue 路由和 Vuex 的实际配合就算真正掌握了。整个过程不建议复制粘贴别人的代码一定自己手写一遍。手写过程中能被逼着去理解模块之间怎么组织、路径该怎么写、异步流从哪里开始到哪里结束这比看十遍文档都管用。5.5 记住这个“单向数据流”把这个案例的流程画成文字描述就是视图层 dispatch actionaction 执行 async 操作action commit mutationmutation 修改 state新 state 通过 Getter 或 mapState 被视图层消费只要这个链条没有被打断你就已经把 Vuex 的五个核心概念串起来了。Module 只是把一个一个这样的链条按照业务域拆到独立文件里。6. 第39天踩坑记录与面试速答6.1 直接修改 state 的坑我见过不少同事为了省事在组件里写 this.$store.state.cartCount页面确实更新了DevTools 里也不会有任何记录。后来项目上线后出现了一个诡异的 bug某个页面购物车角标偶尔少统计一个商品。排查了半天最后发现是有两个组件都在直接操作 state其中一个先执行完把数据改了另一个也改了两个操作在时序上出现了覆盖。如果当时统一走 mutationDevTools 里一条一条回放就能立刻定位是谁改的。不要和浏览器较劲遵循 Vuex 规则就是在减少未来的调试负担。6.2 Mutation 里放异步代码这个也是初学高频错误。我刚开始总觉得“接口返回数据后我要把数据存到 state 里那我就在 mutation 里请求接口不就行了”于是写下类似这样的代码mutations: { async fetchData(state) { const res await api.getData() state.data res.data } }表面看起来没毛病但 Vuex 内部设计上并不支持这种用法。在严格模式下DevTools 会提示异常而且由于异步时序的问题状态更新的可追溯性完全没了。正确写法是 action 里 await 完再 commitcommit 里只做赋值。6.3 刷新页面 state 丢失Vuex 的 state 存在内存里页面刷新后内存重新初始化state 中数据自然清空。token、用户信息这类需要长期保留的数据一般要结合 localStorage 或 Cookie 来做持久化。我第39天做案例时没做持久化每次刷新就回到登录页折腾了一会才反应过来不是代码逻辑错而是根本没有“记忆”。新手很容易在这里卡壳以为是 vue-router 守卫写法不对其实问题在状态恢复逻辑缺失。6.4 面试高频问题速答整理几个这阶段最常被问到的面试题顺带给出表达方向Vuex 是什么有哪些核心概念—— 答法Vuex 是专为 Vue 设计的集中式状态管理方案核心是 State/Getter/Mutation/Action/Module核心价值在于可预测的数据流。Mutation 为什么不能做异步—— 答法为了配合 DevTools 做状态快照和调试异步操作会让状态变更时机不可控。Action 和 Mutation 的区别—— 答法Action 负责异步流程最终通过 commit 触发 MutationMutation 是同步修改 State 的唯一入口。Module 的作用—— 答法把复杂的全局 store 按业务域拆分成多个模块配合 namespaced 隔离命名空间。说一下 Vuex 单向数据流。—— 答法组件 dispatch ActionAction 处理异步并 commit MutationMutation 修改 StateState 变化驱动组件更新。6.5 一个值得养成的习惯第39天做练习时最好同时打开 Vue DevTools在每次操作后观察 State 和 Mutation 的变化记录。尤其是你写“看起来能跑但没想清楚”的代码时DevTools 会直观展示数据来源。我发现很多同学学 Vuex 最大的拦路虎不是语法而是“不习惯在写代码前思考数据流向”。Vuex 逼着你从“改数据”转向“设计数据流”。这个思维一旦建立起来后续再去掌握 Pinia或者学习 Redux 等其他状态管理方案都会非常顺手因为核心逻辑本质上是互通的。最后再多说一句我在实际项目里的体会状态管理不是页面所有数据的搬家而是把“会被多个地方共享、会被多种方式修改”的关键数据沉淀到仓库中其余的本地 UI 状态轻声留在组件里就好。掌握 Vuex 不是目的你能根据自己的页面复杂度做出“哪些数据进仓库、哪些数据不进仓库”的判断才算是真正把第39天学到位了。