Skip to content

关键问题总结与async 与 await(Day17 3/3) ​

本页对应 Day17-Promise,共 5 段视频,约 44 分钟。

学习目标 ​

  • 能够针对包含 Promise、setTimeout、async/await 的代码,正确划分同步任务、微任务与宏任务,并写出最终输出顺序。
  • 能够判断 Promise 回调进入微队列的时机,解释“状态改变后才满足执行条件”以及未改变状态时为何不执行、不阻塞主线程。
  • 能够说明 async 函数中 await 后代码与 Promise.then 回调的等价关系,并判断其作为微任务的执行时机。
  • 能够分析多层嵌套 Promise、多个 then/resolve 调用与零延时定时器混合的代码,逐步列出各轮回调入队顺序并预测输出结果。
  • 能够用事件循环机制解释“同步任务先执行、微任务清空后再执行宏任务”,以及微任务持续产生时宏任务被延后的现象。
  • 能够按“先找同步任务,再确定异步回调入队时机与顺序,最后先微后宏”的步骤复述并校验代码输出。

知识点 ​

先给结论:把 Promise 当“状态机 + 回调队列 + 派生新 Promise” ​

本节不是背 API,而是建立分析模型:Promise 只有 pending、resolved、rejected 三种状态;状态一旦改变就不可逆;then() 会返回一个新 Promise;多个异步任务按“同步任务 → 微任务 → 宏任务”的队列规则执行。转写稿里反复强调:先找同步任务,再找微任务/宏任务,还要看回调函数什么时候满足执行条件,不能只看它“是不是微任务”。

1. 如何改变 Promise 的状态 ​

为什么需要:只有理解状态怎么改,才能判断回调会不会执行、什么时候执行。
怎么想:Promise 初始是 pending,只有 pending 才能被改成 resolved 或 rejected;已经确定的状态不会再变。
怎么做:resolve(value) 把 pending 改为 resolved;reject(reason) 把 pending 改为 rejected;在执行器里抛出异常,也会把 pending 改为 rejected。
易错点:多次调用 resolve/reject,只有第一次生效;如果 Promise 永远 pending,它对应的 then 回调“这辈子都不会输出”,但也不会阻塞主线程。转写稿里特意用 pending 的例子说明:不改变状态,就不进微队列,主线程不会等它。

2. 一个 Promise 指定多个成功/失败回调,都会调用吗 ​

为什么需要:实际开发中同一个异步结果可能被多处订阅。
怎么想:Promise 实例可以挂多个 then,当实例变为对应状态时,对应的成功或失败回调都会调用。
怎么做:正常写法是先指定回调,再改变状态;也可以先改状态再指定回调,因为状态已经确定,后注册的回调仍能拿到结果。转写稿提到“先改状态再指定回调”的两种来源:执行器里直接 resolve()/reject(),或者延迟更久才调用 then()。
易错点:不要以为“先指定回调”就一定先执行;关键看状态何时改变、回调何时满足执行条件。先指定回调时,状态改变后回调得到数据;先改状态时,指定回调时回调得到数据。

3. then() 返回的新 Promise 结果状态由谁决定 ​

为什么需要:链式调用的核心就是 then() 派生新 Promise。
怎么想:新 Promise 的状态由 then() 里回调函数的执行结果决定,不由原 Promise 直接决定。
怎么做:没写返回值,新 Promise 为 resolved,value 是 undefined;返回非 Promise 的任意值,新 Promise 为 resolved,value 是该值;返回另一个 Promise,新 Promise 跟随它的结果;抛出异常,新 Promise 为 rejected,reason 是异常。
易错点:then 里 return 一个 pending Promise,后续链会一直等;这不是“返回 undefined 继续走”。这也是后面“中断 Promise 链”的基础。

4. 串联、异常传透与中断 Promise 链 ​

