聊一聊:一道 Promise 链式调用的题目

时间:2022-07-22
本文章向大家介绍聊一聊:一道 Promise 链式调用的题目,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

问题

这是最近几天在掘金沸点看到的一道题目:

new Promise((resolve,reject) => {
    console.log('外部promise')
    resolve()
})
    .then(() => {
    console.log('外部第一个then')
    new Promise((resolve,reject) => {
        console.log('内部promise')
        resolve()
    })
        .then(() => {
        console.log('内部第一个then')
        return Promise.resolve()
    })
        .then(() => {
        console.log('内部第二个then')
    })
})
    .then(() => {
    console.log('外部第二个then')
})
    .then(() => {
    console.log('外部第三个then')
})
    .then(() => {
    console.log('外部第四个then')
})

// 输出结果是什么?

第一眼看到的时候,你觉得输出结果是什么呢?可以先花几分钟仔细想一想

公布答案:

外部promise
外部第一个then
内部promise
内部第一个then
外部第二个then
外部第三个then
外部第四个then
内部第二个then

不知道你有没有猜对?反正我猜错了。一开始我还以为是常规的 EventLoop 题目,无非就是考链式调用。但事实证明,它没有看上去那么简单。当时心里想的是,好奇怪,怎么和预想的不一样呢?

吃个午饭回来,本想继续看评论里有没有大神指点迷津或者是一起讨论下这道题,没想到的是,大神没出现,倒是出现了不少冷嘲热讽的人,大意是“写这样的代码就是菜,没有意义,不要浪费别人的时间”。又过了几分钟,发现楼主已经把帖子给删了。

…… 一时之间不知道说什么好,等到文章结束再来聊聊吧,我们还是先回到问题上。尽管这样的代码可能只是“为了面试而生”的,但我还是想弄清楚是怎么一回事,为何结果与猜想的不一样,于是这几天一直在翻阅网上的资料,请教网友们。到了今天,算是有点眉目了,所以在这里记录一下具体的分析过程。

注意:

  • 问题的解答来源于网上的相关文章和回答,我只是在此基础上整理分析思路和过程
  • 文章不会讨论 Promise/A+ 实现,ECMAScript 规范解读,webkit 源码等内容,但底下会有相关链接,想继续深挖的朋友可以看看

先从简单的开始分析

在讨论这段代码之前,我们先从一段相对简单的代码开始分析:

new Promise((resolve,reject)=>{
   console.log("promise1")
   resolve( )
})
.then(()=>{
   console.log("外部第一个then")
   new Promise((resolve,reject)=>{
     console.log("promise2")
     resolve()
   }).then(()=>{
     console.log("内部第一个then")
   }).then(()=>{
     console.log("内部第二个then")
   })
})
.then(()=>{
console.log("外部第二个then")
})

先说几个基本的结论:

  • then 的回调到底什么时候进入队列? 调用 then,里面的回调不一定会马上进入队列
    • 如果 then 前面的 promise 已经被 resolve ,那么调用 then 后,回调就会进入队列
    • 如果 then 前面的 promise 还没有被 resolve ,那么调用 then 后,回调不会进入队列,而是先暂时存着,等待 promsie 被 resolve 之后再进队列。
  • then 前面的 promise 怎么才算被 resolve 呢?
    • 如果 promsie 是实例化形成的,那么调用 resolve() 后它就被 resolve
    • 如果 promise 是 then 返回的,那么 then 的回调执行完毕之后它就被 resolve 了。
  • promise 被 resolve 之后会做什么?
    • 会把此前和该 promise 挂钩的 then 的回调全部放入队列

