Bluebird 异步对话框实战:用 Promise 封装用户输入、取消与进度
2026/9/21 14:02:39 网站建设 项目流程
  • 后端

【免费下载链接】bluebird

:bird: :zap: Bluebird is a full featured promise library with unmatched performance.

项目地址:https://gitcode.com/gh_mirrors/bl/bluebird
点击查看免费下载

用户输入通常被认为是"同步"的——程序调用window.prompt后必须停下来等待用户作答。本文基于 Bluebird(当前仓库即 bluebird 开源项目)官方文档《Async Dialogs》,完整演示如何把"等待用户操作"视为一次异步事件,并用 Promise 统一封装:从包装window.prompt、自制非阻塞 DOM 对话框,到抽象 Dialog 基类、区分用户取消与程序错误,再到用Promise.race管理"任务进度 + 用户取消"两个并发 Promise。读完本文,你将掌握一套可复用的"对话框即 Promise"设计模式,并深入理解 Bluebird 的过滤式catchPromise.race与内置 cancellation 功能的真实语义。

为什么把用户输入当作异步事件

Promise最常见的用途是配合网络请求或setTimeout这类异步任务;一个较少被探索的方向是处理用户输入。程序的执行流程需要等待用户继续某些操作(点击确认、输入文本、取消对话框),这与等待一次网络响应在本质上是同一回事——都是"结果在未来的某个时刻才产生",因此完全可以视为一次异步事件。

把对话框封装成 Promise 带来的核心收益是解耦:调用方只需要dialog.show(...).waitForUser().then(...),完全不关心对话框内部用的是window.prompt、DOM 元素还是别的什么实现。这为将来把同步对话框替换成异步对话框提供了"面向未来"的兼容性——这正是本文后续所有示例的出发点。

起步:用 Promise 构造器包装window.prompt

先看一个最简单的 HTML 模板(原文档的起点),页面上有一个实时跳动的时钟、一个用于显示名字的占位符和一个"Set Name"按钮:

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Async Dislogs Example</title> <script src="//cdn.jsdelivr.net/bluebird/{{ site.version }}/bluebird.js"></script> <script type="text/javascript"> document.addEventListener('DOMContentLoaded', function() { var time = document.getElementById('time-stamp'); clockTick(); setInterval(clockTick, 1000); function clockTick() { time.innerHTML = new Date().toLocaleTimeString(); } }); </script> </head> <body> <p>The current time is <span id="time-stamp"></span>.</p> <p>Your name is <span id="prompt"></span>.</p> <button id="action">Set Name</button> </body> </html>

window.prompt在等待用户输入时会阻塞整个页面的处理。它必须阻塞,因为输入值是被同步返回的,下一行代码立刻需要这个结果。原文档用一个 Promise 构造器 把这段典型的条件式代码转换成 Promise API:

function promptPromise(message) { return new Promise(function(resolve, reject) { var result = window.prompt(message); if (result != null) { resolve(result); } else { reject(new Error('User cancelled')); } }); } var button = document.getElementById('action'); var output = document.getElementById('prompt'); button.addEventListener('click', function() { promptPromise('What is your name?') .then(function(name) { output.innerHTML = String(name); }) .catch(function() { output.innerHTML = '¯\\_(ツ)_/¯'; }); });

用户输入合法时resolve(result),点击取消(window.prompt返回null)时reject(new Error('User cancelled'))。注意这里遵循了"reject 时永远传 Error 对象"的约定,这一点在本文"取消与错误处理"一节还会深入展开。

从源码看,Bluebird 的 Promise 构造器在 src/promise.js 中会检查执行器必须是函数,并通过_resolveFromExecutor(executor)同步调用执行器(resolver 是同步执行的,这是 Promise 规范与 Bluebird 实现共同保证的行为)。用 Promise 包装的一个额外好处是:即使执行器内部同步抛错,也会被捕获并转换为拒绝,从而保证promptPromise永远返回一个"不抛同步异常"的 Promise——这正是 new Promise 文档 中强调的 promise-returning 函数契约。

单纯包装window.prompt看似没有增加多少价值,但 Promise API 带来一个明显优势:当我们调用promptPromise(…)时,可以轻松地"响应对话框的结果",而无需关心它的实现方式。在上面的示例里我们实现了window.prompt,但调用方对此毫不知情。这让将来把它换成真正的异步对话框变得更有前瞻性。

自制非阻塞的 DOM 对话框

要体会window.prompt的同步阻塞特性,只需注意一点:对话框弹出时,页面上的时钟停止跳动了。解决的办法是自建一个纯 DOM 对话框——由于它只是 DOM 操作,等待用户输入时页面不会被阻塞。