为什么需要:多个异步任务要按顺序串起来,并且统一处理异常。
怎么想:then() 返回新 Promise,所以可以写成链式调用;前面任何操作出异常,会跳过中间成功回调,传到最后失败回调处理。
怎么做:用 then() 链串任务,在链尾指定失败回调;要中断链,就在回调里返回一个 pending 状态的 Promise。
易错点:异常传透不是“吞掉异常”,而是沿链寻找失败处理;中断链不是 return undefined,那会让新 Promise 变成 resolved,后续仍会执行。

5. 用转写稿的调试法看输出顺序 ​

为什么需要:面试题常把同步、微任务、宏任务、async/await 混在一起。
怎么想:先找同步任务;再找微任务(then 回调、await 后代码);最后找宏任务(定时器)。同时检查回调是否立即满足执行条件。转写稿里 await 后的代码相当于写在 then 回调里,所以看起来像同步,实际是微任务。
怎么做:例如面试题 01 的分析是:同步输出 3,微任务输出 2、4,宏任务输出 1,所以结果是 3241。类似地,25341、41368275 都要按“同步先走完,再清微任务,再取宏任务”验证。
易错点:微任务不是“创建即执行”,要状态改变后才进队列;宏任务也不是“永远最后”,只是优先级低于当前微任务队列。纠正转写稿同音字:permiss 是 Promise,毁调 是回调,红任务 是宏任务,主塞 是阻塞。

先看结论:async/await 是 Promise 的语法糖 ​

为什么需要:Promise 的链式调用能解决回调地狱,但 .then().then() 写多了仍然不够直观。async 和 await 让我们用一种更简洁的方式写出基于 Promise 的异步行为,而无需刻意地链式调用。
怎么想:async 声明一个函数,函数返回 Promise;await 等待一个 Promise 状态改变,并把“等待之后的代码”放到类似 .then 回调的位置执行。
怎么做:先记住一句话:await 后面的代码不是同步代码,而是微任务;async 函数被调用时,函数体仍会同步执行到第一个 await。
易错点:不要把 async 理解成“整个函数都异步执行”,也不要把 await 后面的 console.log 当成同步输出。

怎么想:async 函数调用是同步开始的 ​

为什么需要:很多执行顺序题错在把 async 函数调用当成了“立刻跳到后面”。实际上,调用 async 函数时,函数体会先同步执行,直到遇到 await 才暂停后续代码。
怎么想:async function FN1() 被调用时,console.log(1) 会先执行;执行到 await FN2() 时,先同步调用 FN2(),所以 FN2 里的 console.log(3) 也会先执行。
怎么做:按转写稿中的 FN1/FN2 面试题整理成下面代码:

js
async function FN1() {
  console.log(1);
  await FN2();
  console.log(2);
}

async function FN2() {
  console.log(3);
}

console.log(4);

setTimeout(() => {
  console.log(5);
}, 0);

FN1();

new Promise((resolve) => {
  console.log(8);
  resolve();
}).then(() => {
  console.log(7);
});

console.log(6);

易错点:FN1() 调用后不是直接输出 2;await FN2() 会先执行 FN2(),输出 3。FN2 没有写 return,它返回的 Promise 状态是成功,所以 await 后面的 console.log(2) 会很快满足执行条件,进入微队列等待。

怎么做:先同步、再微任务、最后宏任务 ​

为什么需要:执行顺序题必须区分同步任务、微任务、宏任务,还要看回调什么时候满足执行条件。转写稿里反复强调:除了区分微队列和宏队列,还要看“什么时候满足执行条件”。
怎么想:先找同步任务。console.log(4)、FN1 中的 console.log(1)、FN2 中的 console.log(3)、new Promise 执行器里的 console.log(8)、最后的 console.log(6) 都是同步输出。
怎么做:同步阶段得到:

text
4 1 3 8 6

微任务有 await 后面的 console.log(2),以及 .then 里的 console.log(7)。FN2 返回的成功 Promise 让 2 先进入微队列,new Promise 中 resolve() 后 .then 的 7 进入微队列,所以微任务按进入顺序输出:

text
2 7

