多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Vuex五配置项详解:State、Mutations、Actions、Getters与Modules职责边界实战

Vuex五配置项详解:State、Mutations、Actions、Getters与Modules职责边界实战 干了几年前端看过太多人把Vuex用成了“全局对象”state里放一堆数据组件里随手this.$store.state.xxx ...甚至有人分不清actions和mutations到底哪个该写异步逻辑。直到后端同事看了代码问了一句“你们前端不是有状态管理吗怎么状态全靠脑补”我才意识到很多人对Vuex的理解停留在“持久化数据的盒子”这个层面。state、mutations、actions、getters、modules这五个配置项正是Vuex实现“可预测状态管理”的骨架。它们的区别不是API写法不同而是职责边界不同。这篇文章我会从“为什么这样设计”出发把每个配置项的实际使用场景、容易踩的坑、以及它们之间如何配合讲清楚。无论你是刚接触Vuex还是已经用了一段时间但总感觉哪里不对都可以从里面找到答案。1. 为什么Vuex要把一个Store拆成五张“职能牌”1.1 一个反直觉的事实配置项多不是复杂度是边界第一次看Vuex文档的时候大多数人心里都会冒出同一个疑问不就是存个全局数据吗搞state、getters、mutations、actions这么多层干什么直接this.$store.xxx读写不香吗这个问题的答案要回到状态管理的本质目标上看。如果把应用里的数据比作仓库里的货物那么Vuex不只是给你一个仓库还给你一套仓库管理制度。state是货架getters是仓库的出货清单根据货架上的物品实时计算出来的汇总表mutations是唯一的收货登记窗口每一笔入库都要在这里记录actions是采购/调拨流程先去供应商那拿货、验货最后才拿到登记窗口入账modules则是仓库的隔间划分——不同商品分类放到不同区域。这套制度确实比“把东西堆在大厅”复杂但它换来的是状态的每一次变化都可追踪、可复现、可调试。Vue DevTools里的时间旅行、状态快照依赖的就是这些边界。如果你跳过了mutations直接改state等于绕过了仓库的登记窗口货虽然变了但账本上没有记录回放和排查自然无从谈起。1.2 单向数据流的骨架从“谁能改数据”开始Vuex的整个设计都围绕单向数据流展开组件通过dispatch触发actionsactions做完异步逻辑后通过commit调用mutationsmutations同步修改statestate的变化再驱动视图更新。很多人在写代码时会把顺序记反比如在组件里直接this.$store.state.count或者在一个mutation里写setTimeout。这些做法的共同问题是打破了Vuex预设的“谁负责什么”的规则state只负责“是什么”不负责“怎么变”mutations只负责“把状态改成指定值”不负责“什么时候改”actions只负责“先做哪些事”最终仍然回到mutationsgetters只负责“从已有状态推导出需要展示的数据”不负责修改状态modules只负责“把上面这些按业务域分组”不改变单个配置项的规则。把这套职责想清楚后面所有的代码都是顺着这个脉络展开的。2. state唯一数据源和它身上的“响应式规则”2.1 state并不是普通对象它的每个字段都参与了响应式系统state是Vuex Store中的数据“唯一真相源”。组件里通过mapState映射过来的字段本质上和Vue组件data里的字段一样满足响应式规则。这句话听起来简单实操中却最容易出问题。先看一个典型的Vue 2项目案例// store/index.js export default new Vuex.Store({ state: { userInfo: { name: 张三, age: 18 }, permissions: [] } })如果在某个组件里做这样的操作this.$store.state.userInfo.gender 男在Vue 2的响应式系统里gender这个新字段并不会变成响应式。页面绑定userInfo.gender不会自动更新除非你有别的地方触发了重新渲染或者用Vue.set手动添加。Vue 3用Proxy重写了响应式动态新增属性可以识别但Vuex的设计习惯依然建议所有状态字段在初始化时就声明清楚哪怕初始值是null或空数组。这个习惯的好处有两个一是状态结构一眼可见团队成员看state就能知道这个模块管理了哪些数据二是避免“字段明明存在但不响应”这类隐蔽问题。尤其是权限列表、下拉选项这类异步加载后要填充到界面上的数据提前声明permissions: []后面commit时直接替换整个数组完全不用担心响应式丢失。2.2 在严格模式下绕开mutations改state会直接报错既然state是唯一数据源那修改它就必须有统一入口。Vuex提供了strict选项const store new Vuex.Store({ strict: process.env.NODE_ENV ! production, state: { count: 0 }, mutations: { increment(state) { state.count } } })开发环境开启严格模式后如果你在组件里直接this.$store.state.count控制台会报错[vuex] Do not mutate vuex store state outside mutation handlers.这个报错不是针对“修改状态”本身而是针对“修改状态的时机不可控”。生产环境关闭严格模式主要是性能考虑。严格模式会在每次状态变更后深度监听整个state树数据量大的时候有额外开销。但要注意关闭不意味着你可以随便直接改state只是Vuex不再帮你拦截。团队规范里仍然应该约定所有状态的修改必须走mutation。2.3 mapState的两种用法以及模块化之后的访问路径组件里读取state最原始的方式是this.$store.state.xxx。全局没开modules时还好说一旦用了带命名空间的模块路径会变长所以Vuex提供了mapStateimport { mapState } from vuex export default { computed: { ...mapState({ userName: state state.user.name, count: count }) } }如果当前组件只关注某个模块可以配合模块名访问...mapState(user, [name, age])这个写法的前提是user模块开启了namespaced: true。关于命名空间的细节我会在第6部分展开但这里可以先记住state永远只存“原始数据”不存“计算后的显示文案”。所有展示需要的格式化、筛选、统计逻辑都放到getters里。3. mutations同步修改的唯一合法通道3.1 mutation的函数签名与type常量管理mutations里每个方法接收两个参数第一个是模块的局部state第二个是调用方传来的payload。看一个最常见的例子mutations: { setUserInfo(state, payload) { state.userInfo payload }, increment(state, amount 1) { state.count amount } }组件里通过commit触发this.$store.commit(setUserInfo, { name: 李四 }) this.$store.commit(increment, 1)以前的工程里很多人喜欢直接写字符串setUserInfo项目大了以后拼错一个字符根本不会报错只会让你想改的状态静默失效。所以我建议把mutation的type抽成常量集中放在一个文件里// mutation-types.js export const SET_USER_INFO SET_USER_INFO export const INCREMENT INCREMENT然后在store和组件里都引用常量import { INCREMENT } from ./mutation-types mutations: { [INCREMENT](state, amount) { state.count amount } } // 组件 this.$store.commit(INCREMENT, 1)这样做之后IDE能识别引用关系重构时也更容易。团队里如果已经有规范化工具链还可以通过eslint规则强制不允许直接使用字符串type。3.2 mutation必须同步这是devtools能“时间旅行”的前提Vuex官方文档反复强调mutation必须是同步函数。这不是写代码的风格问题而是功能依赖问题。Vue DevTools在记录状态变更时会记录每一次mutation的before和after快照。如果mutation内部有setTimeout、await这类异步操作devtools无法确定这个mutation“什么时候才算执行完”时间旅行回放时也会乱套。举个很直观的例子mutations: { setData(state) { setTimeout(() { state.data 异步修改 }, 1000) } }你点下commit的一瞬间devtools以为状态已经改完了但实际上state.data一秒后才变。这时你如果回放到这条mutation之前的状态就会看到“回放没有生效”的诡异现象。而实际上带着异步逻辑的代码很可能在回放时触发了另一个定时器把状态又改了回来。所以Vuex把“修改状态”和“异步任务”拆成两个角色mutations只负责一件事——同步地把state改成目标值。至于这个目标值是API返回的、用户输入的、还是本地计算出来的都属于actions的职责范围。3.3 commit的两种风格type payload 或对象风格commit调用mutation的写法有两种在团队里选一种并保持一致就好// 风格一type、payload分离 this.$store.commit(increment, 1) // 风格二提交一个对象 this.$store.commit({ type: increment, amount: 1 })第二种写法在提交多个参数时优势明显因为payload本身就是对象不需要额外定义多个参数。mutation里这样接收mutations: { increment(state, payload) { state.count payload.amount } }我在实际项目中更推荐对象风格。原因是当payload字段多了以后第一种写法的参数顺序容易搞混而且代码review时一眼扫到{ type: increment, amount: 1 }能直接看出这个mutation要干什么。当然这不是硬性规则重点是团队统一。4. actions异步业务逻辑的调度中心4.1 action接收的不是state而是整个contextactions里每个方法接收一个context对象这个对象包含state、rootState、commit、dispatch、getters、rootGetters。很多人第一次看到context会懵直接给一个state不行吗为什么给这么多东西因为action是一个“业务流程的编排者”它可能要读取当前模块的state也可能要读其他模块的state还要触发多个mutation甚至触发其他action所以Vuex直接把“整个store的上下文”都交给你。写起来是这样的actions: { async fetchUser({ commit, state }, userId) { // 可以做前置判断 if (state.loading) return commit(setLoading, true) try { const { data } await api.getUser(userId) commit(setUserInfo, data) } finally { commit(setLoading, false) } } }组件里不再直接调API而是this.$store.dispatch(fetchUser, 123)。这样做的最大好处是组件不需要知道数据是怎么来的也不需要知道状态是怎么改的。它只需要分发一个“用户希望发生的事”剩下的流程全部收敛在action里。4.2 “异步放action同步改状态放mutation”到底是什么意思这条规则听起来简单实操时总有人混淆。我一般用一句话区分action负责“什么时候改”mutation负责“改成什么”。比如一个登录流程组件分发loginactionaction里调用登录接口等待返回接口成功以后actioncommit一个setTokenmutationmutation把state.token更新为接口返回的token。这里的“等待接口返回”就是“什么时候能改”的决策过程放在action里而“把token写到state里”是“改成什么”交给mutation。如果反过来在mutation里调接口一旦接口需要时间devtools就失控了如果让组件自己等接口回来后直接改state又绕开了action的统一入口同样的登录逻辑就没法在多个组件里复用。有一种说法是“没有异步逻辑的action是多余的”这个我不完全同意。action还可以承载“多步骤状态流转”、权限校验、埋点上报等职责。比如切换一个Tab可能需要先记录埋点再提交两个mutation这种流程就算没有异步放在action里也能让组件代码保持干净。4.3 项目里action粒度的两种流派我建议这么选关于action要不要包裹无异步的mutation社区里其实有两种做法严谨流派组件不直接commit一律通过dispatch抛action。好处是状态入口只有一个逻辑统一适合团队多人协作也方便以后在action里增加埋点或权限逻辑。轻量流派只有异步或有业务流程时才用action单纯修改一个字段就组件直接commit。好处是代码量更少适合小项目或状态变更点特别少的场景。我个人的建议是除非项目只有两三个页面否则优先选严谨流派。为什么因为一旦团队里允许“某些情况下直接commit”就会出现一半action、一半直接commit的情况新人根本搞不清什么该走哪条路。我自己在项目中固定一套约定组件永远不直接commit只dispatch actionaction是唯一允许进行异步操作的地方mutation里只允许同步code。这样约定以后虽然多写了一层action但换来的是状态流高度可预测任何状态变更都可以从action出发把整个调用链串起来。排查Bug时看action日志就能还原“用户做了什么、系统请求了什么、状态改了什么”。5. getters派生状态与复用逻辑的计算层5.1 getter和直接读state的核心区别缓存与依赖追踪getters类似Vue组件里的computed它根据state推导出新的数据。最常见的场景是“购物车里已选商品的总价”getters: { selectedTotal(state) { return state.cartItems .filter(item item.selected) .reduce((sum, item) sum item.price * item.count, 0) } }组件里可以这样读取const total this.$store.getters.selectedTotal有人会觉得“那我直接在组件里写个方法传this.$store.state.cartItems进去计算不就行了吗”技术上确实可行但有一个重要差异组件的普通方法每次渲染都会重新执行getter则会在依赖的state变化时才重新计算多次访问同一getter时结果会缓存。这个差异在计算复杂列表、大型筛选场景下非常明显。一个几千条的列表每次渲染都重新遍历一遍做筛选和只在你修改筛选条件时遍历一次性能差距不是一点点。5.2 getter返回函数可以实现传参但代价是失去缓存有些场景需要给getter传参数例如“根据ID查一条订单”getters: { getOrderById: (state) (id) { return state.orders.find(order order.id id) } }组件里用起来很顺手const order this.$store.getters.getOrderById(1001)但要注意这种“返回函数”的getter本质上是每次访问都生成一个新函数所以它没有缓存效果。也就是说但凡这个getter在模板里被频繁调用或者依赖的列表特别大性能就可能比普通getter差。如果你的场景是“同一批数据传不同参数多次访问”也可以在getter内部维护一个Map做手动缓存但绝大多数项目里没必要那么做直接用就行。清楚这个差异不要以为所有getter都有缓存就够了。5.3 开启命名空间后getters的读取路径更清晰在模块中使用namespaced: true后访问模块getter需要带模块前缀// 模块定义 const userModule { namespaced: true, state: { name: }, getters: { upperName(state) { return state.name.toUpperCase() } } } // 组件里 this.$store.getters[user/upperName]如果觉得字符串路径太长可以用mapGettersimport { mapGetters } from vuex computed: { ...mapGetters(user, [upperName]) }这里有个容易踩的坑模块getter内访问根模块state或getter需要在getter定义里增加额外参数顺序是state, getters, rootState, rootGettersgetters: { usernameWithWelcome(state, getters, rootState) { return ${rootState.app.welcomePrefix} ${state.name} } }很多初学者只定义了一个参数下面却想用rootState得到的自然是undefined。这个参数签名是Vuex的约定写多了自然就记住了。6. modules规模变大后状态必须“分家”6.1 模块划分的正确维度按业务域而不是按页面当项目规模变大如果所有状态都放在根store里state树会变成一个大杂烩组件之间的依赖关系也越来越模糊。modules的价值就是“分而治之”。最常见的模块划分方案是按业务域拆分user、cart、order、product。这跟后端微服务的划分思路类似。很多人喜欢按页面拆比如home、detail、mine。但页面是经常变化的同一个业务域可能被多个页面复用按页面拆会导致状态被重复存储页面A和页面B之间的数据同步也得靠外部协调得不偿失。一个比较科学的目录结构大概是src/store/ ├── index.js ├── modules/ │ ├── user.js │ ├── cart.js │ └── order.js入口文件里这样注册import userModule from ./modules/user import cartModule from ./modules/cart import orderModule from ./modules/order const store new Vuex.Store({ modules: { user: userModule, cart: cartModule, order: orderModule } })每个模块内部仍然是标准的五件套const cartModule { namespaced: true, state: {}, mutations: {}, actions: {}, getters: {} }6.2 namespaced: true 到底改了哪些规则namespaced是Vuex模块化的一个关键开关。开启后模块内部的getters、mutations、actions会以模块名为前缀注册到全局Store上关闭时则直接挂到根命名空间。对比关系如下表场景未开启 namespaced开启 namespaced读取state$store.state.cart.xxx$store.state.cart.xxx触发mutationcommit(setCount)commit(cart/setCount)触发actiondispatch(fetchCart)dispatch(cart/fetchCart)读取getter$store.getters.total$store.getters[cart/total]同名action/mutation可能冲突后注册的覆盖先注册的不会冲突带前缀区分一个设计不合理的坑是如果模块没开namespaced两个模块里各自有一个updatemutation后注册的模块会覆盖前者。我见过一次线上Bug就是因为两个模块定义了同名action结果业务调用时总走到另一个模块的逻辑排查了很长时间。打开namespaced后前缀自然把模块隔离开同类问题基本绝迹。所以我的个人习惯是只要用了modules就一律给每个模块加上namespaced: true即使这个模块很小。这样后续模块扩张时不会因为命名冲突产生历史债。6.3 模块之间互相访问三种常用姿势模块拆分以后不可避免会有模块间协作。比如登录后要从user模块拿用户ID去调cart模块的接口。有几个常用方法方式一在action里读取其他模块的state或getter// modules/cart.js actions: { fetchCart({ rootState, commit }) { const userId rootState.user.userId const token rootGetters[user/token] // 请求接口后commit } }方式二调用其他模块的action// modules/order.js actions: { createOrder({ dispatch }, orderData) { // 先更新购物车状态再创建订单 dispatch(cart/clearCart, null, { root: true }) // ... } }方式三调用其他模块的mutationcommit(cart/setSelectedAll, true, { root: true })注意dispatch第三个参数{ root: true }表示“以根命名空间来定位目标模块”否则默认只会找当前模块下的同名action。这个细节很容易漏掉漏掉之后如果没有同名action会直接报错如果有同名action还会产生诡异的跨模块调用。所以模块间交互代码一定要写得显式一些最好加注释说明为什么需要跨模块。7. 核心配置项对比与三条实战心得7.1 一张表理清五个配置项为了避免各种概念最后在脑子里缠成一团我把Vuex五个核心配置项整理成一张对比表方便你直接做工作笔记配置项核心职责如何触发/使用同步/异步典型场景state存储原始数据唯一数据源组件中读取$store.state.xxx同步用户信息、列表数据、加载状态getters从state中派生并缓存计算后的数据组件读取$store.getters.xxx同步筛选列表、总价、格式化后的字符串mutations同步修改state的唯一合法通道组件或action调用commit(type, payload)只能同步给state赋值、切换状态、替换数组actions编排业务流程处理异步逻辑组件调用dispatch(type, payload)可异步请求API、多步骤状态流转、权限校验modules将state、mutations、actions、getters按业务域拆分Store构造选项中注册无特殊限制大型项目状态分模块、避免命名冲突这张表最核心的记忆线索是state是账本getters是报表mutations是记账员actions是业务流程modules是账本分区。7.2 我踩过的三个坑值得你先避开第一个坑在严格模式下用异步mutation。开发环境开启strict后在mutation里写了setTimeout修改state直接报错。当时我还以为是Vuex版本问题后来查文档才意识到“mutation必须同步”是在保护状态流的可控性不是随口说说的规范。第二个坑模块没加namespaced两个模块的action重名。项目里当时有两个业务模块都叫fetchDetail仓库代码合并后其中一个模块的接口永远打不通。最后用Vue DevTools跑了一遍action日志才发现是覆盖导致。给所有模块加上namespaced: true后这种问题从根上消失了。第三个坑在store模块文件里直接import store from ./index。这样容易形成循环依赖尤其在模块之间互相调用时初始化顺序一旦不对store会是undefined。更稳妥的做法是让页面组件里通过this.$store访问或者在模块内部通过rootState和rootGetters访问其他模块而不是反向import整个store。7.3 如果项目还很小我的建议是……看到这里你可能有个疑虑这套五配置项的分工确实清晰但如果我的项目只有两个页面也要硬上Vuex吗我的答案是不一定。Vuex本身是为中大型项目和复杂数据流设计的。如果项目状态非常少用组件的provide/inject加reactive甚至直接Pinia都更轻量。可一旦你决定使用Vuex我建议从一开始就按前面说的职责边界执行而不是抱着“先随便写写等复杂了再规范”的心态。原因很简单规范是越早建立越容易坚持等代码已经铺开根本不敢轻易改数据流。如果你已经在用Vuex却一直觉得它很“重”回头看看代码里有多少“组件里直接改state”的情况。很多时候不是Vuex重而是我们没让它的几个配置项各司其职。把state、mutations、actions、getters、modules当成五个角色而不是五堆API状态管理自然会顺很多。
返回列表