先在 HTML 中加入对话框的结构和样式:

<style type="text/css"> #dialog { width: 200px; margin: auto; padding: 10px; border: thin solid black; background: lightgreen; } .hidden { display: none; } </style> <div id="dialog" class="hidden"> <div class="message">foobar</div> <input type="text"> <div> <button class="ok">Ok</button> <button class="cancel">Cancel</button> </div> </div>

我们希望保持相同的 API,所以只修改promptPromise的实现:查找对话框 DOM 元素、为元素绑定事件、显示对话框、返回一个由绑定事件决定 resolve 状态的 Promise,最后在结束时解绑事件并自我清理(隐藏对话框以便将来复用):

function promptPromise(message) { var dialog = document.getElementById('dialog'); var input = dialog.querySelector('input'); var okButton = dialog.querySelector('button.ok'); var cancelButton = dialog.querySelector('button.cancel'); dialog.querySelector('.message').innerHTML = String(message); dialog.className = ''; return new Promise(function(resolve, reject) { dialog.addEventListener('click', function handleButtonClicks(e) { if (e.target.tagName !== 'BUTTON') { return; } dialog.removeEventListener('click', handleButtonClicks); dialog.className = 'hidden'; if (e.target === okButton) { resolve(input.value); } else { reject(new Error('User cancelled')); } }); }); }

现在用户按下Set Name按钮后,对话框显示期间时钟依然在走——页面没有被阻塞。

实现细节上有两点值得注意:

  • removeEventListener需要传入与addEventListener同一个函数引用,否则无法解绑。这里使用具名函数handleButtonClicks,让清理代码能引用到它,避免了把引用存储到更高作用域。
  • 为了性能并避免重复代码,示例使用了事件委托:两个按钮共用一个click监听器,通过e.target.tagNamee.target === okButton判断具体点击了哪个按钮。

同样的功能用 jQuery 的事件命名空间可以写得更短,并且用.finally保证"无论成败都解绑":

return new Promise(function(resolve, reject) { $('#okButton').on('click.promptDialog', function() { resolve(input.value); }); $('#cancelButton').on('click.promptDialog', reject); }) .finally(function() { $('#okButton').off('click.promptDialog'); $('#cancelButton').off('click.promptDialog'); });

.finally的语义是"无论 Promise 最终是 fulfilled 还是 rejected,处理器都会被调用,且不会改变最终结果"(除非处理器自身抛错或返回一个被拒绝的 Promise),这与同步代码里的finally关键字行为一致,详见 .finally 文档。

抽象:让 Dialog 成为可复用的类

上面的代码仍有一个明显问题:它"做得太多了"。用squint(眯眼)测试审视一下:显示对话框、设置对话框消息、绑定两个 DOM 事件、构造 Promise、事件委托、隐藏对话框、解绑 DOM 事件——一个函数承担了七八项职责。重构的关键在于抽象

我们将创建一个负责管理对话框的对象(类)。它的接口只需管理两个函数引用(回调):用户点击 ok 时触发、用户点击 cancel 时触发,并在被询问时提供值。这样一来promptPromise不再需要了解任何 DOM 细节,只需专注于提供一个 Promise;同时未来创建进度条、确认框或任何"需要拿到一个值"的 UI 时,只需为该 UI 编写一个实现了相同接口的类即可。

对话框的接口如下:

var noop = function() { return this; }; function Dialog() { this.setCallbacks(noop, noop); } Dialog.prototype.setCallbacks = function(okCallback, cancelCallback) { this._okCallback = okCallback; this._cancelCallback = cancelCallback; return this; }; Dialog.prototype.waitForUser = function() { var _this = this; return new Promise(function(resolve, reject) { _this.setCallbacks(resolve, reject); }); }; Dialog.prototype.show = noop; Dialog.prototype.hide = noop;

初始时Dialog把两个回调设为noop函数;由子类在合适时机调用它们。Promise 的创建被收敛到唯一的waitForUser()方法中——它设置回调并返回 Promise。在这个层次上,show()hide()也只是 noop,留给子类实现。

PromptDialog负责继承Dialog、搭好所需的 DOM 脚手架,并在适当时机调用this._okCallbackthis._cancelCallback

