我所理解的 JavaScript 最佳实践(14):确认弹窗关闭时,让等待中的 Promise 得到结果
编辑页还有未保存的内容时,点返回会弹出确认框。用户可以保存后离开、放弃修改,或者留在页面。假如三个按钮都能工作,按 Esc 却只是把弹窗藏起来,离开守卫仍会卡在 await confirmDraft()。弹窗在界面上消失了,等待它的代码还在原地。
先约定每种关闭方式代表什么
确认框交付的是用户的选择。保存有没有成功,要等保存函数返回后才能知道。下面以「关闭等于留在页面」为约定,把按钮和其他关闭入口统一到三个动作。
| 操作 | 确认结果 | 离开守卫的动作 |
|---|---|---|
| 点击「保存后离开」 | save |
等待保存成功,再允许离开 |
| 点击「放弃修改」 | discard |
允许离开,跳过保存 |
| 点击「继续编辑」,或用关闭按钮、Esc、可关闭遮罩结束弹窗 | stay |
取消本次离开 |
| 等待选择时,所属组件被销毁 | stay |
结束还在等待的确认,释放弹窗资源 |
如果产品规定点击遮罩时弹窗继续显示,等待用户选择就是此时的预期。一旦关闭了弹窗,调用方必须收到结果。把关闭都映射成 false 也要看调用约定。只有「继续」和「取消」两个结果时,布尔值够用。上表里「放弃修改」和「留在页面」会走向不同的动作,三个名字能直接表达这种区别。
每次打开都对应自己的 Promise
按钮和关闭事件通常以回调的形式交给组件。可以在这一层用 new Promise 把它们适配成一个可等待的结果。下面的 createDraftDecision 对应一次确认。choose 是给按钮和关闭事件的入口。
function createDraftDecision() {
let resolveChoice;
const result = new Promise(resolve => {
resolveChoice = resolve;
});
function choose(action) {
const resolve = resolveChoice;
if (!resolve) {
return;
}
resolveChoice = undefined;
resolve(action);
}
return { result, choose };
}
点击保存时先调用 choose('save'),再关弹窗。关闭回调即使紧接着调用 choose('stay'),也不会覆盖已经交付的 save。
组件卸载时,对尚在等待的确认调用 choose('stay'),同时解绑它的监听。保存已经开始时,写入有自己的生命周期。能否取消写入由保存接口决定,页面是否还接收结果则由发起保存的一方检查。
连续点返回也要决定如何处理。在同一个编辑对象、同一个离开目标上,可以让重复触发共享一次确认,或在当前守卫结束前挡住重复点击。若每次打开都把 resolver 写进同一个变量,第二次打开会覆盖第一次的 resolver,第一次调用就失去了结算入口。共享确认结果时,由路由守卫统一决定导航,避免多个回调各跳一次。
保存交给等待确认结果的函数
已经能拿到确认 Promise,就可以在外层用 await 串起选择和保存。下例的 saveDraft 需要在保存真正成功时才完成,保存失败则拒绝。
async function decideLeave({ confirmDraft, saveDraft }) {
const action = await confirmDraft();
switch (action) {
case 'save':
await saveDraft();
return true;
case 'discard':
return true;
case 'stay':
return false;
default:
throw new TypeError('Unknown draft action');
}
}
保存失败会让 decideLeave() 拒绝,最外层离开守卫负责提示失败并取消本次导航。如果把这段再包进 new Promise(async (resolve) => ...),构造器不会采用那个异步函数返回的 Promise。await saveDraft() 失败后,内层任务会拒绝,外层 Promise 可能仍在等 resolve。这条差异可以直接对照 ECMAScript 的 Promise 构造算法。
验证时,把操作和等待它的那段代码一起检查。
- 按 Esc 后,守卫应完成并返回
false。 - 保存失败时,外层应收到异常并取消导航。
- 连点两次返回时,检查弹窗、保存和导航分别发生了几次。