Vuex Actions 详解:异步逻辑编排、Promise 组合与源码级实现解析
【免费下载链接】vuex🗃️ Centralized State Management for Vue.js.项目地址: https://gitcode.com/gh_mirrors/vu/vuex
本篇围绕 Vuex 的 Actions(动作)机制展开:从「动作与变化的本质区别」讲起,覆盖 action 注册、context 对象、store.dispatch的 payload/对象两种派发风格、组件内mapActions辅助函数,以及用 Promise 与 async/await 组合多个异步动作的完整方案。结合 src/store.js 与 src/store-util.js 中的真实实现,帮助读者理解「为什么 context 不是 store 实例」「dispatch 返回的 Promise 从何而来」,最终能独立写出可复用的异步业务流程(如下单结算)。
Vuex 官方示例中展示的单向前端数据流(State → Getters / Mutations / Actions → Views):
Actions 与 Mutations 的核心区别
在 Vuex 中,Actions(动作)与 Mutations(变化)形似但职责不同,官方指南(docs/ptbr/guide/actions.md)给出的定义是:
- 动作不直接改变状态,而是提交(commit)mutations;
- 动作可以包含任意异步操作。
这正是 Vuex「mutation 必须同步、副作用必须可追踪」设计原则的延伸:状态变更本身保持同步且可被 DevTools 记录,而网络请求、定时器等异步流程则被收拢到 action 这一层。一个最简单的动作注册示例:
const store = createStore({ state: { count: 0 }, mutations: { increment (state) { state.count++ } }, actions: { increment (context) { context.commit('increment') } } })Context 对象:动作的「局部 store」
动作处理函数收到的第一个参数是一个context 对象,它暴露与 store 实例上相同的一组方法/属性:
context.commit—— 提交 mutation;context.state/context.getters—— 访问状态与 getter;context.dispatch—— 调用其他动作。
实践中常借助 ES2015 参数解构简化代码,尤其在需要多次commit时:
actions: { increment ({ commit }) { commit('increment') } }为什么 context 不是 store 实例本身?
这一点在引入 Modules(模块) 后才显得关键。从源码看,每个模块安装时都会通过makeLocalContext生成一个局部上下文,定义在 src/store-util.js:
- 根级(无命名空间)模块的
local.dispatch/local.commit直接就是store.dispatch/store.commit; - 命名空间模块的
local.dispatch会在派发前自动拼接namespace + type,除非显式传入{ root: true }选项; local.state是一个 getter,通过getNestedState(store.state, path)动态取到模块自己的 state 切片;- 命名空间模块的
local.getters则经过makeLocalGetters代理,只暴露本命名空间下的 getter。
因此「context 不是 store 实例」是为了让命名空间模块的动作拿到的是模块作用域内的dispatch/commit/state/getters。对于根级 store,context 中的state/getters与 store 上的等价,但同时还提供了rootState/rootGetters,这一点在 types/index.d.ts 的类型定义中体现得很清楚:
export interface ActionContext<S, R> { dispatch: Dispatch; commit: Commit; state: S; getters: any; rootState: R; rootGetters: any; }动作真正被注册、包装的位置在 src/store-util.js 的registerAction:
function registerAction (store, type, handler, local) { const entry = store._actions[type] || (store._actions[type] = []) entry.push(function wrappedActionHandler (payload) { let res = handler.call(store, { dispatch: local.dispatch, commit: local.commit, getters: local.getters, state: local.state, rootGetters: store.getters, rootState: store.state }, payload) if (!isPromise(res)) { res = Promise.resolve(res) } // ... }) }两个值得注意的实现细节:
- 同一个 type 可以注册多个 handler——
_actions[type]是一个数组。这为后文「跨模块同名动作」的合并派发埋下伏笔; - 返回值统一被包装成 Promise——即使动作本身是同步的,
dispatch拿到的也是可.then的 Promise。
派发(Dispatching)动作
动作通过store.dispatch触发:
store.dispatch('increment')底层实现:dispatch 的完整调用链
Store.dispatch的实现位于 src/store.js,其流程为:
- 归一化参数:先调用
unifyObjectStyle(_type, _payload)(见 src/store-util.js)判断第一个参数是否为带type字段的对象,从而统一得到{ type, payload }; - 未知动作告警:若
this._actions[type]不存在,开发环境下输出[vuex] unknown action type: ${type}并直接返回; - before 订阅者:遍历
this._actionSubscribers中带有before的回调(由store.subscribeAction注册),在动作执行前被调用; - 执行 handler:
const result = entry.length > 1 ? Promise.all(entry.map(handler => handler(payload))) : entry0即:当一个store.dispatch在不同模块中触发了多个同名 handler 时,返回的 Promise 会在它们全部 resolve 后才 resolve——这与官方文档末尾的提示完全一致; 5.after / error 订阅者 + 统一返回 Promise:外层再包一层new Promise,在结果 resolve 时通知after订阅者,在 reject 时通知error订阅者,最终resolve(res)或reject(error)。
所以从源码结构看,store.dispatch的返回值永远是一个 Promise:即使动作没有返回值,registerAction里的Promise.resolve(res)也会兜底。这使得.then()/await写法在任何动作上都成立。
Payload 与对象风格派发
动作支持两种等价写法,均可复制运行:
// 派发带 payload store.dispatch('incrementAsync', { amount: 10 }) // 对象风格派发 store.dispatch({ type: 'incrementAsync', amount: 10 })在 TypeScript 侧,这两种重载都体现在 types/index.d.ts 的Dispatch接口中:
export interface Dispatch { (type: string, payload?: any, options?: DispatchOptions): Promise<any>; <P extends Payload>(payloadWithType: P, options?: DispatchOptions): Promise<any>; }单元测试 test/unit/store.spec.js 分别以「dispatching actions, sync」与「dispatching with object style」两个用例验证了这两种派发方式最终都会把 payload 正确传给动作并触发 mutation。
为什么需要 dispatch 而不是直接 commit?
乍看之下,只想加一计数时直接store.commit('increment')似乎更直接。区别在于:mutation 必须同步,而动作不必。动作内部可以执行任意异步操作:
actions: { incrementAsync ({ commit }) { setTimeout(() => { commit('increment') }, 1000) } }实战示例:购物车结算
官方文档给出的更贴近真实业务的动作是「购物车结算」——它调用一个异步 API并提交多个 mutations:
actions: { checkout ({ commit, state }, products) { // 保存当前购物车中的商品 const savedCartItems = [...state.cart.added] // 发送结算请求并乐观地清空购物车 commit(types.CHECKOUT_REQUEST) // 商店 API 接受成功与失败两个回调 shop.buyProducts( products, // 成功回调 () => commit(types.CHECKOUT_SUCCESS), // 失败回调 () => commit(types.CHECKOUT_FAILURE, savedCartItems) ) } }要点在于:整个流程是一个异步操作序列,而所有副作用(状态变化)都通过 commit 落盘。仓库自带的示例 examples/classic/shopping-cart/store/modules/cart.js 给出了该场景的现代 async/await 版本,并演示了失败时的购物车回滚:
actions: { async checkout ({ commit, state }, products) { const savedCartItems = [...state.items] commit('setCheckoutStatus', null) // 先清空购物车(乐观更新) commit('setCartItems', { items: [] }) try { await shop.buyProducts(products) commit('setCheckoutStatus', 'successful') } catch (e) { console.error(e) commit('setCheckoutStatus', 'failed') // 回滚到发送请求前保存的购物车 commit('setCartItems', { items: savedCartItems }) } } }对照官方文档的回调版写法可以看出:无论用回调还是try/catch,「先保存现场 → 乐观提交 → 按结果分支提交」的编排模式是一致的。
在组件中派发动作
组件中有两种派发方式:
- 直接调用
this.$store.dispatch('xxx'); - 使用辅助函数
mapActions,把组件方法映射为store.dispatch调用(需要先把 store 注入到根实例)。
import { mapActions } from 'vuex' export default { // ... methods: { ...mapActions([ 'increment', // 将 `this.increment()` 映射为 `this.$store.dispatch('increment')` // `mapActions` 也支持 payload: 'incrementBy' // 将 `this.incrementBy(amount)` 映射为 `this.$store.dispatch('incrementBy', amount)` ]), ...mapActions({ add: 'increment' // 将 `this.add()` 映射为 `this.$store.dispatch('increment')` }) } }mapActions 源码解析
mapActions位于 src/helpers.js,核心逻辑是:
res[key] = function mappedAction (...args) { let dispatch = this.$store.dispatch if (namespace) { const module = getModuleByNamespace(this.$store, 'mapActions', namespace) if (!module) { return } dispatch = module.context.dispatch } return typeof val === 'function' ? val.apply(this, [dispatch].concat(args)) : dispatch.apply(this.$store, [val].concat(args)) }由此可以确认几种写法的真实行为:
- 数组形式
mapActions(['increment']):生成的方法把全部实参透传给this.$store.dispatch('increment', ...args),所以this.incrementBy(amount)等价于dispatch('incrementBy', amount); - 对象形式
mapActions({ add: 'increment' }):方法名add映射到动作类型increment; - 函数形式:映射项本身是函数时,以
dispatch作为第一个参数调用(可自定义派发逻辑),这一点由 test/unit/helpers.spec.js 的mapActions (function)用例验证; - 命名空间:外层再包一层
normalizeNamespace,mapActions('foo/', {...})会把 dispatch 替换为module.context.dispatch,即自动带上foo/前缀(见 test/unit/helpers.spec.js 的命名空间用例)。
在 Composition API 中则不需要mapActions,直接在setup里暴露派发函数即可,例如 docs/ptbr/guide/composition-api.md 中的asyncIncrement: () => store.dispatch('asyncIncrement')。
动作的组合(Composing Actions)
动作通常是异步的:如何知道一个动作何时完成?如何把多个动作组合起来处理更复杂的异步流程?
基于 Promise 的组合
store.dispatch能处理被触发动作 handler 返回的 Promise,因此动作可以直接return new Promise:
actions: { actionA ({ commit }) { return new Promise((resolve, reject) => { setTimeout(() => { commit('someMutation') resolve() }, 1000) }) } }外部即可链式等待:
store.dispatch('actionA').then(() => { // ... })也可以在另一个动作内部组合:
actions: { // ... actionB ({ dispatch, commit }) { return dispatch('actionA').then(() => { commit('someOtherMutation') }) } }基于 async/await 的组合
使用 async/await 后,组合逻辑更直观:
// 假设 `getData()` 和 `getOtherData()` 返回 Promise actions: { async actionA ({ commit }) { commit('gotData', await getData()) }, async actionB ({ dispatch, commit }) { await dispatch('actionA') // 等待 `actionA` 完成 commit('gotOtherData', await getOtherData()) } }test/unit/store.spec.js 的「composing actions with async/await」用例正是按此模式测试:two动作先await dispatch(TEST, 1)断言中间状态,再提交自己的 mutation,最终状态值正确。
多 handler 派发与错误传播
两个容易踩坑的点,均能在源码与测试中找到依据:
- 跨模块同名动作:由于
_actions[type]是数组,entry.length > 1时返回Promise.all(...)(src/store.js),即 Promise 在所有被触发的 handler 都 resolve 后才 resolve。 - 错误会向外传播且通知 DevTools:
registerAction中,若 store 挂载了_devtoolHook,handler 返回的 Promise 会额外接一个.catch,先emit('vuex:error', err)再throw err(src/store-util.js)。test/unit/store.spec.js 的「detecting action Promise errors」用例验证了动作 reject 后,store.dispatch返回的 Promise 会被 reject、thenSpy不会被调用,且 devtoolHook 收到'vuex:error'事件。这意味着动作内部抛错/ reject 时,调用方应该用try/catch或.catch()兜底,否则会产生未处理的 Promise 拒绝。
小结
回到官方文档的主线,Actions 在 Vuex 中的定位可以归纳为三层:
- 职责分离:mutation 保持同步与可追踪,action 承载一切异步流程,状态副作用一律通过
commit落盘(见 Mutations 指南); - 派发体系:
store.dispatch支持 payload 与对象两种风格,始终返回 Promise;mapActions与命名空间机制让组件层的调用保持简洁(src/helpers.js); - 组合能力:Promise 链与 async/await 让多个异步动作可以串行/并行编排,
Promise.all语义与 error 订阅者共同保证了多模块场景下的可观测性(src/store.js)。
理解 context 由makeLocalContext生成的机制后,读者再学习 Modules(模块) 中的命名空间与{ root: true }选项时,就不会觉得它们突兀——它们本质上都是这套「局部上下文」机制的自然延伸。
【免费下载链接】vuex🗃️ Centralized State Management for Vue.js.项目地址: https://gitcode.com/gh_mirrors/vu/vuex
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考