setTimeout 是宏任务,最后输出 5。最终顺序为:

text
4 1 3 8 6 2 7 5

易错点:不要看到 await 就认为 2 立刻同步输出;也不要认为微任务一定“快”,如果它进入微队列晚,仍然会排在后面。转写稿中“红任务”应纠正为“宏任务”,“微阵/微距”应理解为“微队列”。

调试过程:await 后代码相当于 then 回调 ​

为什么需要:理解 await 的本质,才能解释为什么 2 是微任务。转写稿说得很直接:await 后面的代码相当于写在 Promise 对象的 .then 回调里。
怎么想:可以把

js
await FN2();
console.log(2);

近似理解为:

js
FN2().then(() => {
  console.log(2);
});

怎么做:调试时先标同步输出,再标微任务进入时机,最后标宏任务。转写稿中“as think”是 async,“avit/abit”是 await,“permiss”是 Promise,“毁调”是“回调”,“主线成”是“主线程”,“非主塞”是“非阻塞”。
易错点:如果 await 等待的 Promise 永远 pending,它后面的代码不会执行,但不会阻塞主线程。转写稿用“这辈子状态都不会改”说明过这一点:不改变状态就不进入微队列,主线程空下来也不会等它,整体仍然是非阻塞的。

易错点总结 ​

  1. async 函数调用本身是同步开始的,不是整个函数异步执行。
  2. await 后面的代码是微任务,类似 .then 回调。
  3. Promise 构造器里的执行器函数是同步执行的,所以 console.log(8) 先输出。
  4. .then 回调是微任务,setTimeout 是宏任务。
  5. 微任务也要看进入队列的时机,不是所有微任务都“天然靠前”。
  6. 永远 pending 的 Promise 不会阻塞主线程,只是它后面的回调不执行。

先分清同步任务和异步任务 ​

为什么需要:JS 是单线程执行,如果所有回调都立刻排队执行,就会阻塞后续代码。把任务分成同步和异步,才能做到“非阻塞”。怎么想:拿到一段代码,不要先纠结微任务宏任务,先找同步任务——第一次执行时立即执行的语句。怎么做:同步任务先跑完;异步任务的回调进入不同队列等待。转写稿里反复强调“先找同步任务”,比如 Promise 的执行器函数、async 函数调用本身,都是同步执行的。易错点:不要看到回调就认定是异步;new Promise(...) 的执行器是同步的,async 函数在遇到 await 之前也是同步执行的。

微队列和宏队列分别装什么 ​

为什么需要:异步回调的优先级不同,必须分队列管理。怎么想:看回调来源。微队列用来保存待执行的微任务回调,比如 Promise 的回调、MutationObserver 的回调;宏队列按最新 W3C 标准分为交互队列(DOM 事件回调)和延迟队列(定时器回调、Ajax 回调)。怎么做:面试题里常见的是定时器进入延迟队列,Promise 的 then 回调进入微队列。执行优先级是:微队列 > 交互队列 > 延迟队列。易错点:不要把“宏任务”只理解成定时器;标准里宏队列还分交互队列和延迟队列,只是很多题里出现的宏任务都是定时器,所以表现为“微任务先于宏任务”。

执行顺序规则:同步先完,微任务清空,再取宏任务 ​

为什么需要:只有统一规则,才能预测输出顺序。怎么想:JS 引擎首先必须执行所有初始化同步任务代码;每次准备取出第一个宏任务执行前,都要把微队列里的微任务一个一个取出来执行。怎么做:先同步,再看微队列,微队列清空后才取宏任务。易错点:微任务执行过程中如果又产生新的微任务,也要在本轮继续清空;宏任务不能插队到未清空的微队列前面。

执行时机比任务类型更关键:面试题 01 的 3241 ​

