Element UI $confirm 闭包竞态:操作打到了错误目标
某后台管理系统上线后,偶发一个让人头皮发麻的 bug:管理员对 A 条记录点了「确认」,但实际生效的却是 B 条记录。复现率极低,测试从未跑出过,只在线上被用户抓到。翻了半天日志,才发现根子在 $confirm 的 .then() 回调里。
现象
后台管理页面有一个列表,点击某一行的操作按钮,会弹出 Element UI 的 $confirm 确认框。正常流程下一切正常。但如果管理员在弹窗还没关闭的情况下,用鼠标点了另一行(切换了当前选中项),再回来点「确定」——操作就会命中新选中的那行,而不是最初触发弹窗的那行。
根因
问题出在闭包对响应式状态的读取时机上。
// 有问题的写法
onConfirmAction() {
this.$confirm('确定要对该记录执行操作吗?', '提示').then(() => {
// BUG: this.currentId 在弹窗等待期间可能已经变了
const id = String(this.currentId)
this.doAction(id)
}).catch(() => {})
}.then() 回调在用户点击「确定」时才执行,而 this.currentId 是一个 Vue 响应式属性。从弹窗弹出到用户点击确认,这中间可能经过几秒甚至更长时间,用户完全可以在这个时间窗口内切换上下文,导致 this.currentId 已经指向了别的记录。
$confirm 是 Promise-based 的异步 API,.then() 回调捕获的是 调用时刻的 this 引用,而不是弹窗打开时刻的 this.currentId 值。Vue 的响应式系统只负责 DOM 更新,不会给 Promise 回调"冻结"一个快照。
只要 .then() 内部通过 this.xxx 访问响应式属性,就存在这个竞态窗口。弹窗等待时间越长、UI 上下文切换越容易触发,风险越高。高危场景:聊天窗口(切换对话)、表格操作(选中不同行)、多 Tab 页(切换 Tab)。
正解
在调用 $confirm 之前,立即把所有需要用到的响应式状态捕获为局部常量,让 .then() 回调消费这个快照值,而不是再次访问 this.xxx。
// 正确写法:在弹窗打开前捕获快照
onConfirmAction() {
if (!this.currentId) return
// 在 $confirm 调用前立即捕获——此刻的值被「冻结」在局部变量里
const id = String(this.currentId)
const name = this.currentName
this.$confirm(`确定要对「${name}」执行操作吗?`, '提示').then(() => {
// 使用快照值,与 this.currentId 的后续变化完全隔离
this.doAction(id)
}).catch(() => {})
}同样的规则适用于 $prompt、$alert、自定义 Promise 弹窗,以及任何在 setTimeout / setInterval 回调里读取响应式状态的场景。
Vue 3 Composition API 中同样存在此坑,捕获的是 ref.value:
const currentId = ref(null)
function onConfirmAction() {
const id = currentId.value // 提前读取 .value,而不是在 .then 里读
confirmDialog().then(() => {
doAction(id)
})
}const targetId = this.selectedId // 快照
this.$confirm(...).then(() => {
this.deleteItem(targetId) // 用快照,不用 this.selectedId
})一行提前捕获,彻底切断回调与后续状态变更之间的耦合。
排查验证
复现步骤:
- 对列表项 A 触发确认弹窗,不要点确定
- 在弹窗仍然打开的情况下,点击列表中的 B 项(触发上下文切换)
- 回到弹窗点「确定」
- 观察操作命中的是 A 还是 B
修复前命中 B,修复后应命中 A。
一句话外卖
异步确认弹窗里,快照胜过引用——在打开弹窗的那一行之前把所有依赖状态
const住,.then()只消费快照,永远不再通过this.xxx读活的响应式值。