Skip to content

事件循环与 Promise:按因果关系读懂执行顺序 ​

异步代码最值得掌握的不是几十道输出顺序谜题,而是两件事:后续代码在什么条件下可以执行,以及它的结果会传给谁。事件循环回答第一件事,Promise 链回答第二件事。

浏览器的任务与微任务 ​

浏览器安排脚本、计时器、事件等任务。一个任务中的同步 JavaScript 运行结束后,在相应的微任务检查点处理微任务;处理期间加入的微任务还会继续处理。浏览器还会在合适时机更新渲染,并非每完成一个任务必定绘制一帧。

js
console.log('A');
setTimeout(() => console.log('D'), 0);
Promise.resolve().then(() => {
  console.log('B');
  queueMicrotask(() => console.log('C'));
});
console.log('A2');
// A → A2 → B → C → D

计时器先注册,不代表先于 Promise 回调运行。当前脚本先完成;已兑现 Promise 的 reaction 进入微任务处理;新增微任务也在本次检查点处理;随后计时器任务才有机会执行。

HTML 有多个任务来源,不能把所有网络、计时器、交互都画成一个全局 FIFO 队列并宣称严格先来先到。不同窗口、Worker、宿主也有不同事件循环。这里的顺序只讨论同一浏览器 agent 中的上述片段。

状态落定与解析锁定 ​

Promise 状态是 pending、fulfilled、rejected。fulfilled/rejected 合称 settled,状态落定后不会改变。但调用 resolve(otherPromise) 不一定立即 fulfilled:它可能锁定跟随另一项尚未完成的结果。

js
let finish;
const inner = new Promise(resolve => { finish = resolve; });
const outer = new Promise((resolve, reject) => {
  resolve(inner);
  reject(new Error('太迟了'));
});
outer.then(console.log);
finish('完成'); // 最终输出 完成

第一次 resolve 已经决定外层跟随 inner,后面的 reject 无效,但在 finish 前 outer 仍等待结果。由此可见 resolved 不应机械翻译成“已成功”。

Promise 还会吸收 thenable,也就是有可调用 then 的对象。读取 then 属性可能同步执行 getter;调用取得的 then 方法则由 Promise job 安排。恶意 thenable 多次 resolve/reject,最终只能采用第一次有效决定。直接让 Promise 解析为自身会拒绝为 TypeError。

把 thenable 的解析过程拆开观察 ​

js
const events = [];
const candidate = {
  get then() {
    events.push('取得then');
    return function (resolve, reject) {
      events.push('调用then');
      resolve(7);
      reject(new Error('第二次决定'));
      throw new Error('决定之后抛错');
    };
  }
};
const promise = Promise.resolve(candidate);
events.push('resolve已返回');
const value = await promise;
console.log(value); // 7
console.log(events.join(' → ')); // 取得then → resolve已返回 → 调用then

先读取 then 属性以确定它是否可调用,所以 getter 在 Promise.resolve 返回之前执行。获得可调用方法之后,语言安排一个 job,稍后才调用它,并交给它一对只接受首次决定的函数。resolve(7) 已经决定结果,之后 reject 无效;随后抛错虽会被处理为一次拒绝尝试,也无法推翻已决定的成功。

若 then 属性不是函数,这个普通对象本身就是成功值。若读取 then 的 getter 抛错,得到的是拒绝 Promise。若调用 then 时还没作出决定便抛错,也会拒绝。若 then 不调用任何一方而只是 return 7,那个返回值不会成为 Promise 成功值,结果仍可一直 pending。thenable 的契约是调用传入的函数,而非像 then 的处理器那样用 return 交付结果。

这最后一点特别容易混淆:p.then(handler) 中 handler 的返回值参与下游决议;自定义 then(resolve, reject) 方法自己的返回值则不参与其被吸收的结果。这是两个不同层次的函数调用。

参数求值在 Promise.resolve 之前 ​

js
function fail() { throw new Error('参数阶段'); }
try {
  Promise.resolve(fail()).catch(() => console.log('不会走这里'));
} catch (error) {
  console.log(error.message); // 参数阶段
}
await Promise.resolve().then(fail).catch(error => console.log(error.message));
// 参数阶段