为什么需要:只知道“微任务优先”不够,还要看回调什么时候满足执行条件。怎么想:面试题 01 中,输出 3 是同步任务;输出 2 和 4 是 Promise 回调,因为 Promise 创建之初状态就确定了,回调立即满足条件,进入微队列;输出 1 是定时器,进入延迟队列。怎么做:同步先输出 3,然后微队列输出 2、4,最后宏任务输出 1,所以答案是 3241。转写稿还做了一个调试:在输出 4 后面套一个状态永远不改变的 Promise,结果 4 不输出,也不会阻塞程序。这说明“你是微任务”不等于“你一定很快执行”,状态不改变就进不了微队列。易错点:以为微任务一定先执行;实际上它得先满足条件进入队列,程序主打非阻塞,没有人会一直等一个不改变状态的 Promise。

面试题 02、03:谁先改状态,谁先进微队列 ​

面试题 02 的输出是 25341:2、5 是同步;3、4 是 then 回调,状态成功,进入微队列;1 是定时器宏任务。易错点是漏掉 then 返回的新 Promise 也会让后续回调满足条件。

面试题 03 更绕:first 是箭头函数,返回一个 Promise;同步先输出 3、7、4。里面有两个 resolve,但只有第一个生效,所以先输出 1,再输出 2;first 返回的 Promise 要到后面才改变状态,因此 1 比 2 先进入微队列;定时器里的 5 最后输出。怎么想:不要按代码位置硬排,要看“谁的状态先改变,谁先进入微队列”。易错点:两个 resolve 都以为会执行;忽略箭头函数省略 return 时,表达式整体就是返回值。

多轮微任务:面试题 04 的 238456 和最后的 0 ​

为什么需要:微任务不是一次性全部进队列,有些要等前面的回调执行时才满足条件。怎么想:同步任务执行过程中,第一轮进入微队列的是 28 到 38 行的函数和输出 8 的回调,所以先输出 2、3、8;执行 2 时又 new Promise 输出 3,并让 34 到 36 行的回调进入微队列,输出 4;38 到 40 行的函数执行结束后,6 进入微队列;5 要等 4 执行完才进入,最后输出 5。微队列清空后,才轮到定时器的 0。怎么做:按“第几轮进入微队列”画图,不要只按代码上下顺序排。易错点:把 5 提前;忽略微任务执行中还会产生新微任务。

async/await 的陷阱:面试题 05 的 41368275 ​

为什么需要:await 后面的代码看起来像同步,其实相当于放进 Promise 的 then 回调里。怎么想:FN2 返回 Promise,状态立即成功,所以 await 后面的输出 2 是微任务;同步任务先输出 4、1、3、8、6;微任务输出 2、7;定时器 5 最后。怎么做:把 await 后代码视为微任务,但 async 函数调用本身仍同步执行到 await。易错点:把 await 后代码当同步;忘记 FN2 没写 return 时返回的 Promise 状态仍是成功。

易错点收束 ​

判断顺序时固定四步:先找同步任务;再找微任务和宏任务来源;再看回调是否立即满足执行条件;最后看微任务是否分多轮进入。记住:微队列 > 交互队列 > 延迟队列;每次取宏任务前清空微队列;状态不改变的回调不会进队列,也不会阻塞主线程。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

代码示例 ​

示例 1:同步任务、微任务、宏任务的执行顺序 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>Promise 微任务 / 宏任务执行顺序</title>
</head>
<body>
  <script>
    console.log(3);                                  // ① 同步任务

    Promise.resolve().then(() => console.log(2));    // ② 微任务(状态已确定)

    setTimeout(() => console.log(1));                // ③ 宏任务(延迟队列)

    Promise.resolve().then(() => console.log(4));    // ④ 微任务(状态已确定)

    // 永远 pending 的 Promise:then 回调永远进不了微队列
    const p = new Promise((resolve, reject) => {
      // 既不 resolve 也不 reject,状态一直 pending
    });
    p.then(() => console.log('永远不会输出'));
  </script>
</body>
</html>

输出结果:3 2 4 1

