6.2 模块化与 Pinia:仓库的拆与换 本节摘要:大项目的 Vuex store 靠 modules 按领域拆分,命名空间解决命名冲突,但跨模块调用与根实例耦合仍是痛点。Pinia 用去中心化的多个小 store 替代单一根 store,砍掉 mutations 层,天然支持组合式 API 与 TypeScript。本节讲清拆分方法与换代逻辑,并给出迁移判断。 学习目标 阅读完本节,你应当能够: 把巨型 store 按领域拆成带命名空间的 modules; 写出跨模块读状态与调 action 的代码,避开常见耦合坑; 用 Pinia 的 setup store 风格组织一个小仓库; 判断存量 Vuex 工程要不要迁、怎么分阶段迁。
本节摘要:大项目的 Vuex store 靠 modules 按领域拆分,命名空间解决命名冲突,但跨模块调用与根实例耦合仍是痛点。Pinia 用去中心化的多个小 store 替代单一根 store,砍掉 mutations 层,天然支持组合式 API 与 TypeScript。本节讲清拆分方法与换代逻辑,并给出迁移判断。
阅读完本节,你应当能够:
单一 store 超过几百行就该拆。拆分维度选领域(用户、订单、购物车)而不是页面——页面会改版合并,领域相对稳定;且页面组件天然只关心自己领域的数据。
// 每个模块一个文件,各自拥有完整的四件套 const user = { namespaced: true, // 关键:开启命名空间 state: () => ({ profile: null, token: '' }), getters: { isLoggedIn: (s) => !!s.token }, mutations: { SET_PROFILE(state, p) { state.profile = p; } }, actions: { async login({ commit }, form) { const res = await api.login(form); commit('SET_PROFILE', res.profile); } } }; export default new Vuex.Store({ modules: { user, cart, order } });
命名空间开启后,调用要带模块前缀:
this.$store.state.user.profile; // 读状态带模块名 this.$store.dispatch('user/login', form); // 动作带斜杠路径 ...mapGetters('user', ['isLoggedIn']); // 辅助函数第一参数指定模块
跨模块交互用 action 上下文里的 rootState 与 rootGetters:
const order = { namespaced: true, actions: { async checkout({ commit, dispatch, rootState, rootGetters }) { if (!rootGetters['user/isLoggedIn']) { return dispatch('user/login', null, { root: true }); // 调别模块的 action } // 下单逻辑... } } };
坑有两处。一是模块内部 mutation 与 getter 注册默认是全局命名空间(不开启 namespaced 时所有模块的 mutations 混在一个池子里,同名互相覆盖),所以除了极小项目,模块一律开命名空间;二是模块间大量互相 dispatch 是坏味道——领域之间耦合到需要频繁互调,说明领域边界划错了,先审边界再写代码。
Pinia 的核心变化:没有根 store,只有一组平等的独立 store;mutations 层被删除,组件与 action 都直接读写 state。
import { defineStore } from 'pinia'; // 选项式风格:与 Vuex 模块神似,迁移成本低 export const useCartStore = defineStore('cart', { state: () => ({ items: [] }), getters: { total: (s) => s.items.reduce((sum, i) => sum + i.price * i.count, 0) }, actions: { addItem(item) { this.items.push({ ...item, count: 1 }); // 直接改,没有 mutation } } });
// 组合式风格:就是一组 ref 与函数,Vue 3 项目的推荐形态 export const useUserStore = defineStore('user', () => { const token = ref(''); const isLoggedIn = computed(() => !!token.value); async function login(form) { const res = await api.login(form); token.value = res.token; } return { token, isLoggedIn, login }; });
组件里使用:
const cart = useCartStore(); // 像调一个组合函数 cart.addItem(goods); const total = computed(() => cart.total); // 保持响应式

Vuex 的同步红线是为了时间旅行快照的确定性。Pinia 的解法换了个角度:变更追踪改由 patch 与 subscribe 承担——需要批量变更时用 patch 提交对象或函数,需要监听时订阅整个 store 的变化,快照语义由订阅机制保证,不再需要把所有写入逼进一条窄门。代价是"所有修改必须走 mutation"这条强约束带来的代码纪律消失了,团队约定要补位:action 命名保持事件语义、组件里只读不写,这两条写进规范即可兜住大半风险。
存量 Vuex 4 工程的处理建议:
搬运单模块的映射很机械:模块的 state 变成新 store 的 state,mutations 与 actions 合并进 actions(同步异步都放),getters 原样。命名空间前缀自动消失,调用点从 dispatch('user/login') 改为 useUserStore().login(),IDE 重构工具能覆盖大部分改动。
两套仓库共存期还有一个容易被忽略的坑:同一段业务数据只能有一个主人。如果用户信息在 Vuex 与 Pinia 里各存了一份,两边各有更新逻辑,状态不一致只是时间问题。共存期的纪律是按领域划界——老的 user 模块还归 Vuex,新的订单域用 Pinia,任何数据都不允许双边写,等某领域彻底迁完再交割。这条纪律写进迁移文档,比口头约定可靠得多。搬移过程中每完成一个领域,记得同步删除老仓库里的对应模块与调用点,半拉子共存最容易在几个月后被新同事误用——他们在搜索示例时找到老写法,照抄之后一个项目里两种心智模型并存,沟通成本反而高于不迁。
状态有了主人,接下来解决"页面级视图怎么切换"——路由管理。