Vuex Actions 详解:异步逻辑编排、Promise 组合与源码级实现解析
2026/9/19 19:20:53 网站建设 项目流程

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) } // ... }) }

两个值得注意的实现细节:

  1. 同一个 type 可以注册多个 handler——_actions[type]是一个数组。这为后文「跨模块同名动作」的合并派发埋下伏笔;
  2. 返回值统一被包装成 Promise——即使动作本身是同步的,dispatch拿到的也是可.then的 Promise。

派发(Dispatching)动作

动作通过store.dispatch触发:

store.dispatch('increment')

底层实现:dispatch 的完整调用链

Store.dispatch的实现位于 src/store.js,其流程为:

  1. 归一化参数:先调用unifyObjectStyle(_type, _payload)(见 src/store-util.js)判断第一个参数是否为带type字段的对象,从而统一得到{ type, payload }
  2. 未知动作告警:若this._actions[type]不存在,开发环境下输出[vuex] unknown action type: ${type}并直接返回;
  3. before 订阅者:遍历this._actionSubscribers中带有before的回调(由store.subscribeAction注册),在动作执行前被调用;
  4. 执行 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)用例验证;
  • 命名空间:外层再包一层normalizeNamespacemapActions('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。
  • 错误会向外传播且通知 DevToolsregisterAction中,若 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 中的定位可以归纳为三层:

  1. 职责分离:mutation 保持同步与可追踪,action 承载一切异步流程,状态副作用一律通过commit落盘(见 Mutations 指南);
  2. 派发体系store.dispatch支持 payload 与对象两种风格,始终返回 Promise;mapActions与命名空间机制让组件层的调用保持简洁(src/helpers.js);
  3. 组合能力: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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询