第一段先执行 fail,尚未调用 Promise.resolve 就已抛错;第二段把函数交给 then,异常发生在处理器运行期间,才会转为下游拒绝。不是写在 Promise 字样附近的任何异常都归 Promise 捕获。

每个 then 都产生新 Promise ​

js
Promise.resolve(5)
  .then(n => n * 2)
  .then(n => { throw new Error(`超过 ${n}`); })
  .catch(error => error.message)
  .then(console.log); // 超过 10

第一个回调返回 10,下游成功值是 10;第二个抛错,下游变为拒绝;catch 返回字符串,表示恢复为成功。处理器返回 Promise/thenable 时,下游等待它;没有返回值时,下游成功值为 undefined;缺少相应处理器时,成功值或拒绝原因继续传播。

因此 .then(() => { load(); }) 与 .then(() => load()) 不同。前者丢失 load 的结果和失败关联,后一条链才会等待它。多个 p.then(...) 是从同一个 p 分叉,不是彼此串联。

then(success, failure) 的 failure 处理的是上游失败,捕获不到同一个 success 内抛出的异常;要处理它,应接后续 .catch(...)。catch 如果只打印日志不再 throw,就是恢复成功,不是“错误自动继续上报”。

一个上游可以长出两条完全不同的结果链 ​

js
const original = Promise.resolve(4);
const left = original.then(value => value * 2);
const right = original.then(() => { throw new Error('右侧失败'); });
const results = await Promise.allSettled([left, right]);
console.log(results[0].status, results[0].value); // fulfilled 8
console.log(results[1].status, results[1].reason.message); // rejected 右侧失败

original 的成功值仍是 4;left、right 是两个新 Promise。右侧失败不修改 original,也不让左侧失败。给 left 加 catch 无法处理 right,就像给两条不同函数调用之一加 try,不能覆盖另一条未来执行路径。

判断链是否完整时,可以把每个 then 的返回值命名,再画箭头:哪个 Promise 的状态触发当前处理器,当前处理器的结果又决定哪个 Promise。遗漏 return 常常不是风格问题,而是剪断了这条箭头。回调仍会执行,却已不在外层所等待的完成条件里。

finally 是清理,不是替换结果 ​

js
Promise.resolve('正文')
  .finally(() => '忽略这个返回值')
  .then(console.log); // 正文

正常返回的 finally 不改变成功值或拒绝原因,而且不接收这个结果参数。但 finally 抛错或返回拒绝 Promise,会让后续链采用这个新的失败;返回尚未完成的 Promise 会延后后续链。清理失败可能遮住原始失败,需要根据场景保留原因。

没人及时处理的拒绝由宿主报告;浏览器可以发出 unhandledrejection,Node 的策略另有环境约定。全局监听用于诊断,不能代替每条业务链明确消费结果。特别留意“启动了 Promise,但既没 return 也没 await”的游离工作。

清理失败会成为最终失败,需要有意识地保存原原因 ​

js
const original = new Error('读取失败');
const cleanup = new Error('关闭失败');
const retained = await Promise.reject(original)
  .finally(() => {})
  .catch(error => error);
const replaced = await Promise.reject(original)
  .finally(() => { throw cleanup; })
  .catch(error => error);
console.log(retained === original, replaced === cleanup); // true true

finally 的正常返回表示“清理完成,原结果继续”;异常返回表示“清理本身失败”,因此新失败覆盖原结果。若诊断上两个原因都重要,需要在清理代码或更高层显式保留它们,例如汇总错误或通过 cause 表达关联。不要用一个总是抛新错误的 finally,无意丢失最先导致操作失败的信息。

练习 ​

  1. 为什么 resolve(inner) 之后 outer 还能 pending,却不接受后续 reject?自定义 thenable 只 return 7、没有调用 resolve,结果又如何?

  2. then(success, failure) 为什么捕获不到 success 自己抛出的错误?

  3. finally 返回 99 与 finally 抛 Error,分别怎样影响下游?

  4. 某函数写成 Promise.resolve(text).then(value => { Promise.resolve(value).then(JSON.parse); }),调用方得到 undefined,解析失败也未连到主链。写 parseLater(text) 修复它,不用 async/await;合法 JSON 返回解析后的值(包括对象、null、数字或布尔值),非法 JSON 由调用方 catch 捕获。解释删除哪一个 return 会再次断链。

查看答案

用心记录每一步成长