Vuex 3:沿一次 dispatch 找到真正的写入
在旧项目里看到 this.$store.dispatch('cart/load'),先分清三个问题:cart 是哪份状态,load 是异步任务还是直接写入,谁在等待它完成。Vuex 3 把共享状态放进 store,用 mutation 记录同步修改,用 action 组织等待、分支和多次提交。getters 从状态派生结果,组件通过响应式读取保持显示。模块文件拆开之后,这些关系仍然存在;文件夹并不会自动决定 action 的名字。
从同步修改建立可追踪的入口
沿用本章环境,保存 store.cjs。本页所有代码都可在 Node 中运行,不依赖浏览器接口。
const Vue = require('vue')
const Vuex = require('vuex')
Vue.use(Vuex)
function createStore() {
return new Vuex.Store({
strict: true,
modules: {
cart: {
namespaced: true,
state: () => ({ count: 0 }),
getters: { doubled: state => state.count * 2 },
mutations: {
add(state, amount) { state.count += amount }
},
actions: {
async add({ commit }, amount) {
if (!Number.isInteger(amount) || amount < 0) {
throw new Error('数量必须是非负整数')
}
commit('add', amount)
return amount
},
async addLater({ dispatch }, amount) {
await Promise.resolve()
return dispatch('add', amount)
}
}
}
}
})
}
async function main() {
const store = createStore()
const events = []
const stop = store.subscribe((mutation, state) => {
events.push([mutation.type, state.cart.count])
})
await store.dispatch('cart/add', 2)
const result = await store.dispatch('cart/addLater', 3)
console.log(store.state.cart.count, store.getters['cart/doubled'], result)
// 5 10 3
console.log(events) // [['cart/add', 2], ['cart/add', 5]]
stop()
await store.dispatch('cart/add', 1)
console.log(events.length) // 2
console.log(createStore().state.cart.count) // 0
}
main().catch(console.error)Vue.use(Vuex) 安装 Vue 2 插件,new Vuex.Store 创建状态容器。浏览器应用把 store 传给根实例 new Vue({ store, ... }),后代才可以用 this.$store。Node 实验直接持有 store,更容易观察命令结果。strict 在开发时检测 mutation 以外的写入,它用于发现问题,并不把状态变成安全边界;大型状态树深度检查有成本,不默认在生产开启。
模块 state 由工厂函数创建,第二个 store 的 count 才仍为 0。namespaced=true 让外部名称成为 cart/add,action 内的 commit 已是 cart 的本地上下文,所以只写 add。如果在内部再写 cart/add,会查找 cart/cart/add。相反,默认没有命名空间时,模块虽然存放在 state.cart,mutation/action 名称仍可能注册在全局。两个模块同名处理器都响应一次提交,是旧项目值得核查的行为。
getters 依赖 Vue 2 响应式机制。往对象状态中新增键仍需要 Vue.set 或替换已观察引用;“放进 Vuex”没有消除上一节的限制。组件可在 computed 中写 return this.$store.state.cart.count;mapState('cart', ['count']) 只是生成这些读取函数的辅助工具。它不把状态复制到组件 data。
异步 action 的完成必须能被调用者等待
addLater 等待后 commit,再返回 amount。dispatch 的 Promise 把这项任务的完成传给调用者。若 action 启动一个 Promise 却不 return/await 它,dispatch 可能先完成,调用方错误地关掉提交按钮的 loading。mutation 必须保持同步:Devtools 和订阅者要能在提交结束时确定这次修改;把请求放进 mutation 会让“提交结束”和“数据被修改”脱离。
失败同样需要贯通。把参数改成 -1,action 在 commit 前抛错,await dispatch 进入调用方 catch,count 保持旧值。这里把业务校验放在 action 命令入口,mutation 只做已经验证过的同步写入;外部业务代码也通过该命令进入,不能绕过它直接提交任意参数。Vuex 3.6.2 的内部提交标志在 mutation 抛错时不保证复原,因此不要把预期业务失败设计为 mutation 抛错。修复后还应验证失败之后的合法命令仍可执行,strict 仍能检测越过 mutation 的写入。
不要用 catch(() => {}) 吞掉所有错误后返回成功;若页面需要展示错误,action 可以更新错误状态后继续抛出,让调用方明确知道命令失败。
加载状态与过期结果怎样一起维护
下面独立保存 load-store.cjs。load 接受一个代表请求结果的 Promise,便于控制响应次序;实际页面可以传入已经启动的服务调用。token 保存每次加载身份,取消则让当前身份失效并回到 idle。
const Vue = require('vue')
const Vuex = require('vuex')
Vue.use(Vuex)
const store = new Vuex.Store({
strict: true,
modules: {
list: {
namespaced: true,
state: () => ({ token: 0, rows: [], status: 'idle', error: '' }),
mutations: {
begin(state) { state.token++; state.status = 'loading'; state.error = '' },
success(state, rows) { state.rows = rows; state.status = 'success' },
fail(state, message) { state.error = message; state.status = 'error' },
cancel(state) { state.token++; state.status = 'idle'; state.error = '' }
},
actions: {
async load({ state, commit }, task) {
commit('begin')
const own = state.token
try {
const rows = await task
if (own !== state.token) return { applied: false }
commit('success', rows)
return { applied: true }
} catch (error) {
if (own !== state.token) return { applied: false }
commit('fail', error.message)
throw error
}
}
}
}
}
})
async function main() {
let finishOld
const oldTask = new Promise(resolve => { finishOld = resolve })
const oldRun = store.dispatch('list/load', oldTask)
await store.dispatch('list/load', Promise.resolve(['B']))
finishOld(['A'])
console.log(await oldRun) // { applied: false }
console.log(store.state.list.rows, store.state.list.status) // ['B'] success
try { await store.dispatch('list/load', Promise.reject(new Error('离线'))) }
catch (error) { console.log(error.message) } // 离线
console.log(store.state.list.rows, store.state.list.status) // ['B'] error
}
main().catch(console.error)这里失败后保留上一次 rows,让页面可以显示旧内容和错误提示;status 明确表明刷新失败,不能把缓存误当作本次加载成功。begin 不清空 rows 是本例的业务选择,若是切换用户,应在身份变化时同步清空敏感数据。cancel 只禁止后续写入,没有自动停止网络传输;真实请求的 AbortController 应由启动它的服务/页面持有,并在取消入口同时调用 abort。
取消之后旧任务仍可能拒绝,但 action 会发现身份已失效,返回 applied:false。当前任务失败则更新 error 并拒绝。区分这两条路径能避免已经离开的页面又弹出过期错误,也避免把当前错误静默吞掉。
订阅和动态模块也有归属
store.subscribe 返回取消函数。组件只在存在期间需要日志或同步时,应在 beforeDestroy 调用;服务级订阅则由服务关闭入口清理。动态注册模块使用 registerModule('feature', module),对应结束时 unregisterModule('feature');后者只能移除动态模块,不能拿来删除创建 store 时静态声明的模块。移除前还要取消该模块正在执行的任务,否则 action 的后续提交可能找不到 mutation。
跨模块提交使用完整名称和 { root: true },例如 cart action 中 commit('session/clear', null, { root: true })。这只是寻址方式,不自动给多个 mutation 提供事务;需要原子业务结果时,应先收集并验证输入,再决定一次明确的提交顺序。
练习:取消与真正的完成
在 load-store.cjs 的 main 中启动一个未完成加载,立刻 commit('list/cancel'),再让它成功。要求结果 applied:false、status=idle、rows 仍为之前的 B。另创建一个当前失败任务,验证它仍拒绝并显示 error,而不是被当成取消。
把 cart 的 addLater 改成漏掉 await/return 的版本,内部等待第二篇已经学过的 deferred.promise。先 await dispatch,确认状态未改;再手动 resolve 服务,观察提交此时才发生。不要用立即完成的 Promise 来猜队列先后。恢复为可等待实现,给出为什么“调用代码写了 await”仍不足以保证真正完成的解释。