明确这几点之后,我们再来逐步分析这段代码:

  1. 执行宏任务,实例化 Promise,打印 promise1,之后调用了 resolve,该 promise 被 resolve
  2. 外部第一个 then 执行,对应的回调马上进队列
  3. 外部第二个 then 执行,但是由于外部第一个 then 的回调还没执行,所以它返回的 promise 还没 resolve,所以外部第二个 then 的回调暂时放着,不进队列
  4. 执行微任务,即外部第一个 then 的回调,打印 外部第一个 then
  5. 实例化第二个 Promsie,打印 promise2,之后调用了 resolve,该 promise 被 resolve
  6. 内部第一个 then 执行,对应的回调马上进队列
  7. 内部第二个 then 执行,但是由于内部第一个 then 的回调还没执行,所以内部第一个 then 返回的 promsie 还没 resolve,导致内部第二个 then 执行的回调暂时放着,不进队列
  8. 到这里,外部第一个 then 的回调其实已经执行完毕,所以外部第一个 then 返回的 promsie 被 resolve了,一旦被 resolve,和它挂钩的 then 的回调全部放入队列,所以外部第二个 then 的回调进队列
  9. 执行宏任务,无宏任务
  10. 执行微任务,队头是内部第一个 then ,于是打印 内部第一个 then,由于内部第一个 then 的回调执行完毕,所以它返回的 promise 被 resolve 了,使得内部第二个 then 的回调进入队列
  11. 接着继续按队列执行,打印 外部第二个then,使得这个 then 返回的 promise 被 resolve,不过它没有后续的 then ,所以不管它接着继续按队列执行,打印最后的 内部第二个then

综上,执行顺序为:

promise1  
外部第一个then  
promise2   
内部第一个then  
外部第二个then   
内部第二个then

再看题目

那么,按照这个思路分析的话,文章开头那段代码的输出结果是什么呢?由于思路差不多,这里就直接写结果了:

外部promise
外部第一个then
内部promise
内部第一个then
外部第二个then
内部第二个then
外部第三个then
外部第四个then

当然,这个结果是错误的,下面才是正确的结果:

外部promise
外部第一个then
内部promise
内部第一个then
外部第二个then
外部第三个then
外部第四个then
内部第二个then

在一开始分析的时候,我忽略了 return Promise.resolve() 这个语句,以为它就只是同步返回一个 Promise 实例而已,但实际上, then 的回调的返回值是需要引起关注的。

前面说过,如果 promise 是 then 返回的,那么 then 的回调执行完毕之后它就被 resolve 了,这里其实要细分情况:

  • 如果 then 的回调返回的不是一个 thenable (具有 then 方法的 object),那么,这个返回值将被 then 返回的 promise 用来进行 resolve。而这个 promise 一旦被 resolve,则后面调用 then 的时候,then 的回调可以马上进入队列(严格地说,进入队列的不是回调,而是用于调用回调的某个微任务)。
  • 如果 then 的回调返回的是一个 thenable ,比如说返回一个 promise_0,那么,这个 promise_0 会直接决定 then 返回的 promise_1 的状态(pending,resolve,reject)。而且,即使 promise_0 本身已经被 resolve 了,promise_1 也不会马上被 resolve,具体地说,需要经历下面的过程: 在返回 promise_0 之后,会生成一个微任务并放入队列中,这个微任务可以近似理解为如下代码:
microTask() => {
    promise_0.then(() => {
        promise_1.resolve()    
    })
}

它所做的事情,就是调用 promise_0 的 then 方法,从而将 then 的回调放入队列中,而直到回调被执行的时候,promise_1 才终于被 resolve 或者 reject,它后面的 then 的回调才终于有机会进入队列。

