本文从一段 Vue 项目中「防抖 + Promise resolve 外挂」的弹窗代码出发,分析它为什么别扭、背后想实现的 Deferred Pattern 究竟是什么,以及在现代 JavaScript 里更干净的写法。

一、问题代码

先看引发讨论的这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const debounceOpenRecomfirmPopup = debounce(
(resolve) => {
// 记录 Promise resolve,等待关闭回调传回 actionCode
recomfirmPopupPromiseResolve.value = resolve as (actionCode: 'confirm' | 'cancel' | 'close') => void
// 打开二次确认弹窗
isShowRecomfirmPopup.value = true
},
300
)

// 利息换超市卡二次确认弹窗:打开
const openRecomfirmPopupAsync = () => {
return new Promise<'confirm' | 'cancel' | 'close'>((resolve) => {
debounceOpenRecomfirmPopup(resolve)
})
}

第一眼看上去会有几个疑问:

  • 防抖的回调里再去记录 Promise 的 resolve,打开和完成是断开的,顺序怎么保证?
  • 这是某种「高级稳定」的写法吗?
  • 我是不是应该照着学?

直觉是对的——这不是值得照搬的高级模式,而是一个把两个机制硬耦合在一起的 workaround。


二、为什么这段代码别扭

2.1 完整的执行链路

  1. 调用 openRecomfirmPopupAsync():创建 Promise,把 resolve 传给 debounceOpenRecomfirmPopup
  2. 防抖 300ms 后才执行:把 resolve 存到 recomfirmPopupPromiseResolve.value,并打开弹窗。
  3. 弹窗关闭:handleRecomfirmClose 调用存起来的 resolve(actionCode),Promise 才算完成。

2.2 顺序隐患:debounce 会吞掉 resolve

如果用户快速连续触发两次 openRecomfirmPopupAsync()

  • 第一次的 resolve 被防抖吞掉了(不执行),但对应的 Promise 已经挂起在等待。
  • 防抖只执行最后一次回调,第二次的 resolve 覆盖了 recomfirmPopupPromiseResolve.value
  • 第一次的 Promise 永远不会被 resolve —— 直接挂死。

核心问题:防抖的语义是「只保留最后一次调用」,而 Promise 的语义是「每次调用都必须有结果」。把 resolve 丢进 debounce,相当于允许「Promise 完成事件」被静默丢弃。

2.3 「resolve 外挂到 ref」本身也不稳

  • 依赖一个全局 ref 做中转,任何地方意外清空就会挂死。
  • 和 Vue 响应式系统混用,调试困难。
  • 弹窗如果走了非预期路径(组件销毁、路由跳转、异常),resolve 永远不会被调用,Promise 泄漏

三、它想做的事:Deferred Pattern

抛开实现,这段代码的实质意图非常简单:

弹窗打开 → 等用户操作 → 拿到结果继续。

业务代码希望能这样写:

1
2
3
4
// 一气呵成,逻辑连贯
const actionCode = await openRecomfirmPopupAsync()
if (actionCode === 'cancel') return false
// 继续下单...

但弹窗本身是异步的:

  1. isShowRecomfirmPopup = true 打开它;
  2. 用户看到弹窗,点击「确认 / 取消」;
  3. Vue 组件 emit close 事件,传回 actionCode

步骤 2 是不可预测的时间间隔——你不可能在打开弹窗后同步拿到结果。这正是 Promise 的用武之地。

Promise 的本质:把「未来才会发生的结果」变成一个现在就能持有的对象。

这种「把异步操作和它的完成控制权解耦」的模式,就是 Deferred Pattern(延迟对象模式)


四、Deferred Pattern 详解

4.1 Promise vs. Deferred

对比维度 Promise Deferred
关注点 异步操作的结果 异步操作本身及其控制权
类比 取餐票,只能等出餐 厨师手里的底单,决定何时出餐
谁能完成它 构造函数内部的逻辑 外部任意代码(持有 resolve / reject 的人)

4.2 基础实现

在现代 JavaScript 里,可以用一个工具函数构造 Deferred:

1
2
3
4
5
6
7
8
9
10
11
function createDeferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void

const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})

return { promise, resolve, reject }
}

要点:

  • resolve / reject 在构造函数中被「偷出来」赋值给外部变量。
  • 返回一个三件套:promise 给消费方 await,resolve / reject 给控制方调用。

4.3 适用场景

  • 解耦异步逻辑:开始和结束不在同一个作用域里(事件监听、Socket 消息、弹窗回调等)。
  • 封装回调式 API:旧接口不便直接塞进 new Promise 构造函数时,用 Deferred 当外挂。
  • 跨函数共享 resolve:多个回调函数协同决定一个 Promise 的命运。

五、现代标准:Promise.withResolvers()

ECMAScript 2024 (ES15) 已经把这个模式标准化了,直接内置:

1
2
3
4
5
6
7
const { promise, resolve, reject } = Promise.withResolvers<string>()

setTimeout(() => {
resolve('任务完成!')
}, 2000)

promise.then(console.log)

以后写 Deferred 不需要再手撸 createDeferred,直接用原生 API 即可。


六、为什么 Promise 一开始是 pending

回到最初的疑问:

点击下单要打开二次弹窗时,openRecomfirmPopupAsync 返回的 Promise 里也没立刻 resolve 啊,它一直在 pending —— 这是怎么工作的?

答案是:resolve 是一个普通函数对象,可以被传递、存储、延后调用。Promise 不需要在创建时就完成。

6.1 执行时间线

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
时间 0ms ─── 业务代码调用 openRecomfirmPopupAsync()

│ new Promise(resolve => debounceOpenRecomfirmPopup(resolve))
│ → resolve 被当作参数传进 debounce,但 debounce 不立即执行
│ → 此时 Promise 状态:pending,resolve 函数"悬空"
│ → await 暂停,业务代码停在这里等待

时间 300ms ─── debounce 延迟结束,执行回调

│ recomfirmPopupPromiseResolve.value = resolve ← resolve 被存起来
│ isShowRecomfirmPopup.value = true ← 弹窗打开

│ → Promise 状态:仍然是 pending
│ → 但现在 resolve 有了"持有者"(ref),未来可以被调用

时间 ?s ─── 用户在弹窗里点了确认 / 取消 / 关闭

│ handleRecomfirmClose({ actionCode })
│ recomfirmPopupPromiseResolve.value?.(actionCode) ← resolve 被调用
│ recomfirmPopupPromiseResolve.value = null ← 清理

│ → Promise 状态:pending → fulfilled,值 = actionCode
│ → await 恢复执行,业务代码拿到 'confirm' / 'cancel' / 'close'

6.2 类比:事件监听

这就像你给 addEventListener 注册了一个回调:事件没触发时回调当然不会执行,但回调已经「挂上」了。Promise 的 resolve 也是同理:

1
2
3
4
5
6
const promise = new Promise((resolve) => {
// resolve 是一个函数,可以像任何变量一样被传递
// 传给 debounce、存到 ref、传给另一个函数——都可以
// 只要最终有人调用 resolve(值),Promise 就会完成
debounceOpenRecomfirmPopup(resolve)
})

Deferred Pattern 的核心就是:把「何时完成」的决定权从 Promise 内部移到了外部。

  • 普通 Promise 是内部自治的(网络请求成功就 resolve)。
  • Deferred 是外部控制的(弹窗关闭才 resolve)。

七、更干净的写法

回到最初那段代码,改造方向有两个原则:

  1. 防双击应该在 UI 层做(按钮点击节流),不要和 Promise 机制纠缠。
  2. resolve** 不要散落在 ref 里,尽量保持在同一个闭包内管理。**

方案 A:用 Deferred 工具函数封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function createDeferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((r, j) => { resolve = r; reject = j })
return { promise, resolve, reject }
}

// 弹窗使用
let currentDeferred: ReturnType<typeof createDeferred<ActionCode>> | null = null

const openRecomfirmPopupAsync = () => {
currentDeferred = createDeferred<ActionCode>()
isShowRecomfirmPopup.value = true
return currentDeferred.promise
}

const handleRecomfirmClose = (actionCode: ActionCode) => {
currentDeferred?.resolve(actionCode)
currentDeferred = null
}

方案 B:直接用 Promise.withResolvers()

1
2
3
4
5
6
7
8
9
10
11
12
let pending: ReturnType<typeof Promise.withResolvers<ActionCode>> | null = null

const openRecomfirmPopupAsync = () => {
pending = Promise.withResolvers<ActionCode>()
isShowRecomfirmPopup.value = true
return pending.promise
}

const handleRecomfirmClose = (actionCode: ActionCode) => {
pending?.resolve(actionCode)
pending = null
}

方案 C:组件内部用 watchOnce 自动收尾

1
2
3
4
5
6
const openRecomfirmPopupAsync = () => new Promise<ActionCode>((resolve) => {
isShowRecomfirmPopup.value = true
watchOnce(isShowRecomfirmPopup, (val) => {
if (!val) resolve(lastActionCode.value)
})
})

三种写法的共同点:逻辑是线性的,不会丢 resolve,也没有防抖和 Promise 的耦合。


八、总结

值得学的:Deferred Pattern 的思路 —— Promise 可以拆开,手动控制完成时机。这是处理异步 UI 交互、事件驱动逻辑的标准手段。

不值得照搬的:在原代码里,把 debounce 和 Deferred 混用、把 resolve 外挂到 Vue ref —— 这是两个不相干机制的硬耦合,会带来顺序丢失和 Promise 泄漏的隐患。

一句话总结:

学到的是思路(Promise 可以手动控制完成时机),不是具体写法(debounce + ref 外挂 resolve)。