
1. 从一个数据两边对不上的经典场面说起先说一个很多人做后台管理系统时都遇到过的场景左侧是一个侧边栏显示当前登录人的权限菜单右上角是一个头像下拉显示待办数量中间主区域是一张订单列表。某个用户在主区域点了标记已读列表上的红点消失了但右上角的待办数字纹丝不动刷新页面之后才同步过来。这时候你八成会去翻代码发现待办数量是另一个组件自己在mounted里单独请求了一遍接口两个组件各自持有一份数据谁也不认识谁。这类问题的本质不是接口写错了而是状态的归属没有想清楚。同一个业务事实被复制成了多份散落在不同组件里任何一处更新都得靠人工去通知其他地方。项目小的时候还能靠$emit一路往上抛等到组件层级到了四五层抛事件就像玩击鼓传花中间任何一环漏掉数据就断了。Vuex 就是为了解决这个问题才被引入的。它是 Vue 生态里的集中式状态管理方案把跨组件共享的那部分数据抽出来放进一个全局的 store 里所有组件只读同一份数据所有修改都必须走统一的入口。关键词落到三个单一状态树、单向数据流、可预测的状态变更。这三点听着像口号但后面每一处设计细节、每一个让人抓狂的报错几乎都能从这三句话里找到解释。这篇内容面向的是已经写过 Vue 组件、但被 Vuex 那几个概念绕晕的人state、getter、mutation、action、module到底各自该管什么为什么改数据非得绕一圈 commitnamespaced开了之后为什么到处报unknown action type。我会把每个概念背后的为什么拆开讲顺带把实际项目里踩过的坑、调试的排查顺序都摆出来。看完你应该能自己判断一个需求该不该放进 store、该放在哪一层。2. 组件通信的五条路Vuex 只是其中一条2.1 props 与 $emit 的天花板在哪里父子组件通信最朴素父组件用props往下传子组件用$emit往上抛。两三层的结构里这套东西非常清爽也没什么性能负担。问题出在两个地方。第一个是跨层级。假设 A 是页面根B 是布局容器C 是卡片区D 才是真正要展示用户信息的组件。用户信息在 A 里拿到了要传给 D那 B 和 C 就得各自声明一个自己根本用不上的 prop纯粹当传话筒。这种写法在代码审查里叫prop 打洞一旦中间某一层重构改名链条上三四个文件都得跟着动。第二个是兄弟组件之间没有直接通道。同一层级的两个组件要通信只能把状态提升到共同的父级再由父级往下分发。当页面里有一张表格和右侧的筛选面板需要联动父组件立刻变成一个中转站data里塞满了跟自身业务无关的东西。2.2 provide/inject、EventBus 和 Vuex 的取舍provide/inject是 Vue 自带的跨层级注入方案祖辈提供任意后代注入。它解决打洞问题很有效但它只适合稳定的、不频繁变化的依赖比如主题配置、表单实例、国际化方法。原因很直接注入的值不具备响应式的强约束谁改了源头下游何时更新依赖的响应式机制心智负担不轻调试时也很难一眼看出来某个值是从哪个祖先流下来的。EventBus 那套写法$bus.$emit/$on在早期项目里很流行它的致命弱点是没有数据源。事件发出去之后状态存在哪里还在各个组件自己身上。这等于只解决了通知没解决共享而且事件名散落各处半年后没人记得order-refresh到底是刷新列表还是刷新计数。Vuex 和这几个方案的根本区别在于它先有一份数据再有通知。数据是唯一的那一份通知是数据变更的副产品。所以在下面这几种情况下Vuex 的价值才真正成立多个互不相关的组件需要读写同一份业务数据登录态、权限码、购物车、全局配置。需要跨路由保持状态比如从列表进详情再返回筛选条件还在。想把数据怎么变这件事记录下来方便回溯和调试。团队协作需要一条明确的纪律约束大家别乱改数据。反过来如果只是父子传个值或者某个状态只在单个组件内部使用硬塞进 store 只会让 store 变成一个巨大的杂物间。这一点我后面还会细说。2.3 单向数据流这条线到底约束了什么Vuex 里的数据流动方向是固定的组件不能直接改state必须commit一个mutationmutation里再去修改state改完之后视图跟着更新。如果要处理异步请求接口、定时器就先dispatch一个actionaction里拿到结果再commit。多绕的这一层不是官僚主义。它的价值在于所有对 state 的写操作都被收敛到了 mutation 里。这么一来你只要打开调试工具就能看到一个严格按时间排列的操作记录每一条都带载荷、带时间戳甚至能时间旅行回退到之前某个状态。如果没有这层约束一个值在十几个地方被直接赋值出了问题只能靠console.log一点点试。打个比方mutation 就像银行柜台的业务窗口。你要动账上的钱必须走窗口填单子柜员记录一笔流水。如果允许客户自己伸手进金库拿钱那账本永远对不上。异步操作比如等审批、等外部确认不能占着柜台所以放到 action 那边去排队等结果确定了你再回来填单子。3. State 与 Getter数据源和派生数据的边界3.1 单一状态树不是什么都往里塞state就是那个全局的数据对象。Vuex 采用单一状态树整个应用只有一个 store 实例所有模块的状态最终都挂在同一棵树上。好处是定位数据非常直接看到一个字段你知道去 store 里找不用猜是哪个组件私有的。但单一不等于臃肿。我刚接触 Vuex 的时候犯过一个典型错误把组件内部的临时状态也搬进了 store比如弹窗的显示隐藏、表格的当前页码、输入框的草稿值。结果就是 store 里躺着几百个字段每次改动都要写一套 mutation代码量翻了三倍收益为零。判断标准其实很简单问自己三个问题这个状态有没有第二个人读只有一个组件用就留在组件data里。这个状态需不需要跨路由存活组件销毁就没了那也没必要上 store。这个状态是不是业务事实弹窗开没开是 UI 状态订单已支付是业务事实。前者留本地后者进 store。在组件里读 state 最直接的写法是this.$store.state.count但实际项目里更推荐用mapState因为它能在computed里批量映射而且模板里不用写那么长的前缀。mapState有三种写法用起来差别不小import { mapState } from vuex export default { computed: { // 写法一数组要求 state 字段名和 computed 名一致 ...mapState([count, userInfo]), // 写法二对象可以改名 ...mapState({ total: state state.cart.total }), // 写法三对象 普通函数函数里可以拿到 this ...mapState({ currentOrder(state) { return state.orders.find(o o.id this.orderId) } }) } }第三种写法有个细节必须记住只有普通函数不是箭头函数才能拿到组件实例的this。用箭头函数的话this指向的是模块作用域拿不到this.orderId运行起来就是undefined而且不会报错只会默默算错。这个坑我见过不止一次排查时对着断点看了半天才发现是箭头函数的问题。另外还要注意命名冲突。mapState展开在computed里如果和组件自己的 computed 同名后者会被覆盖或者报重复键。稳妥的做法是在映射时就改好名字别指望后面靠约定去避让。3.2 Getter 的缓存机制决定了它和 methods 不是一回事getter可以理解成 store 层面的 computed。它的核心特性是基于依赖的缓存只要依赖的 state 没变多次访问返回的是同一个结果不会重复计算。这一点和 methods 有本质区别。假设你有一个购物车总价的计算里面要遍历几十个商品、做折扣判断、算税费。如果写成 methods模板里每次重新渲染都会执行一遍写成 getter只有cart.items或cart.discount变化时才重算。在渲染频繁的页面里这个差距是肉眼可见的。const store new Vuex.Store({ state: { cart: { items: [ { id: 1, price: 100, count: 2 }, { id: 2, price: 50, count: 1 } ], discount: 0.9 } }, getters: { cartTotal(state) { const sum state.cart.items.reduce((acc, item) { return acc item.price * item.count }, 0) return sum * state.cart.discount }, // getter 也可以接收其他 getter 作为第二个参数 cartTotalText(state, getters) { return 合计${getters.cartTotal.toFixed(2)} 元 } } })有个关键限制要提前说清楚getter 默认不能接收自定义参数。如果你写getters.getById(3)这行代码不会按你预期工作。需要传参时的标准做法是让 getter 返回一个函数getters: { getOrderById: (state) (id) { return state.orders.find(o o.id id) } }但这里有个容易忽略的代价返回函数之后缓存失效了。因为每次调用返回的都是一个新的函数实例Vue 无法对这个函数内部的结果做依赖追踪缓存。所以高频、重计算的场景别用这种写法改成在组件 computed 里基于state.orders自己find反而更快。什么时候用返回函数的 getter数据量小、调用不频繁、主要图个写法方便的时候。这是个典型的方便和性能之间的取舍没有标准答案但心里得有数。3.3 别把 getter 当成第二个 state 仓库还有一种常见误用把所有能算出来的东西都塞进 getter。曾经见过一个 store里面几十个 getter其中一半只是简单取个字段比如userName: state state.user.name。这种 getter 没有任何计算逻辑缓存也帮不上忙纯粹增加了一层间接引用改代码的时候还得多跳一次文件。我的习惯是有计算逻辑、可能被多个组件复用、或者计算成本较高的才做成 getter单纯取字段直接用mapState或者干脆在组件里点出来。这条线划清楚store 的可读性会好很多。4. Mutation 的设计纪律为什么只能同步、只能 commit4.1 一次直接赋值引发的调试灾难先说结论在严格模式下任何绕过 mutation 直接修改 state 的行为都会抛错。我建议开发环境一定开严格模式const store new Vuex.Store({ state: { count: 0 }, mutations: { increment(state, payload) { state.count payload.amount } }, strict: process.env.NODE_ENV ! production })严格模式的原理不复杂Vuex 内部会用深度 watcher 监听 state 树的整体变化一旦发现有变化不是由 mutation 触发的就抛出错误。所以它的实现方式是深比较这在大型 state 树上是有性能开销的这也是为什么生产环境要关掉。判断依据就是上面那段process.env.NODE_ENV构建工具会在打包时把这段替换掉。我遇到过最隐蔽的一次问题是某个组件里写了this.$store.state.user.info res.data开发环境直接红屏报错但那位同事本地的strict是关着的所以他一直没发现。等到代码合到主分支别人一跑就炸。团队里最好在上线前把这条写进代码规范或者用 lint 规则卡一下对$store.state的赋值操作。4.2 载荷设计对象优先命名统一mutation 的第二个参数叫载荷payload它只能有一个。所以要传多个值时习惯上打包成对象mutations: { SET_USER_INFO(state, { name, avatar, roleId }) { state.user.name name state.user.avatar avatar state.user.roleId roleId } }载荷不做成对象也不是不行但对象有个明显好处以后要加字段不用改函数签名调用方和定义方都不受影响。我在项目里统一约定了两条规则执行下来很省事。第一mutation 名用全大写加下划线动词开头比如SET_XXX、ADD_XXX、REMOVE_XXX、CLEAR_XXX。这么写在提交的时候一眼就能区分是动作还是状态字段。第二mutation 名用常量抽出来集中放在一个文件里定义和调用都引同一个常量避免手写字符串拼错// mutation-types.js export const SET_USER_INFO SET_USER_INFO export const CLEAR_USER_INFO CLEAR_USER_INFO// store/user.js import { SET_USER_INFO, CLEAR_USER_INFO } from ./mutation-types const user { state: { info: {} }, mutations: { [SET_USER_INFO](state, payload) { state.info { ...state.info, ...payload } }, [CLEAR_USER_INFO](state) { state.info {} } } }不用常量也不是活不下去只是拼错字符串的时候编辑器不会提示运行到那一行才炸排查成本高。用了常量之后至少能靠 ESLint 的未使用变量和拼写检查挡一部分。4.3 那些在 mutation 里不能做的事mutation 里除了不能异步还有几个隐性要求值得单独拎出来。因为 Vuex 的调试工具记录的是每次 mutation 提交后的状态快照它假设同步执行完之后状态就定型了。如果你在 mutation 里写了回调、setTimeout、Promise.then那快照记录下来的就是提交那一刻的状态而此时真正的修改还没发生。调试时看到的状态和实际不符时间旅行也回退不到正确位置。还有一个坑是关于响应式的给对象新增属性要用Vue.set或者用 ES6 的展开运算整体替换给数组按索引赋值同样不会触发更新得用splice或者整体替换。这本质上是 Vue 2 响应式系统的限制不是 Vuex 的锅但因为 Vuex 里状态结构往往更深踩中的概率更高。mutations: { // 错误这样新增的属性不具备响应式 ADD_FIELD(state, { key, value }) { state.form[key] value }, // 正确 ADD_FIELD(state, { key, value }) { state.form { ...state.form, [key]: value } }, // 数组同理 UPDATE_ITEM(state, { index, item }) { state.list.splice(index, 1, item) } }整体替换看起来好像开销大但在绝大多数业务场景下这些对象的体量都很小真正影响性能的是渲染而不是这里多一次浅拷贝。5. Action异步逻辑的收口与副作用管理5.1 Action 和 Mutation 的职责分界线action存在的唯一理由是mutation 必须是同步的而业务里到处是异步。所以 action 的定位很明确它就是那个先出去办事办完回来填单子的角色。action 函数的第一个参数是一个与 store 实例具有相同方法和属性的 context 对象它能拿到commit、dispatch、state、getters、rootStateconst store new Vuex.Store({ actions: { async fetchOrderDetail({ commit, state, getters }, orderId) { if (state.orderDetailCache[orderId]) { commit(SET_ORDER_DETAIL, state.orderDetailCache[orderId]) return } const res await api.getOrderDetail(orderId) commit(SET_ORDER_DETAIL, res.data) } } })注意这里我在 action 里读了state但没有直接写state。这是纪律action 只负责取数、判断、编排写操作一律交给 mutation。有人会觉得这太啰嗦直接在 action 里赋值不也行吗运行起来确实能跑但严格模式会报错而且时间旅行失效。一旦养成这个习惯后面整个调试链路都会废掉。5.2 Action 返回 Promise 带来的编排能力action 默认返回 Promise准确说是它内部执行的结果包成 Promise这是它最好用的特性之一。组件里可以await一个 action等数据落地了再去做跳转、弹提示这些后续动作// 组件里 export default { data() { return { submitting: false } }, methods: { async handleSubmit() { this.submitting true try { await this.$store.dispatch(submitOrder, this.form) this.$message.success(提交成功) this.$router.push(/order/list) } finally { this.submitting false } } } }这里有个细节值得说submitting我放在了组件的 data 里而不是 store。因为它只服务于这一个按钮的 loading 状态别的组件不需要。但如果这个提交动作是从多个入口触发的比如列表页批量提交、详情页单个提交那 loading 就应该提到 store 里用一个orderSubmitting字段统一管理避免两个地方各转各的圈圈用户看着莫名其妙。action 之间也可以互相 dispatch这在组合场景里很有用actions: { async initUserPage({ dispatch }) { await Promise.all([ dispatch(fetchUserList), dispatch(fetchDeptTree), dispatch(fetchRoleOptions) ]) } }用Promise.all并行拉取互不依赖的接口比串行await快很多。这一点在页面初始化慢的时候特别有效我一般会先画个依赖图谁依赖谁的结果有依赖的串行没依赖的并行。这个判断做完首屏时间往往能砍掉一半。5.3 竞态、重复提交和错误边界异步最麻烦的不是请求失败而是请求回来了但不是你想要的那一次。典型场景是搜索框输入联想用户快速输入 abc连着发了三次请求服务端返回顺序不保证最后可能 a 的结果覆盖了 abc 的结果。在 action 里处理竞态我常用的办法是给每次请求打一个时间戳或序号回来的时候比对一下不是最新的就丢弃let requestSeq 0 actions: { async search({ commit }, keyword) { const seq requestSeq const res await api.search(keyword) if (seq ! requestSeq) return commit(SET_SEARCH_RESULT, res.data) } }另外一个常见问题是重复提交。用户手快点两下两次 action 都发出去了接口幂等没做的话就是两条脏数据。前端能做的第一层防护是按钮 loading 置灰第二层是在 action 里做一个进行中标记actions: { async submitOrder({ commit, state }, payload) { if (state.submitting) return commit(SET_SUBMITTING, true) try { const res await api.submit(payload) commit(SET_SUBMIT_RESULT, res.data) } catch (e) { commit(SET_SUBMIT_ERROR, e.message) throw e } finally { commit(SET_SUBMITTING, false) } } }注意catch里我重新throw了。这一点很容易被忽略如果 action 内部把错误吞掉组件里await之后就永远进不了catch用户看不到任何提示。要么在 action 里统一处理比如 commit 一个错误状态由全局组件监听展示要么往外抛让调用方处理二者选一别两边都做一半。6. Module 拆分项目变大之后怎么组织6.1 namespaced 开关与它带来的连锁反应一开始大家都是单文件 store写着写着state里有两百个字段mutations有八十个改一个功能要在一千行的文件里上下翻。这时候就该拆 module 了。拆模块本身很简单麻烦的是namespaced。默认情况下模块的 mutation、action、getter 都注册在全局命名空间里也就是不同模块里同名的 action 会互相覆盖。打开namespaced: true之后调用方式全部要加前缀const user { namespaced: true, state: () ({ info: {}, permissions: [] }), mutations: { SET_INFO(state, info) { state.info info } }, actions: { async fetchInfo({ commit }) { const res await api.getUserInfo() commit(SET_INFO, res.data) } }, getters: { hasPermission: (state) (code) { return state.permissions.includes(code) } } } export default new Vuex.Store({ modules: { user, order, cart } })开了之后调用全部变成dispatch(user/fetchInfo)、mapGetters(user, [hasPermission])。这就是很多人报unknown action type的直接原因模块开了命名空间但调用时忘了加前缀。这个错误信息看起来吓人实际排查就是去对一下模块名和调用名。关于state为什么用函数返回而不是直接写对象这里也顺带说明一下。如果写对象多个地方引用同一个模块对象时会共享同一份 state写成函数每次注册都是新的实例更适合做模块复用和 SSR 场景。养成习惯就好成本几乎为零。6.2 跨模块访问与根状态模块之间难免要互相看一眼。比如订单模块需要用户 ID用户模块需要清空购物车。Vuex 给了几个入口actions: { async createOrder({ commit, rootState, rootGetters, dispatch }, payload) { const userId rootState.user.info.id const canBuy rootGetters[user/hasPermission](order:create) if (!canBuy) throw new Error(无权限) const res await api.createOrder({ ...payload, userId }) commit(ADD_ORDER, res.data) // 第三个参数传 root: true 才能调到别的模块 dispatch(cart/clear, null, { root: true }) } }这里有三个点值得记rootState和rootGetters是 action 上下文里自带的可以直接拿但commit和dispatch要跨模块调用时必须传{ root: true }否则它会默认在当前模块里找找不到就报错。这个参数位置在 dispatch 的第三个参数很容易漏。我个人的经验是跨模块的直接访问要尽量少。一旦 A 模块频繁读 B 模块的 state说明这两个模块的业务边界没划清楚可能应该合并或者把公用部分抽成一个独立的 common 模块。模块拆分的目标是降低耦合不是把一个大文件切成五个互相引用的小文件。6.3 动态注册模块的适用场景还有一种进阶用法是registerModule运行时动态挂载模块store.registerModule(orderDetail, orderDetailModule) // 用完记得卸载否则会一直占着内存 store.unregisterModule(orderDetail)这个语法在实际业务里用得不多但有两个场景特别合适。一是超大表单或复杂详情页只在进入那个路由时才注册离开就卸载能显著减少初始化的 state 体积。二是同一份模块要复用多个实例比如同时开多个编辑器窗口每个窗口一个模块实例。不过要提醒一句动态注册的模块在调试工具里的表现不如静态模块直观卸载之后历史记录里还会残留。如果只是图代码分文件用普通的静态模块就够了别为了这点结构上的优雅引入额外的复杂度。7. 刷新丢数据、v-model 报错与性能三个上线前必须确认的事7.1 页面刷新之后状态全没了怎么办这是新人必问的问题为什么一按 F5登录信息和购物车就没了答案很朴素Vuex 的 state 存在内存里刷新等于重新初始化。想持久化就得自己往localStorage或sessionStorage里存一份初始化的时候再读回来。最简单的做法是在创建 store 时读一次在 mutation 订阅里存一次const store new Vuex.Store({ ... }) // 初始化时恢复 const cached localStorage.getItem(vuex-cache) if (cached) { store.replaceState(Object.assign({}, store.state, JSON.parse(cached))) } // 订阅 mutation每次变更后写回 store.subscribe((mutation, state) { localStorage.setItem(vuex-cache, JSON.stringify(state)) })这段代码跑起来没问题但直接照抄会踩坑。第一个坑是全量存储state 里如果有列表数据、字典缓存序列化开销很大写入频率又高会让页面明显卡顿。解决办法是只存需要持久化的字段要么在订阅里挑字段要么把持久化的部分单独放一个模块只序列化那一个模块。第二个坑是敏感信息。登录 token、用户手机号这些不适合明文放在localStorage里容易被人翻到。这类数据的常规处理是只把 token 放进带 httpOnly 的 cookie 由服务端管前端 store 里只保留非敏感的展示字段。这一点在评审时经常被漏掉值得单独提一下。第三个坑是版本兼容。用户浏览器里存的是上个版本的 state 结构你新版本加了个必填字段读回来是undefined页面直接白屏。稳妥的做法是存的时候带一个版本号读的时候版本对不上就丢弃。7.2 v-model 直接绑 state 的那个报错v-model绑到this.$store.state.form.name上控制台会提示不要在 mutation 之外修改 state。原因前面说过v-model本质是双向绑定输入的时候会直接给 state 赋值。标准解法是用带 getter 和 setter 的 computedexport default { computed: { userName: { get() { return this.$store.state.user.info.name }, set(val) { this.$store.commit(user/SET_INFO, { name: val }) } } } }如果表单项很多一个个写太啰嗦可以封装一个通用的辅助函数或者在项目里约定表单数据先放组件本地提交时再一次性 commit。后一种做法我更推荐因为表单在编辑过程中会产生大量中间态全塞进 store 会让 mutation 记录变得极其冗长调试工具里翻半天找不到关键操作。7.3 大对象映射与渲染性能最后一个容易被忽略的点是mapState的粒度。有人图省事直接...mapState([orderList, userList, dictMap])把整个大数组映射进组件。这本身没错但如果在模板里对数组做了复杂运算过滤、排序、分组每次任何 state 变化都会触发重算。处理办法是把运算挪到 getter 里让缓存挡住大部分重复计算或者在组件里用 computed 包一层确保依赖精确。再狠一点的做法是分页或虚拟滚动别一次渲染几千条。我在一个明细表页面里做过对比三千条数据全量渲染滚动时掉帧明显改成 getter 里先算好、模板只做展示之后卡顿基本消失。还有一个排查技巧如果发现某个页面明明没改数据却频繁重渲染可以在watch里挂一个$store.state的深度监听看看是哪个字段在变。有时候罪魁祸首是一个每秒刷新的时间戳字段放在 store 里导致整个组件树跟着抖。8. 报错排查的固定链路从现象往回倒推把常见问题按现象归一下类遇到的时候照着走比漫无目的地翻代码快得多。现象最可能的原因排查动作unknown action type: xxx模块开了 namespaced 但调用没加前缀或模块压根没注册检查 dispatch 字符串与模块名确认 modules 里有这个 keyunknown mutation type同上另外注意常量拼写全局搜 mutation 名比对大小写页面不更新但 state 里值变了新增属性没走 Vue.set数组按索引赋值检查 mutation 里是否有obj.newKey x或arr[i] x提示不要在 mutation 外修改 statev-model 直绑、组件里直接赋值、异步回调里赋值全局搜$store.state.后面的赋值语句getter 拿到的是旧值getter 返回函数导致缓存失效依赖字段本身没变确认 getter 里依赖的字段引用是否被整体替换刷新后数据丢失未做持久化检查是否有 subscribe 写回逻辑版本号是否匹配提交了但接口没发出去action 里 return 前有分支提前退出或者被 loading 标记挡了在 action 首尾打断点确认执行路径排查顺序我一般是这样先看调试工具的操作记录确认 mutation 到底有没有被提交有记录但视图没变问题在响应式没记录问题在调用链dispatch 名、模块、守卫条件。这个顺序能把范围快速缩到一半以内比从头读代码高效得多。还有一类问题不在 Vuex 本身而在生命周期时序。比如在created里 dispatch 一个异步 action然后在同一个created里立刻读$store.state拿结果那必然是空的。异步就是异步得await或者在watch里等。我在项目里统一约定凡是需要依赖 store 数据的初始化逻辑一律写在 async 函数里await不在同步代码里碰运气。最后分享一个实践中的体会Vuex 用得顺不顺八成取决于前期有没有把哪些数据进 store这条线划清楚。线划好了五个概念各司其职代码像流水账一样好读线没划好store 变成垃圾场再多的规范也救不回来。至于新项目要不要上更轻的方案那是另一个话题但如果团队已经在用 Vuex把它用对、用干净收益是立刻能感受到的。