关键行与易错点:

  • console.log(3) 是最外层的同步代码,JS 引擎首先执行全部同步任务,所以 3 最先输出。
  • Promise.resolve() 在创建的那一刻状态就已经是 resolved,因此它的 .then 回调立即满足执行条件,进入微队列;两个微任务按注册顺序排队,得到 2、4。
  • setTimeout 的回调进入延迟队列(宏任务),必须等所有微任务清空后才执行,所以 1 最后输出。
  • 执行优先级是:微队列 > 交互队列 > 延迟队列,并且每次取第一个宏任务前,都要把微队列一个一个取完。
  • 易错点:const p = new Promise(() => {}) 这个实例状态永远是 pending,它的 then 回调根本不会进入微队列,所以永远不会输出;但它也不会阻塞主线程——JS 是"非阻塞"的,不会等一个永远不改变状态的 Promise。

示例 2:一个 Promise 实例指定多个回调 + 先改状态再指定回调 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>一个 Promise 多个回调</title>
</head>
<body>
  <script>
    const p = new Promise((resolve, reject) => {
      console.log(2);                      // ① 执行器函数是同步执行的
      resolve('ok');                       // ② 立即把状态改为 resolved
      setTimeout(() => console.log(1));    // ③ 宏任务,进延迟队列
    });

    p.then(() => console.log(3));          // ④ 微任务
    p.then(() => console.log(4));          // ⑤ 微任务

    console.log(5);                        // ⑥ 同步任务
  </script>
</body>
</html>

输出结果:2 5 3 4 1

关键行与易错点:

  • new Promise(executor) 里的执行器函数是同步执行的,所以 2 在同步阶段就输出了,不要误以为是异步。
  • resolve('ok') 把状态从 pending 改为 resolved;同一个 Promise 实例上注册的多个成功回调都会被调用(3 和 4 都会执行),并按注册顺序进入微队列。
  • console.log(5) 属于同步任务,同步代码优先于所有微任务,所以 5 排在 3、4 之前。
  • 先改状态再指定回调也没问题:本例中 resolve 先执行、then 后注册,回调依然会被调用;反过来"先指定回调、后改状态"同样成立——只要状态变了,回调就会执行并拿到数据。
  • 易错点:定时器写在执行器里,但 console.log(1) 的回调是宏任务,哪怕延迟是 0,也要等微任务 3、4 全部清空后才执行,所以 1 一定在最后。

示例 3:async / await 与事件循环 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>async / await 执行顺序</title>
</head>
<body>
  <script>
    async function fn2() {
      console.log(3);                     // ① 同步执行(调用 fn2 时立即执行)
    }

    async function fn1() {
      console.log(1);                     // ② 同步执行
      await fn2();                        // ③ await 后面的代码相当于 then 回调
      console.log(2);                     // ④ 微任务!
    }

    console.log(4);                       // ⑤ 同步任务,最先输出
    fn1();                                // ⑥ 调用 async 函数,同步执行到 await
    console.log(8);                       // ⑦ 同步任务

    new Promise((resolve) => {
      console.log(6);                     // ⑧ 执行器同步执行
      resolve();
    }).then(() => console.log(7));        // ⑨ 微任务

    setTimeout(() => console.log(5));     // ⑩ 宏任务

    // 最终输出:4 1 3 8 6 2 7 5
  </script>
</body>
</html>

输出结果:4 1 3 8 6 2 7 5

关键行与易错点:

  • async 函数被调用时,函数体是同步执行的,一直执行到第一个 await 才挂起,所以 1、3 都在同步阶段输出。
  • await 后面的代码相当于写在该 Promise 的 .then 回调里,是微任务。所以 console.log(2) 虽然看起来像同步代码,实际上要等所有同步任务跑完才输出。
  • fn2 没有 return,返回 undefined,await 得到的是非 Promise 值——但 await 依然会让出线程,后续代码照样进微队列。
  • 微任务的排队顺序:2 在 await 挂起时就已入队,7 在 new Promise(...).then 时才入队,所以 2 先于 7。
  • 易错点:console.log(8) 和 new Promise 执行器里的 console.log(6) 都是同步代码,会先于微任务 2、7 输出;setTimeout 的 5 是宏任务,必须等微队列完全清空后才执行,所以永远排在最后。

