Vuex 应用结构:用三条高层原则组织可维护、可扩展的模块化 Store
【免费下载链接】vuex🗃️ Centralized State Management for Vue.js.项目地址: https://gitcode.com/gh_mirrors/vu/vuex
Vuex 本身不强制任何目录模板,而是通过三条高层原则约束状态管理的边界:应用级状态集中在 Store、状态只能通过同步的Mutation变更、异步逻辑封装并组合于Action。本文以 docs/ja/guide/structure.md 为骨架,讲解在遵守这些原则的前提下如何拆分文件、如何借助模块(Module)应对非平凡应用,并对照仓库中的 Shopping Cart 完整示例给出可直接复用的目录设计。
Vuex 不限制结构,只约束三条原则
很多状态管理库会规定你必须把代码放进某个固定目录,Vuex 恰恰相反——它把"怎么排版代码"的决定权完全交给你,只强制三条更高层的原则:
- 应用级状态集中在 Store 中:整个应用共享的唯一数据源(single source of truth)放在一个 store 对象里,组件不各自维护散落的状态副本;
- 改变状态的唯一方式是提交(commit)Mutation:Mutation 是同步事务,所有状态变更都有迹可循、可被 devtools 追踪;
- 异步逻辑必须封装在 Action 中:Action 内部可以做任意异步操作(请求、延时、组合其他 action),但最终仍然通过 commit mutation 来真正修改状态。
这三条原则正是 docs/guide/mutations.md 与 docs/guide/actions.md 展开论述的核心:Mutation 处理同步变更、Action 处理异步与编排。只要守住这三条边界,Store 内部的组织方式(单一文件还是多文件、是否拆模块)完全由项目规模和团队习惯决定。
从单文件到多文件:Store 变大的第一步拆分
对于小应用,把 state、getters、mutations、actions 全部写在一个 store 文件里完全合理。当 store 文件开始变得臃肿时,原文档给出的建议非常直接:把 actions、mutations、getters 分别切到独立文件。
这是最轻量的演进路径——不需要引入模块机制,只是把根级(root)的四个部分按职责拆文件,每个文件导出对应对象,再在store/index.js中汇总:
// store/index.js —— 汇总并导出 store import { createStore } from 'vuex' import actions from './actions' // 根级 actions import mutations from './mutations' // 根级 mutations import getters from './getters' // 根级 getters(按需) export default createStore({ state () { return { /* 根状态 */ } }, getters, mutations, actions })这种拆分纯粹是"文件组织"层面的重构,不改变任何运行语义——createStore只关心传入的选项对象结构,不关心这些对象来自哪个文件。Vuex 4 的入口实现 src/store.js 通过 ModuleCollection 收集模块树,把传入的 state/getters/mutations/actions 统一安装到内部_modules上,因此文件的切分方式对运行时完全透明。
推荐目录结构:非平凡应用的模块化模板
原文档明确表示:"只要遵循这些规则,项目如何结构取决于你;而对于任何非平凡(non-trivial)的应用,我们很可能需要借助模块。" 并给出了一个经典的项目结构模板:
├── index.html ├── main.js ├── api │ └── ... # 抽象 API 调用 ├── components │ ├── App.vue │ └── ... └── store ├── index.js # 汇总模块并导出 store ├── actions.js # 根级 actions ├── mutations.js # 根级 mutations └── modules ├── cart.js # cart 模块 └── products.js # products 模块这个结构的几个设计要点:
api/独立于 store:网络请求的细节(URL、参数、序列化)被隔离在api目录,store 的 action 只调用api暴露的函数,拿到数据后 commit mutation。这样 store 里没有fetch/axios噪音,便于测试时 mock;store/index.js是唯一出口:所有模块在这里组装,main.js只import store并app.use(store),组件不感知模块内部布局;store/modules/按业务域划分:每个业务域一个文件,文件内自含该域的 state、getters、mutations、actions,形成内聚单元;- 根级 actions/mutations 保留:跨模块共享或不属于任何业务域的根级逻辑放在 store 根,与模块树并存。
从模块系统的实现看,src/module/module.js 把每个模块(包括根模块)建模为包含state、getters、mutations、actions、children的节点,module-collection.js 负责递归注册嵌套模块——这正是store/modules/目录可以无限嵌套、每个模块文件内部再声明modules子模块的底层依据(详见 docs/guide/modules.md)。
实战参考:Shopping Cart 示例的完整解剖
原文档末尾指向了 Shopping Cart 示例。在当前仓库中,它有两个版本:经典选项式 API 版 examples/classic/shopping-cart 与 Composition API 版 examples/composition/shopping-cart。以 classic 版为例,它完整实现了上文推荐的结构。
目录与文件的对应关系
examples/classic/shopping-cart/ ├── api/ │ └── shop.js # mock 的 API 抽象 ├── components/ │ ├── App.vue │ ├── ProductList.vue │ └── ShoppingCart.vue ├── store/ │ ├── index.js # 组装模块、导出 store │ └── modules/ │ ├── cart.js # 购物车模块(含嵌套模块 nested) │ ├── nested.js # 嵌套子模块示例 │ └── products.js # 商品模块 ├── app.js # 创建应用并 app.use(store) ├── currency.js └── index.html组装入口:store/index.js
examples/classic/shopping-cart/store/index.js 是目录结构的核心——它把模块汇总进createStore,并展示了两个非常实用的生产配置:
import { createStore, createLogger } from 'vuex' import cart from './modules/cart' import products from './modules/products' const debug = process.env.NODE_ENV !== 'production' export default createStore({ modules: { cart, products }, strict: debug, // 非生产环境开启严格模式 plugins: debug ? [createLogger()] : [] // 非生产环境挂载日志插件 })这里体现了两条值得沿用的实践:严格模式(strict)与 logger 插件只在开发环境开启。strict模式下,任何绕过 mutation 的状态直接赋值都会抛出错误,强制团队遵守"唯一变更途径是 commit mutation"的原则;createLogger则把每次 mutation 前后的状态快照打到控制台。二者在NODE_ENV !== 'production'时启用、生产构建自动剔除,零成本换取开发体验。
业务模块的自我包含:cart.js 与 products.js
cart 模块 与 products 模块 都采用同样的"四件套"结构,这正是原文档推荐目录结构的模块粒度:
- state 使用函数返回(
state: () => ({ items: [], checkoutStatus: null })),避免模块被复用或 SSR 时共享同一个状态对象(模块复用问题的详细讨论见 docs/guide/modules.md 的 "Module Reuse" 一节); - getters 负责派生数据:例如
cartProducts把购物车中的{ id, quantity }与rootState.products.all中的商品详情合并,cartTotalPrice计算总价——组件只需读 getter,无需关心数据拼装逻辑; - actions 承载异步与编排:
async checkout({ commit, state }, products)演示了完整的异步事务流程——先清空购物车、调用shop.buyProducts(products)、成功则提交setCheckoutStatus('successful'),失败则回滚到保存的快照。这就是"异步逻辑封装在 action"原则的活教材; - mutations 只做同步变更:
pushProductToCart、incrementItemQuantity、setCheckoutStatus全部是纯同步的状态修改,action 通过commit调用它们。
两个模块都声明了namespaced: true,因此 action 与 mutation 的类型名自动带上前缀:cart 模块的addProductToCart在组件中通过dispatch('cart/addProductToCart')或mapActions('cart', ['addProductToCart'])调用;products 模块同理。cart 模块还通过commit('products/decrementProductInventory', { id }, { root: true })提交其他命名空间下的 mutation,演示了跨模块协作的标准姿势。
此外,cart 模块内部还嵌套了 nested.js(modules: { nested }),这是一个极简的嵌套模块示例——它继承了父模块的cart/命名空间,getter 类型名为cart/twoBars。这印证了模块系统"分形递归、可任意嵌套"的特性:目录结构深度与模块嵌套深度一一对应。
API 抽象层:api/shop.js
api/shop.js 用 mock 数据模拟客户端-服务器交互:getProducts()延时 100ms 返回商品列表,buyProducts()以 50% 概率模拟结算失败并抛出Checkout error。这正是api/目录存在的意义——action 只依赖"有一个能返回 Promise 的 API 函数",至于它背后是真实 HTTP 请求还是本地 mock,对 store 完全透明。真实项目中,把这个文件替换成 axios/fetch 封装即可,store 代码一行不用改。
组件如何接入
app.js 展示了入口的接入方式:createApp(App)创建应用后app.use(store)完成注入。组件侧,ProductList.vue 用mapState({ products: state => state.products.all })读取 products 模块状态、...mapActions('cart', ['addProductToCart'])绑定带命名空间的 action、在created()中dispatch('products/getAllProducts')触发初始化。整个调用链严格单向:组件 → action(可能调 API)→ mutation → 状态 → 组件渲染。
命名空间:让模块自包含、可复用
非命名空间模块的 actions/mutations 默认注册在全局命名空间,多个模块可以响应同一个 action/mutation 类型,但 getters 同名会报错。要让模块更自包含、可复用,在模块上声明namespaced: true即可——模块内所有 getters、actions、mutations 会自动按注册路径加前缀(详见 docs/guide/modules.md 的 "Namespacing" 一节)。
命名空间带来的配套能力包括:
- 局部化上下文:命名空间模块内的 getters/actions 收到的是局部化的
getters、dispatch、commit,模块内部写代码时无需前缀; - 访问全局资源:通过 getter 的第 3、4 个参数
rootState、rootGetters,或dispatch/commit的第 3 个参数{ root: true }访问全局;Shopping Cart 中commit('products/decrementProductInventory', { id }, { root: true })就是这一用法的实例; - 绑定辅助函数:
mapState('cart', ...)、mapGetters、mapActions可以接收命名空间字符串作为第一个参数,也可以使用createNamespacedHelpers('cart')生成预绑定命名空间的 helper,减少组件中的样板代码。
模块化的进阶能力:动态注册与模块复用
原文档的目录模板只是起点,docs/guide/modules.md 还补充了三个非平凡应用几乎必然会用到的模块能力,它们与"结构"主题直接相关:
- 动态注册模块:
store.registerModule('a', module)可在 store 创建后按需挂载模块(如按路由懒加载业务模块),配合store.unregisterModule与store.hasModule使用;嵌套模块路径要传数组['nested', 'myModule']。registerModule还支持{ preserveState: true }保留既有状态(如 SSR 水合场景); - 模块复用:同一个模块被多个 store 使用、或在同一 store 注册多次时,必须用函数形式声明 state(
state: () => ({...})),否则状态对象按引用共享会造成污染——这与 Vue 组件data必须用函数是同一个道理,Shopping Cart 各模块均已遵循; - 插件开发的命名空间陷阱:插件若向用户 store 提供模块,用户可能把它挂在命名空间模块下,因此插件应通过选项接收 namespace 值再拼接类型名(docs/guide/plugins.md 有更完整的插件开发说明)。
小结:结构是手段,原则是底线
回到原文档的结论:Vuex 把"如何组织代码"的自由留给开发者,但用三条原则框定了底线——状态集中、变更走 mutation、异步走 action。在这条底线之上:
- 小应用可以直接单文件;文件变大时先按 actions/mutations/getters 拆文件;
- 非平凡应用引入
store/modules/按业务域拆模块,必要时叠加命名空间与动态注册; - 推荐将
api/与store/分离,让网络细节不进状态层; - 直接对照仓库中的 classic 版 或 composition 版 Shopping Cart 示例落地。
这套结构不依赖特定构建工具或框架版本,既适用于选项式 API,也适用于 docs/guide/composition-api.md 描述的 Composition API 用法——它解决的是"状态层如何组织"的问题,与组件层的写法正交。
【免费下载链接】vuex🗃️ Centralized State Management for Vue.js.项目地址: https://gitcode.com/gh_mirrors/vu/vuex
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考