跳到正文

我所理解的 JavaScript 最佳实践(14):确认弹窗关闭时,让等待中的 Promise 得到结果

我所理解的 JavaScript 最佳实践

  1. 尽量去掉只用一次的中间变量
  2. 遍历的起点决定按 ID 取数的顺序
  3. 不用 reduce
  4. 按遍历任务选择循环和数组方法
  5. 异步遍历按任务依赖选择并发或串行
  6. 卫语句让主流程保持平直
  7. 用函数给可复用的业务规则命名
  8. 让对象字段保持有序
  9. 正确写注释
  10. 一行代码只做一件事
  11. 给函数起合适的名字
  12. 用空值合并运算符更好地控制默认值生效逻辑
  13. 用队列串行处理偏好更新,避免修改互相覆盖
  14. 确认弹窗关闭时,让等待中的 Promise 得到结果(当前)
  15. 用独立的预期值捕获实现错误

编辑页还有未保存的内容时,点返回会弹出确认框。用户可以保存后离开、放弃修改,或者留在页面。假如三个按钮都能工作,按 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。
  • 保存失败时,外层应收到异常并取消导航。
  • 连点两次返回时,检查弹窗、保存和导航分别发生了几次。