本文从一段 Vue 项目中「防抖 + Promise resolve 外挂」的弹窗代码出发,分析它为什么别扭、背后想实现的 Deferred Pattern 究竟是什么,以及在现代 JavaScript 里更干净的写法。
一、问题代码
先看引发讨论的这段代码:
1 | const debounceOpenRecomfirmPopup = debounce( |
第一眼看上去会有几个疑问:
- 防抖的回调里再去记录 Promise 的
resolve,打开和完成是断开的,顺序怎么保证? - 这是某种「高级稳定」的写法吗?
- 我是不是应该照着学?
直觉是对的——这不是值得照搬的高级模式,而是一个把两个机制硬耦合在一起的 workaround。
二、为什么这段代码别扭
2.1 完整的执行链路
- 调用
openRecomfirmPopupAsync():创建 Promise,把resolve传给debounceOpenRecomfirmPopup。 - 防抖 300ms 后才执行:把
resolve存到recomfirmPopupPromiseResolve.value,并打开弹窗。 - 弹窗关闭:
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 | // 一气呵成,逻辑连贯 |
但弹窗本身是异步的:
isShowRecomfirmPopup = true打开它;- 用户看到弹窗,点击「确认 / 取消」;
- 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 | function createDeferred<T>() { |
要点:
resolve / reject在构造函数中被「偷出来」赋值给外部变量。- 返回一个三件套:
promise给消费方 await,resolve / reject给控制方调用。
4.3 适用场景
- 解耦异步逻辑:开始和结束不在同一个作用域里(事件监听、Socket 消息、弹窗回调等)。
- 封装回调式 API:旧接口不便直接塞进
new Promise构造函数时,用 Deferred 当外挂。 - 跨函数共享 resolve:多个回调函数协同决定一个 Promise 的命运。
五、现代标准:Promise.withResolvers()
ECMAScript 2024 (ES15) 已经把这个模式标准化了,直接内置:
1 | const { promise, resolve, reject } = Promise.withResolvers<string>() |
以后写 Deferred 不需要再手撸 createDeferred,直接用原生 API 即可。
六、为什么 Promise 一开始是 pending
回到最初的疑问:
点击下单要打开二次弹窗时,
openRecomfirmPopupAsync返回的 Promise 里也没立刻resolve啊,它一直在 pending —— 这是怎么工作的?
答案是:resolve 是一个普通函数对象,可以被传递、存储、延后调用。Promise 不需要在创建时就完成。
6.1 执行时间线
1 | 时间 0ms ─── 业务代码调用 openRecomfirmPopupAsync() |
6.2 类比:事件监听
这就像你给 addEventListener 注册了一个回调:事件没触发时回调当然不会执行,但回调已经「挂上」了。Promise 的 resolve 也是同理:
1 | const promise = new Promise((resolve) => { |
Deferred Pattern 的核心就是:把「何时完成」的决定权从 Promise 内部移到了外部。
- 普通 Promise 是内部自治的(网络请求成功就 resolve)。
- Deferred 是外部控制的(弹窗关闭才 resolve)。
七、更干净的写法
回到最初那段代码,改造方向有两个原则:
- 防双击应该在 UI 层做(按钮点击节流),不要和 Promise 机制纠缠。
resolve** 不要散落在 ref 里,尽量保持在同一个闭包内管理。**
方案 A:用 Deferred 工具函数封装
1 | function createDeferred<T>() { |
方案 B:直接用 Promise.withResolvers()
1 | let pending: ReturnType<typeof Promise.withResolvers<ActionCode>> | null = null |
方案 C:组件内部用 watchOnce 自动收尾
1 | const openRecomfirmPopupAsync = () => new Promise<ActionCode>((resolve) => { |
三种写法的共同点:逻辑是线性的,不会丢 resolve,也没有防抖和 Promise 的耦合。
八、总结
值得学的:Deferred Pattern 的思路 —— Promise 可以拆开,手动控制完成时机。这是处理异步 UI 交互、事件驱动逻辑的标准手段。
不值得照搬的:在原代码里,把
debounce和 Deferred 混用、把resolve外挂到 Vue ref —— 这是两个不相干机制的硬耦合,会带来顺序丢失和 Promise 泄漏的隐患。
一句话总结:
学到的是思路(Promise 可以手动控制完成时机),不是具体写法(debounce + ref 外挂 resolve)。