干了几年前端,看过太多人把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触发actions,actions做完异步逻辑后通过commit调用mutations,mutations同步修改state,state的变化再驱动视图更新。
很多人在写代码时会把顺序记反,比如在组件里直接this.$store.state.count++,或者在一个mutation里写setTimeout。这些做法的共同问题,是打破了Vuex预设的“谁负责什么”的规则:
state只负责“是什么”,不负责“怎么变”;mutations只负责“把状态改成指定值”,不负责“什么时候改”;actions只负责“先做哪些事”,最终仍然回到mutations;getters只负责“从已有状态推导出需要展示的数据”,不负责修改状态;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提供了mapState:
import { 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,而是整个context
actions里每个方法接收一个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负责“改成什么”。
比如一个登录流程:
- 组件分发
loginaction; - action里调用登录接口,等待返回;
- 接口成功以后,action
commit一个setTokenmutation; - mutation把
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 action;action是唯一允许进行异步操作的地方,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']如果觉得字符串路径太长,可以用mapGetters:
import { mapGetters } from 'vuex' computed: { ...mapGetters('user', ['upperName']) }这里有个容易踩的坑:模块getter内访问根模块state或getter,需要在getter定义里增加额外参数,顺序是state, getters, rootState, rootGetters:
getters: { 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 |
| 触发mutation | commit('setCount') | commit('cart/setCount') |
| 触发action | dispatch('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 }) // ... } }方式三,调用其他模块的mutation:
commit('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,状态管理自然会顺很多。