function PromptDialog() { Dialog.call(this); this.el = document.getElementById('dialog'); this.inputEl = this.el.querySelector('input'); this.messageEl = this.el.querySelector('.message'); this.okButton = this.el.querySelector('button.ok'); this.cancelButton = this.el.querySelector('button.cancel'); this.attachDomEvents(); } PromptDialog.prototype = Object.create(Dialog.prototype); PromptDialog.prototype.attachDomEvents = function() { var _this = this; this.okButton.addEventListener('click', function() { _this._okCallback(_this.inputEl.value); }); this.cancelButton.addEventListener('click', function() { _this._cancelCallback(); }); }; PromptDialog.prototype.show = function(message) { this.messageEl.innerHTML = String(message); this.el.className = ''; return this; }; PromptDialog.prototype.hide = function() { this.el.className = 'hidden'; return this; };

注意大多数函数末尾的return this;——这个模式支持方法链式调用(method chaining),马上就会看到它的威力。

使用起来更灵活(尽管更啰嗦):

var output = document.getElementById('prompt'); var prompt = new PromptDialog(); prompt.show('What is your name?') .waitForUser() .then(function(name) { output.innerHTML = String(name); }) .catch(function() { output.innerHTML = '¯\\_(ツ)_/¯'; }) .finally(function() { prompt.hide(); });

show(...)返回this,所以可以一路链到.waitForUser().finally保证了无论用户输入名字还是取消,对话框都会被隐藏清理。

这套抽象还可以继续扩展。例如一个通知型对话框NotifyDialog:它不关心输入,只提供一个 OK 按钮,点击后直接调用_okCallback(不带值):

function NotifyDialog() { Dialog.call(this); var _this = this; this.el = document.getElementById('notify-dialog'); this.messageEl = this.el.querySelector('.message'); this.okButton = this.el.querySelector('button.ok'); this.okButton.addEventListener('click', function() { _this._okCallback(); }); } NotifyDialog.prototype = Object.create(Dialog.prototype); NotifyDialog.prototype.show = function(message) { this.messageEl.innerHTML = String(message); this.el.className = ''; return this; }; NotifyDialog.prototype.show = function() { this.el.className = 'hidden'; return this; };

留给读者的练习

  1. 编写一个函数,接收一个Dialog实例和一个默认值;当用户点击 cancel 时,返回一个 resolve 为默认值的 Promise。
  2. 借助抽象类,能否把PromptDialogNotifyDialog的相似之处抽取出来?请创建Dialog的子类DOMDialog以抽象公共 DOM 代码,然后把PromptDialogNotifyDialog重构为继承DOMDialog,仅引用各自正确的 DOM 选择器。

取消与错误处理:用自定义 Error 区分失败原因

上面的示例缺少了恰当的错误处理。在 Promise 世界中有一条最佳实践:reject 时永远传 Error 对象,而不是普通数据(对象、字符串、数字或 null/undefined)。原因在于:Promise 的用途是让异步代码重新获得与同步try {} catch() {}一致的控制流——同步代码里throw的必然是错误对象,异步代码里被拒绝的"原因"也应如此。

使用Error的另一个优势是:可以测试 Promise 被拒绝的原因并据此做决策。这一能力内建在 Bluebird 的工作方式中——你可以向catch()传入一个谓词(predicate),从而根据拒绝时携带的Error类型挂接多个不同的处理块。例如:

doSomething().then(function(value) { // Do something with value or fail with an error. throw new Error('testing errors'); }) .catch(ArgumentError, function(e) { console.log('You buggered up something with the arguments.', e); }) .catch(SyntaxError, function(e) { console.log('Check your syntax!', e); }) .catch(function(e) { // e is an Error object. console.log('Well something genaric happened.', e); });

在对话框场景中,我们可能想区分"由于某个问题被拒绝"(如 AJAX 失败、编程错误)与"用户按了取消按钮"。做法是准备两个catch():一个处理UserCanceledError,一个处理其他任何Error。自定义错误类型可以这样创建:

function UserCanceledError() { this.name = 'UserCanceledError'; this.message = 'Dialog cancelled'; } UserCanceledError.prototype = Object.create(Error.prototype);

然后在 Dialog 上增加cancel()方法,让取消回调携带这个自定义错误:

Dialog.prototype.cancel = function() { this._cancelCallback(new UserCanceledError()); }; … PromptDialog.prototype.attachDomEvents = function() { var _this = this; this.okButton.addEventListener('click', function() { _this._okCallback(_this.inputEl.value); }); this.cancelButton.addEventListener('click', function() { _this.cancel(); }); };

使用处就可以按错误类型分别处理了,甚至还可以为对话框加一个 5 秒自动取消的超时:

// Timeout the dialog in five seconds. setTimeout(function() { prompt.cancel(); }, 5000); prompt.show('What is your name?') .waitForUser() .then(function(name) { output.innerHTML = String(name); }) .catch(UserCanceledError, function() { output.innerHTML = '¯\\_(ツ)_/¯'; }) .catch(function(e) { console.log('Something bad happened!', e); }) .finally(function() { prompt.hide(); });

Bluebird 过滤式 catch 的底层实现

Bluebird 的过滤式catch(别名.caught)在 src/promise.js 中实现:当传入多个参数时,除最后一个处理函数外的所有参数都被收集为"catch 实例",最终包装为this.then(undefined, catchFilter(...))。真正的过滤逻辑位于 src/catch_filter.js,它依次支持三种谓词:

  • 错误构造函数item === Error || item.prototype instanceof Error时,用e instanceof item判断,命中则调用处理器;
  • 谓词函数:把错误对象传给谓词,返回值(真值)决定是否命中;
  • 对象谓词:对错误对象逐键做宽松相等比较(item[key] != e[key]),全部相等即命中。

此外,.catch的最后一个参数必须是函数,否则 Bluebird 会抛出TypeError。这些规则意味着:自定义错误只要保证prototype instanceof Error(如上面的UserCanceledError),即可被过滤式 catch 识别;谓词与对象写法还能实现更细粒度的模式匹配。相关行为在 test/mocha/catch_filter.js 中有大量测试覆盖。

注意:Bluebird 内置 cancellation 与"用户取消"不是一回事

Bluebird 的 cancellation 是默认关闭的可选功能,需要通过Promise.config({ cancellation: true })启用(配置项详见 Promise.config 文档)。自 3.0 起其语义是"don't care":取消一个 Promise 只意味着它的 then/catch 回调不再被调用(.finally.reflect等极少数处理例外),并不会"撤销"正在进行的操作。

它并不适用于本文描述的用户取消场景——用户取消对话框时,我们恰恰希望waitForUser()返回的 Promise 以UserCanceledError被拒绝,从而触发对应的.catch分支。从源码看,Promise.prototype.cancel在 src/cancel.js 中会沿链向上传播取消信号,finally处理器依然会被调用(因此官方示例中用isCancelled()判断后再隐藏 spinner)。这套机制针对的是"调用方放弃某个异步操作",与"用户在 UI 上主动取消"是两种截然不同的需求,不要混淆。

进度条:用 Promise.race 管理"任务进度 + 用户取消"

当异步任务有能力在完成过程中汇报进度时,很自然会想把进度塞进代表该任务的 Promise 里。但这是一个反模式:Promise 的意义在于"把一个值表现得像自然值一样"(如同普通同步代码),而不是被过度美化的回调管理工具。因此进度不应进入 Promise API 内部——Bluebird 早已弃用了 progression 特性(.progressed.then的第三个进度参数等),并提供迁移指引(见 Progression Migration),推荐的做法就是把进度通过回调在 Promise API 之外管理

进度条对话框与前面所有对话框还有一个关键差异:它展示的是另一个任务的信息,而非用户输入。程序不再等待用户提供值,而是对话框在等待程序提供值(resolved = 100% 完成,rejected = 中途中止)。因此进度条对话框的接口与前文不同,但仍可能包含用户交互(取消按钮)——本质上我们是在同时处理两个 Promise

Bluebird 提供了管理多个 Promise 的机制:

  • Promise.all():接收一个 Promise 数组,返回一个新 Promise,等待所有成员 resolve;任一成员被拒绝则立即拒绝。详见 Promise.all 文档。
  • Promise.race():同样接收 Promise 数组,但不等待全部完成——只要数组里任意一个 Promise 先 fulfilled 或 rejected,结果 Promise 就以该结果结算。这正是我们要的。详见 Promise.race 文档。

一个典型的组合示例:

function showProgress(otherPromise) { var progress = new ProgressbarDialog().show('Uploading…'); return Promise.race([otherPromise, promise.waitForUser()]) .finally(function() { progress.hide(); }); }

进度对话框的 HTML 如下:

<style type="text/css"> #progress-dialog { width: 200px; margin: auto; border: thin solid black; padding: 10px; background: lightgreen; } #progress-dialog .progress-bar { border: 1px solid black; margin: 10px auto; padding: 0; height: 20px; } #progress-dialog .progress-bar>div { background-color: blue; margin: 0; padding: 0; border: none; height: 20px; } </style> <div id="progress-dialog"> <div class="message"></div> <div class="progress-bar"><div></div></div> <div> <button class="cancel">Cancel</button> </div> </div>

JavaScript 与PromptDialog基本相同,只增加一个setProgress()方法:

function ProgressDialog() { Dialog.call(this); this.el = document.getElementById('progress-dialog'); this.messageEl = this.el.querySelector('.message'); this.progressBar = this.el.querySelector('.progress-bar>div'); this.cancelButton = this.el.querySelector('button.cancel'); this.attachDomEvents(); } ProgressDialog.prototype = Object.create(Dialog.prototype); ProgressDialog.prototype.attachDomEvents = function() { var _this = this; this.cancelButton.addEventListener('click', function() { _this.cancel(); }); }; ProgressDialog.prototype.show = function(message) { this.messageEl.innerHTML = String(message); this.el.className = ''; return this; }; ProgressDialog.prototype.hide = function() { this.el.className = 'hidden'; return this; }; ProgressDialog.prototype.setProgress = function(percent) { this.progressBar.style.width = percent + '%'; };

一个常见的误解是"Promise 是回调管理的一种形式"——事实并非如此,这正是进度回调没有进入 Promise 规范的原因。不过,就像 Promise 库在new Promise(…)时传入resolvereject回调一样,我们也可以照搬同样的模式,为进度传入一个进度回调

下面用setTimeout模拟一次耗时的文件上传:期望传入一个函数,每当进度需要更新时调用它,并返回一个 Promise:

function delayedPromise(progressCallback) { var step = 10; return new Promise(function(resolve, reject) { var progress = 0 - step; // So first run of nextTick will set progress to 0 function nextTick() { if (progress >= 100 ) { resolve('done'); } else { progress += step; progressCallback(progress); setTimeout(nextTick, 500); } } nextTick(); }); }

构造ProgressDialog后,用waitForUser()捕获用户交互 Promise,用delayedPromise()捕获"伪造的网络请求"Promise,最后用Promise.race()同时管理两者,并像往常一样以单个 Promise 收尾:

document.addEventListener('DOMContentLoaded', function() { var button = document.getElementById('action'); var output = document.getElementById('output'); var prompt = new ProgressDialog(); button.addEventListener('click', function() { var pendingProgress = true; var waitForPromise = delayedPromise(function(progress) { if (pendingProgress) { prompt.setProgress(progress); } }); // Prevent user from pressing button while dialog is visible. button.disabled = true; prompt.show('Simulating a file upload.'); Promise.race([waitForPromise, prompt.waitForUser()]) .then(function() { output.innerHTML = 'Progress completed'; }) .catch(UserCanceledError, function() { output.innerHTML = 'Progress canceled by user'; }) .catch(function(e) { console.log('Error', e); }) .finally(function() { pendingProgress = false; button.disabled = false; prompt.hide(); }); }); });

Promise.race的底层实现位于 src/race.js:它把传入的数组逐个Promise.cast(val)并挂上_then(fulfill, reject, ...),第一个结算的 Promise 直接决定结果;数组为空时结果 Promise 将永远 pending(源码注释里也直言这一点)。race与取消、取消回调等相关组合行为在 test/mocha/race.js 中有所覆盖。

两个 Promise 的胜负关系:

  • 上传先完成delayedPromise'done'resolve,race立即完成,output显示 "Progress completed";
  • 用户先点 CancelwaitForUser()UserCanceledErrorreject,被第一个.catch(UserCanceledError, ...)捕获,显示 "Progress canceled by user";
  • 其他异常:落入兜底的.catch(function(e) {...})
  • 无论哪种结局,.finally都会把pendingProgress置为 false(停止进度更新)、恢复按钮、隐藏对话框。

小结

本文展示的"对话框即 Promise"模式,其价值不仅在于让用户输入与网络请求共享同一套异步抽象:

  • 模块化Dialog基类把"Promise 的创建"与"UI 的具体形态"分离,新增一个对话框类型只需写一个实现相同接口的子类;
  • 可组合:借助Promise.race,两个相互独立的异步事件(任务进度、用户取消)可以被组合成一个统一的 Promise 流程,用过滤式catch区分失败原因;
  • 可维护:虽然代码看起来比过程式写法啰嗦,但它模块化、易于替换——这种特性正是过程式风格难以企及的。

进一步阅读:本文用到的 new Promise 构造器、.catch 过滤式错误处理、.finally、Promise.all、Promise.race、Cancellation 与 Promise.config 均可在仓库 docs/docs/api 目录下找到完整 API 参考。

  • 后端

【免费下载链接】bluebird

:bird: :zap: Bluebird is a full featured promise library with unmatched performance.

项目地址:https://gitcode.com/gh_mirrors/bl/bluebird
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询