今天是我这个Vue学习计划第三阶段的第43天,如果让我挑这一天里最值得单独写一篇的东西,那一定是Vuex Module。前面几周我已经用单一Store写了好几个练习项目,功能倒是都跑通了,但随着页面变多、状态变杂,每次往store/index.js里追加东西都有一种往衣柜里硬塞被子的感觉。这节内容我研究得比较透,从为什么拆、怎么拆、拆完怎么调用到实际踩过的坑,整理出一篇能直接照着做的记录,给同样卡在Vuex模块化这块的朋友省点时间。
1. 从大一统Store到分柜管理:什么信号出现后就必须拆模块
1.1 单一Store的演变过程
我学习Vuex的过程大概分三个阶段,刚开始写todo应用,Store里一共就两三个state和一个mutation,全靠根Store就非常舒服。等到第二个阶段做音乐列表播放器时,state开始多了:用户信息、播放列表、当前歌曲、搜索历史、喜欢列表,这时候根Store已经开始有点臃肿,但还能忍受,因为每个mutation和action的名字都还比较长且有辨识度。
转折点是在练习某商城项目的时候,这个模拟项目X里需要同时维护用户登录态、商品列表、购物车、订单状态、全局UI控制,我把它们全部塞进根Store后出现了几个非常难受的症状:
- 每次改一个功能都要从几百行的store文件里找到底改了哪里,上下文全靠
Ctrl+F。 - 团队成员各自维护不同的业务模块,却在同一个store文件里疯狂引发git合并冲突。
- 为了避开命名冲突,我给mutation起名时开始出现
setUserInfoAndCart这种一看就是妥协品的名字。
第三个阶段我痛定思痛,把这些状态按业务域拆进Vuex Module里,整个store文件的体积一下子从500多行降到几十行,每个模块文件都在100行上下,查代码时的体验完全是两个世界。
1.2 什么时候拆比较合适
拆模块这件事不是越早越好。一开始状态很少时强行拆出十几个模块文件,反而会让人觉得管理成本陡增。根据我自己的经验,出现下面任何一个信号就可以动手了:
- 根Store里的state数量超过15个,且它们可以明显分成两个以上的业务域。
- mutations里开始出现前缀重复的方法名,说明命名冲突已经到了必须回避的地步。
- 一个actions文件里同时处理用户登录、购物车刷新、全局loading这三种互相不沾边的业务。
- 多人协作时,同一个store文件的冲突频率已经影响开发效率。
我见过一种错误的拆法:按页面拆模块,每个路由对应一个Vuex模块文件,结果不同页面之间重复使用的状态散落一地,还得来回跨模块取。正确的思路是按业务域拆分,比如用户、商品、购物车、订单、全局UI,页面只是一个消费这些状态的壳。页面会变,业务不会,这一点越早想通,后面重构越少。
2. Vuex Module的语法骨架与推荐目录设计
2.1 模块文件的基本结构
Vuex Module的语法并不复杂,一个模块文件本质上是把原来写在根Store里的state、getters、mutations、actions按业务域装进一个对象,然后加上一个namespaced开关。以购物车模块为例,我习惯在src/store/modules/下建文件:
// src/store/modules/cart.js const state = () => ({ items: [], totalCount: 0, totalPrice: 0 }) const mutations = { SET_ITEMS(state, payload) { state.items = payload }, SET_TOTAL_COUNT(state, count) { state.totalCount = count } } const actions = { async fetchCart({ commit }) { const data = await MyService.getCart() commit('SET_ITEMS', data.items) commit('SET_TOTAL_COUNT', data.items.length) } } const getters = { cartCount: (state) => state.totalCount } export default { namespaced: true, state, mutations, actions, getters }这里有一个很多人会忽略的细节:模块里的state我建议写成函数返回对象,而不是直接写对象字面量。这一点和组件里data写成函数的原因是一样的,就是防止状态对象在多个地方被意外共享,导致互相污染。之前我用普通对象写模块state,某天在单元测试里发现两个测试用例竟然共用同一个state引用,一个用例改了数据另一个用例也跟着变了,排查了大半天才发现是这个原因。改成函数返回后问题立刻消失。
2.2 模块注册与目录组织
模块文件写好后,在根Store里通过modules字段注册:
// src/store/index.js import { createStore } from 'vuex' import cart from './modules/cart' import user from './modules/user' import product from './modules/product' export default createStore({ strict: process.env.NODE_ENV !== 'production', modules: { cart, user, product } })关于目录组织的经验,经过这么几个项目的折腾,我现在固定用这样的结构:
src/store/ ├── index.js # 根Store,只做模块注册 ├── plugins/ # 需要扩展功能时放插件 └── modules/ ├── cart.js ├── user.js ├── product.js └── app.js # 全局UI状态:loading、sidebar、toast等根Store里只保留跨模块共享的极少量状态,比如整个应用的版本号、是否处于移动端适配模式这类信息。凡是能归属到具体业务域的状态都不要留在根里,这是模块化拆分的第一条纪律。
strict模式在开发环境打开非常有必要,它会在状态被非mutation方式修改时直接在控制台报警,帮助我们第一时间发现“绕过规则”的代码。上线前关掉即可,因为严格模式会深度监听整个状态树,性能上会有一定开销。
3. namespaced是模块化的分水岭:开关决定的调用方式
3.1 不开启namespaced时的混乱
Vuex Module虽然提供模块化隔离,但如果模块里不写namespaced: true,模块里的getters、mutations、actions默认还是注册到全局命名空间下。这意味着什么呢?
- 两个模块里定义了同名getter,后注册的那个会覆盖先注册的,页面里取到的结果完全取决于注册顺序。
- 两个模块里定义了同名mutation,它们不会互相覆盖,而是会按顺序全部执行。也就是说,你
commit('SET_ITEMS')时,购物车模块的SET_ITEMS会执行,另一个模块里恰好也叫SET_ITEMS的mutation也会跟着执行。
这两个后果一个比一个隐蔽,尤其是同名mutation全部触发这种,线上出问题的时候根本想不到是模块没加namespaced导致的。我某次在商城练习项目里新增了一个活动模块,里面恰好有一个SET_LISTmutation,结果原本商品模块的SET_LIST被一起调用,商品列表和活动列表同时被改成了同一个数据源。排查链路很长,最后定位到原因时多少有点哭笑不得。
所以我的建议一句话说死:项目中所有模块一律显式开启namespaced: true,这不是可选项,而是必须项。
3.2 开启之后的调用路径变化
开启namespaced: true后,模块内的getters、mutations、actions都会带上模块路径作为前缀,调用方式如下:
| 操作 | 无命名空间(不推荐) | 有命名空间(推荐) |
|---|---|---|
| commit mutation | commit('SET_ITEMS') | commit('cart/SET_ITEMS') |
| dispatch action | dispatch('fetchCart') | dispatch('cart/fetchCart') |
| 读取 state | $store.state.cart.items | 同样写法不变 |
| mapState | mapState(['cartItems']) | mapState('cart', ['items']) |
需要特别注意的是,state在模块化之后反而是不受命名空间影响的,它始终是挂在store.state.模块名下面,这个行为比较容易记住。
开启命名空间后,模块内部访问自己的mutation和action都不需要加前缀,因为Vuex会默认在当前模块里查找。但如果你想在模块里访问根Store或其他模块的状态,需要用到getters的额外参数:
const getters = { // state是模块自己的,getters是模块自己的,rootState是根Store状态,rootGetters是根及所有模块的getters computedProductName: (state, getters, rootState, rootGetters) => { return rootState.user.locale === 'en' ? getters.productNameEn : state.productName } }相应地在actions里,如果想要commit一个根Store的mutation,需要传第三参数:
const actions = { async init({ commit }) { // 提交根Store的全局loading mutation commit('SET_GLOBAL_LOADING', true, { root: true }) // 提交当前模块的mutation commit('SET_READY', true) } }这个{ root: true }是模块化开发里使用频率非常高的一个选项,尤其是做全局loading控制、统一错误提示这类跨模块操作时,几乎每次都会用到。
4. 实战拆解:某商城练习项目中按业务域组织状态模块
4.1 user模块:登录态与用户资料的收拢
光讲语法比较抽象,我直接拿自己在练习里跑通的某个商城demo来拆解。先看用户模块,它负责登录、退出、获取用户资料这些动作。原始的单Store写法会把用户相关的state、persist逻辑和购物车的混在一起,现在拆到独立文件里就非常清晰:
// src/store/modules/user.js const state = () => ({ token: '', profile: { id: null, nickname: '', avatar: '' } }) const mutations = { SET_TOKEN(state, token) { state.token = token }, SET_PROFILE(state, profile) { state.profile = profile } } const actions = { async login({ commit }, { username, password }) { // 模拟调用登录接口 const res = await FakeAuthApi.login(username, password) commit('SET_TOKEN', res.token) commit('SET_PROFILE', res.userInfo) return res }, async logout({ commit }) { commit('SET_TOKEN', '') commit('SET_PROFILE', { id: null, nickname: '', avatar: '' }) }, async fetchProfile({ commit }) { const profile = await FakeUserApi.getCurrent() commit('SET_PROFILE', profile) } } const getters = { isLogin: (state) => Boolean(state.token), nickname: (state) => state.profile.nickname || state.profile.mobile } export default { namespaced: true, state, mutations, actions, getters }这里的命名空间是user,所以组件里登录就是dispatch('user/login', form),取登录状态就是mapGetters('user', ['isLogin'])。逻辑上用户模块与其他业务完全不耦合,后面做任何改动都只需要动这个文件。
关于token持久化,我在早期模块化时犯过一个错:直接在模块的state初始化里读localStorage,比如token: localStorage.getItem('token') || ''。这个做法初看没问题,但在服务端渲染或测试环境下localStorage根本不存在的,会直接报错。后来我把持久化逻辑抽到了独立插件里,只在mutation发生时同步写localStorage,这是更稳妥的做法。
4.2 cart模块:异步流、本地持久化与Getter简化
购物车模块比用户模块稍微复杂一点,它涉及异步获取接口数据、本地修改、以及派生状态的计算。先看核心代码:
// src/store/modules/cart.js const state = () => ({ items: [], lastUpdated: null }) const mutations = { SET_ITEMS(state, items) { state.items = items state.lastUpdated = Date.now() }, ADD_ITEM(state, item) { const existing = state.items.find((i) => i.productId === item.productId) if (existing) { existing.count += item.count } else { state.items.push({ ...item, count: item.count }) } }, REMOVE_ITEM(state, productId) { state.items = state.items.filter((i) => i.productId !== productId) }, CLEAR(state) { state.items = [] } } const actions = { async fetchCart({ commit }) { const data = await FakeCartApi.fetch() commit('SET_ITEMS', data) }, async addToCart({ commit }, item) { // 这里先做本地乐观更新,再异步同步服务器,最后用服务器结果校正 commit('ADD_ITEM', item) try { const synced = await FakeCartApi.syncItem(item) commit('SET_ITEMS', synced) } catch (e) { // 同步失败时至少要把错误抛出让上层提示用户 throw new Error('sync cart failed') } } } const getters = { totalCount: (state) => state.items.reduce((sum, i) => sum + i.count, 0), totalPrice: (state) => { return state.items.reduce((sum, i) => sum + i.count * i.price, 0) } } export default { namespaced: true, state, mutations, actions, getters }这个模块里有一个经验值得细说。totalPrice这类依赖多条件循环计算的派生状态,我一开始傻乎乎地把它直接放在state里,每次调用接口后手动commit最新价格,后来发现这种方式极易出现状态不同步。你改了购物车数量却忘了更新总价,页面上显示的价格就是错的。正确的做法是用getters去计算,它天然依赖state中的基础数据,state一变,getters自动重新计算,不存在手动同步这种问题。
优化之后,totalCount和totalPrice都不需要单独存在state里,需要的时候由getters基于items实时计算即可。这样一来,模块的基础数据只有一个数据源,逻辑上干净,不容易出错。
模块化之后,我会在store/index.js里注册一个全局生效的订阅:
store.subscribe((mutation, state) => { if (mutation.type.startsWith('cart/')) { localStorage.setItem('cartItems', JSON.stringify(state.cart.items)) } })store.subscribe这个方法是官方提供但教程里很少提的。它每次mutation提交后都会触发,我利用它把购物车数据同步到本地,应用启动时再从localStorage读取还原。比起在每个action里手动写保存逻辑,这种订阅式的持久化方案耦合度低、维护方便。
5. 组件侧的调用套路:mapState与mapActions的精简写法
5.1 未开命名空间的老写法 vs 开启命名空间的新写法
模块化之前,组件里的状态调用经常密密麻麻:
computed: { ...mapState(['cartItems']), ...mapGetters(['totalPrice']) }, methods: { ...mapActions(['fetchCart', 'addToCart']) }这种写法在模块化之后会直接失效,因为模块里的getters和actions不再是全局注册的。如果模块开启了namespaced: true,正确写法是给每个map方法传入模块路径作为第一个参数:
computed: { ...mapState('cart', ['items']), ...mapGetters('cart', ['totalCount', 'totalPrice']) }, methods: { ...mapActions('cart', ['fetchCart', 'addToCart']), ...mapActions('user', ['login']) }这种写法比手写$store.commit('cart/ADD_ITEM', item)整洁非常多,尤其是在一个组件里同时用到两个以上模块状态时,代码的可读性直接提升一个级别。
5.2 对象形式映射:给变量重命名的语法
有些时候组件里的state名和我们的语义命名不一样,比如模块里叫items,但在某个页面里它代表的是“推荐商品”,直接映射items很突兀。map方法支持对象形式重命名:
computed: { ...mapState('cart', { recommendProducts: 'items' }) }, methods: { ...mapActions('cart', { removeItem: 'removeFromCart' }) }这个写法在处理同名词语义不同的场景时非常有用。我经常在一个组件里同时映射多个模块的状态,比如用户模块的profile和商品模块的profile,如果不重命名就会出现属性冲突。对象形式把重命名的权力交还给组件层,各取所需,两种模块互不干扰。
模块化之后,最好形成这样一个规范:组件里只通过mapXxx方法或useStore来访问模块状态,不要到处直接写$store.state.cart.items。直接访问虽然快,但会让模板里散布大量模块路径,后面一旦模块改名或路径调整,全项目搜索替换的工作量相当大。mapXxx方式把模块路径集中在一个两行的区域,改起来一目了然。
6. 模块化路上的三个坑与完整排查链路
6.1 坑一:DevTools里找不到某个模块的state
某次在商城练习项目里,我明明在store/modules/下创建了cart.js,也注册到了根Store的modules里,但打开Vue DevTools的State面板时,根Store下面只有user和product,找不到cart的模块入口,页面里能正常使用操作购物车的方法,数据也会更新,偏偏DevTools里就是看不到。
排查链路大概是这样的:我第一反应是检查src/store/modules/cart.js导出格式是否正确。仔细一看,模块文件里的export default写的是{ namespaced: true, state, mutations: ..., actions: ..., getters: ... },没有发现问题。接着检查store/index.js的注册代码,找了一会儿发现原因了——我在import时写的是import cartModule from './modules/cart',但注册时写的是modules: { cart: cartModule.cart },试图取出一个不存在的cart属性。这个错误不会导致编译报错,只会导致注册失败,且没有任何警告,所以DevTools里完全没有显示。
修复方式就是直接注册整个模块对象:
modules: { cart: cartModule }这个坑提醒我,注册模块时一定要检查导出和引用是不是同一个对象层级,尤其是重构之后命名变化,很容易出现这种静默失败。DevTools里某个模块消失,第一站永远应该检查store/index.js里的modules字段。
6.2 坑二:新模块上线后老页面数据变了
另一个比较隐蔽的问题出现在给项目新增“促销活动”模块的时候。新增模块之前,商品详情页里有个“优惠价格”显示正常,新增活动模块后,这个价格突然变成了另一个数值,看起来像是被什么东西改了。
我排查的步骤是这样的:先看DevTools的Vuex面板,查看商品详情页使用的getterproduct/salePrice,值确实变了,但product模块的state没有变化,这就说明source不是product模块的数据。然后看getter定义的位置,发现store/modules/product.js里确实定义了salePrice,同时新增的activity.js模块里也有一个salePricegetter,而我前文提到过,如果模块没开启namespaced: true,同名getter会放到全局命名空间里,后注册的会覆盖先注册的,于是页面拿到的是活动模块的价格。
整个排查过程走下来,最有效的动作就是给新模块也加上namespaced: true,同时在项目规范里定死一条纪律:所有模块必须开启命名空间,模块内getter和mutation命名不要追求全局唯一,但跨模块调用必须带路径前缀。这样才能从根本上杜绝这种覆盖问题。
6.3 坑三:严格模式报警,状态不知道被谁改了
还有一次我打开strict: true之后,控制台疯狂报错,提示“Do not mutate Vuex store state outside mutation handlers”,但页面功能看起来都正常,一时半会儿找不到是哪里违反了规则。
我采取的做法是把报错堆栈打开,同时配合DevTools的Vuex面板,观察报错发生的具体时间点。最终锁定在某个组件的mounted钩子里,有一段逻辑直接改了购物车的items数组:
this.$store.state.cart.items.push(newItem)这种写法在没开严格模式时不会报错,状态也能正常更新,DevTools也能看到变化,但它绕开了mutation,导致时间旅行调试和模块化状态追踪全部失效。我把这段代码改成通过dispatch('cart/addToCart', newItem)来执行,严格模式的报警立刻消失,功能保持不变。
排查这类问题有个心得:开启严格模式并不是为了找麻烦,而是为了尽早暴露“绕过规则”的代码。模块化之后,所有状态变更路径都应该能通过DevTools的mutation列表完整还原,如果某次数据变了但mutation列表里没有记录,那一定是某处直接修改了state,严格模式就是最好的检测器。
在跑通模块化拆分之后,我再回头看第43天这个节点,最大的收获不是记住了namespaced: true这个配置项,而是真正理解了模块化解决的是“业务边界清晰化”的问题。状态代码和面条代码一样,堆在一起永远难维护。如果你也正好在学Vuex的Module,不要急着把根Store拆得稀碎,先找到状态之间的业务边界,拆出来的每个模块让一个人单看就能读懂业务逻辑,那这个模块就是合格的。