常见错误 ​

  1. 现象: 把 new Promise(executor) 里的执行器当成异步代码,先排微任务。
    原因: 执行器是同步执行的,只有 then/catch/finally 注册的回调才按队列调度。
    修法: 分析输出顺序时先标同步代码,再单独标回调入队时机。

  2. 现象: 看到 then 就认为一定最后执行,或看到 setTimeout 就认为一定晚于所有 Promise。
    原因: 只看任务类型,没看 Promise 状态何时改变、回调何时满足入队条件。
    修法: 先判断状态改变时机,再判断回调进入微队列/宏队列的轮次。

  3. 现象: then 链中后续拿到 undefined,或后续任务莫名不执行。
    原因: 上一个回调漏写 return,或返回了 pending Promise。
    修法: 明确每个 then 的返回值;需要中断链时显式返回 pending Promise,不要误写 return undefined。

  4. 现象: 链式调用中异常没有被捕获,或中间 catch 后后续逻辑状态混乱。
    原因: 未利用异常穿透在末尾统一处理,或局部捕获后没有恢复链状态。
    修法: 默认在链末尾加 catch/finally;只有需要局部兜底时才在中间 catch 并明确返回值。

  5. 现象: 把 await 后面的代码当同步代码分析。
    原因: await 后续逻辑相当于进入 Promise 回调续体,通常按微任务调度。
    修法: 以 await 为分界分段分析,先执行 await 前同步部分,再排后续微任务。

练习 ​

  1. 写出输出顺序:

    js
    new Promise((resolve) => &#123;
      console.log(1);
      resolve(2);
    &#125;).then((v) => console.log(v));
    setTimeout(() => console.log(3), 0);
    console.log(4);
  2. 写出输出顺序并说明每个 then 的结果来源:

    js
    Promise.resolve(1)
      .then(() => &#123; console.log(2); &#125;)
      .then((v) => console.log(v))
      .then(() => &#123; throw new Error('x'); &#125;)
      .catch((e) => console.log(e.message))
      .finally(() => console.log('f'));
  3. 分析下面代码为什么 ok 不输出,并给出一种“可控地中断 Promise 链”的改法:

    js
    async function f() &#123;
      const p = new Promise(() => &#123;&#125;);
      await p;
      console.log('ok');
    &#125;
    f();
  4. 写出输出顺序:

    js
    async function async1() &#123;
      console.log('A');
      await async2();
      console.log('B');
    &#125;
    async function async2() &#123;
      console.log('C');
    &#125;
    console.log('D');
    setTimeout(() => console.log('E'), 0);
    async1();
    Promise.resolve().then(() => console.log('F'));
    console.log('G');
  5. 简答:在 then 链中间停止后续回调,且不进入失败回调,应返回什么?写出关键代码。

原视频对照 ​

#视频时长
1面试题0105:10
2面试题0204:16
3面试题0313:00
4面试题0414:53
5面试题0507:15

小结 ​

  1. 分析异步输出顺序的固定流程是:先同步,再异步;异步中再分微任务、宏任务,并检查回调入队时机。
  2. executor 同步执行,then/catch/finally 回调按状态改变和队列规则调度,不能只凭书写位置判断。
  3. then 链的新 Promise 状态由回调的返回值、返回 Promise、抛错共同决定,漏 return 是高发问题。
  4. 异常穿透适合末尾统一 catch;需要中断链时应返回 pending Promise,而不是普通值。
  5. async/await 分析要以 await 为分界,await 后续代码不能按同步代码排。
  6. 微任务会优先清空,且执行中可继续产生微任务;宏任务要等微队列空后才取。
  7. 面试题建议按“标同步→列回调→判状态改变时机→判入队轮次→按优先级输出”作答,减少凭感觉排序。