返回博客

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 是读「当前值」,不是「弹窗打开时的值」

只要 .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,.then() 只消费 const
const targetId = this.selectedId   // 快照
this.$confirm(...).then(() => {
  this.deleteItem(targetId)         // 用快照,不用 this.selectedId
})

一行提前捕获,彻底切断回调与后续状态变更之间的耦合。

排查验证

复现步骤:

  1. 对列表项 A 触发确认弹窗,不要点确定
  2. 在弹窗仍然打开的情况下,点击列表中的 B 项(触发上下文切换)
  3. 回到弹窗点「确定」
  4. 观察操作命中的是 A 还是 B

修复前命中 B,修复后应命中 A。

一句话外卖

异步确认弹窗里,快照胜过引用——在打开弹窗的那一行之前把所有依赖状态 const 住,.then() 只消费快照,永远不再通过 this.xxx 读活的响应式值。