在清楚这一点之后,我们再从头到尾分析一下这段代码:

  1. 整体代码作为宏任务执行:实例化 promise,输出 外部promise,之后调用 resolve,promise 到达 resolved 状态
  2. 执行外部第一个 then,由于 then 前面的 promsie 已经被 resolve,所以 then 的回调进入队列。后面虽然相继执行了外部第二个、第三个、第四个 then,但由于每个 then 前面的 promise 都还没有 resolve,所以他们的回调都不会进入队列。 此时的队列:外部第一个 then 的回调
  3. 宏任务执行完毕,查看微任务并执行:队列取出外部第一个 then 的回调执行,输出 外部第一个then,接着实例化 promise,输出 内部promise,之后调用 resolve,该 promise 达到 resolved 状态 此时的队列:空
  4. 执行内部第一个 then,由于 then 前面的 promsie 已经被 resolve,所以 then 的回调进入队列;执行内部第二个 then,由于内部第一个 then 尚未 resolve,所以它的回调暂时不进入队列 此时的队列: 内部第一个 then 的回调
  5. 到这里,外部第一个 then 的回调执行完毕,并且返回一个非 thenable(返回undefined),所以这个 then 返回的 promise 被 resolve,使得外部第二个 then 的回调进入队列。 此时的队列:内部第一个 then 的回调 → 外部第二个 then 的回调
  6. 执行内部第一个 then 的回调,输出 内部第一个then,接着执行 retrun Promise.resolve(),按照前面说的,这会往队列中放入一个新生成的微任务 此时的队列: 外部第二个 then 的回调 → microTask
  7. 记住,内部第一个then的回调虽然执行完毕了,但是 then 返回的 promise 还没有 resolve,所以,内部第二个 then 的回调还不会进入队列。接着执行外部第二个 then 的回调,输出 外部第二个then,同时,外部第三个 then 的回调进入队列 此时的队列:microTask → 外部第三个 then 的回调 微任务执行完毕,第二轮事件循环结束。
  8. 执行 microTask,这将执行此前内部第一个 then 的回调返回的 promsie_0 的 then 方法,那么 then 的回调是否会马上进入队列呢?会的,因为 promsie_0 已经处于 resolved 状态 此时的队列:外部第三个 then 的回调 → promsie_0 的 then 的回调
  9. 执行外部第三个 then 的回调,输出 外部第三个then,同时,外部第四个 then 的回调进入队列 此时的队列:promsie_0 的 then 的回调 → 外部第四个 then 的回调
  10. 执行 promsie_0 的 then 的回调,这将会 resolve 内部第一个 then 返回的 promise_1。由于这个 thenresolve 了,所以后面跟着的内部第二个 then 的回调得以进入队列 此时的队列: 外部第四个 then 的回调 → 内部第二个 then 的回调
  11. 执行外部第四个 then 的回调,输出 外部第四个then。同时,外部第四个 then 返回的 promise 被 resolve,不过它后面没有跟着额外的 then,所以不再往队列中增加新的回调 此时的队列:内部第二个 then 的回调
  12. 执行内部第二个 then 的回调,输出 内部第二个then。同时,这个 then 返回的 promise 被 resolve,不过它后面没有跟着额外的 then,所以不再往队列中增加新的回调 此时的队列:空

整段代码的事件循环其实只有一轮,宏任务的执行负责分发微任务到队列中,而微任务在执行的时候又会产生其它微任务,后面其实一直都是在处理微任务了,直到清空队列,没有额外的微任务或者宏任务需要执行了,整段代码也就结束了。

综上,最终的输出是:

外部promise
外部第一个then
内部promise
内部第一个then
外部第二个then
外部第三个then
外部第四个then
内部第二个then

与实际的输出结果完全一致。

这样分析就结束了。其实核心就在于判断 then 的回调进入队列的时机,而它入队的时机又取决于前面 promise_1 被 resolve 的时机。一开始认为在同步执行 return Promise.resolve() (记作 promise_0)的时候,前面 then 的回调就执行完毕了, promise_1 就已经被 resolve 了。但实际上,如果回调返回的是一个 thenable,则属于特殊情况,它会导致生成一个新的微任务放到队列中, promise_1 也因此不会马上被 resolve,而是等到 promise_0 的 then 的回调被执行的时候,才会被 resolve

最后

分析思路基本是参考思否的 @fefe 大佬的,他在回答中提到了规范的一些内容,不过我没有了解过 Promise 的内部实现,也没有研读过 spec,所以这篇文章就没办法往深的地方写了,也不会涉及原理,但如果你想从事件循环的角度分析这段代码,应该还是能提供一点帮助的。各位如果想继续深入挖掘的话,可以阅读文末链接的几篇文章。

最后想谈谈楼主删帖这件事情。我觉得在技术社区提问之前,如果能确保:

  • 自己花时间思考过
  • 网上所能找到的资料暂时不能解惑

而在提问的时候,能确保:

  • 描述问题准确、重点突出

那么这个提问毫无疑问就已经是合格的了,甚至说已经超出了一般提问的水平(因为上面说的几点,其实有很多人是做不到的)。但我看到的却是,这样的一个提问受到了一些人的冷嘲热讽,这种现象发生在一个技术社区,并不正常。

不瞒各位,我偶尔也会在 StackOverflow 上问一些比较小白的问题,但从不会有人吐槽说 “You are foolish”。我知道,也许真有人会这么想,但他们不会说出来,这对我来说是最大的善意了。国内技术社区缺乏的,往往并不是技术,而是一颗包容心以及足够友善的氛围。自己技术提高了,看一些问题自然会觉得很简单,但说实话,这不是你挖苦别人的资本,大家都是一步一个脚印慢慢走过来的。

参考链接:

关于promise输出顺序的疑问

深度揭秘 Promise 微任务注册和执行过程

Promise 链式调用顺序引发的思考