就在昨天,与朋友聊到JS基础时,她突然想起之前在面试时,遇到了一道难以理解的Promise
执行顺序题。由于我之前专门写过手写promise
的文章,对于部分原理也还算了解,出于兴趣我便要了这道题的代码,想看看自己现在的理解能做到什么程度,顺便也给对方解疑答惑,代码如下:
function doSomething() { return Promise.resolve(1); }; function doSomethingElse() { return Promise.resolve(2); }; // 执行1 doSomething() .then(() => { return doSomethingElse() }) .then(val => console.log('a', val)) // 执行2 doSomething() .then(() => { doSomethingElse() }) .then(val => console.log('b', val)) // 执行3 doSomething() .then(doSomethingElse()) .then(val => console.log('c', val)) // 执行4 doSomething() .then(doSomethingElse) .then(val => console.log('d', val)) // 要求给出上面4个promise的执行顺序,以及输出内容
可以看到这是一个纯Promise
的执行顺序题,没有setTimeout
和async await
的干扰。但在我尝试分析后我发现,这道题考研的细节还挺多,如果没有深入了解过Promise
源码,可以毫不夸张的说,这道题不可能做的对。而我自己手写Promise
也是2个多月前的事情了,所以初次分析果然做错了。
在我重读了自己手写Promise
的文章后,重新捡起了当时记录的部分细节,也非常顺利的把这道题的细节都给挖了出来,为了方便讲给朋友听,也方便自己日后回顾,这里我就做个记录,那么本文开始。
在题解分析之前,我先把这道题的考点单独拧出来先讲一遍。毕竟当一个问题我们觉得难到自己无法理解时,那往往是我们对于需要使用的知识掌握是非常薄弱的。如果知道考什么,我们还能根据现有的知识体系去做分析和猜测,如果考了什么都不知道,那就是猜就不知道怎么猜了。所以这一小节,我们先普及这道题中考察的细节与概念,在此之后我们再去看题解分析,起码能做到有部分概念能为自己做理解支撑。
注意,以下四个细节点在因两道Promise执行题让我产生自我怀疑,从零手写Promise加深原理理解一文中均有讲解,要了解底层原理还是建议阅读此文,当然你也可以先不理解原理,只是作为硬性概念先记忆,这一点大家自行安排。
我们知道,new
一个Promise
的过程是同步的,包括.then()
注册callback
的行为也是同步的,真正异步的是被注册的成功以及失败的回调(它们得等待状态改变,而且不知道什么时候才会改变),且它们都是微任务。
const P1 = new Promise((resolve, reject) => { console.log('new Promise是同步操作') resolve(1); }); setTimeout(() => { console.log('我是异步宏任务') }, 0) P1.then((e) => { console.log('我是异步微任务') console.log(e); }) // new Promise是同步操作 // 我是异步微任务 // 我是异步宏任务
而当存在多个Promise
调用需要区分执行顺序时,我们往往以.then()
注册callback
的顺序来决定执行顺序:
const P1 = Promise.resolve(1); const P2 = Promise.resolve(2); P1.then((e) => { console.log('我先注册的,所以先输出1') console.log(e); }) P2.then((e) => { console.log('我后注册的,所以我后面输出2') console.log(e); }) // 我先注册的,所以先输出1 // 1 // 我后注册的,所以我后面输出2 // 2
而.then
的callback
中往往也能再返回一个Promise
,这时候就是我们所说的链式调用,而当存在多个链式调用时,我们心里会默认,只要你多.then()
一次,你的执行就得往后排一次,比如:
const P1 = Promise.resolve(1); const P2 = Promise.resolve(2); P1.then((e) => { console.log(e) return Promise.resolve(3) }) .then((e) => console.log(e)); P2.then((e) => { console.log(e); }) // 1 // 2 // 3
这个例子的3
一定是晚于2
输出的,我们就站在宏观的角度去理解,因为3
多了一层.then()
,那么你肯定得比2
还要往后面排一排,最后输出毫无疑问,我想很多人对于这个问题的理解都是这样的。
但是,现在请大家记住第一个硬性概念,假设.then()
内部返回了一个Promise
,那么这个Promise
的执行得往后延后两次次,而不是一次,这是因为.then()
中的Promise
在改变状态到执行,底层会创建2次微任务,导致它的执行往后推两次。
来看个例子:
Promise.resolve() .then(() => { console.log(0); // then内部返回promise,默认理解成延迟2次后执行 return Promise.resolve(4); }).then((res) => { console.log(res) }) Promise.resolve() .then(() => { console.log(1); }).then(() => { console.log(2); }).then(() => { console.log(3); }) // 0 1 2 3 4
先输出0
,再输出1
肯定毫无悬念,但是由于第一个的then()
返回了一个Promise
,我们默认它得往后延迟2位,所以2,3
先走,4
最后执行。这个例子,也是我当时手写Promise
的起因,细节如果再在这里展开讲会很复杂,还是建议阅读手写Promise
一文,这里大家先作为概念去记忆。
我们知道.then()
可以返回一个Promise
然后使用链式调用,但事实上也存在返回不是Promise
或者不返回的情况。我们先说返回的情况。
假设返回的是Promise
,那么下一个.then
肯定得等待这个Promise
状态发生改变才能执行,但其实我们还有返回不是Promise
的情况:
Promise.resolve(1) .then( (e) => { console.log(1) return 2; } ).then((e) => { console.log(e) return 3; }) .then((e) => { console.log(e) }); // 1 2 3
上述例子中返回的数字,有点类似于:
Promise.resolve(1) .then( (e) => { console.log(1) return Promise.resolve(2); } ).then((e) => { console.log(e) return Promise.resolve(3); }) .then((e) => { console.log(e) });
你现在只用知道当返回一个数字或者字符串,只要不是Promise
,它本质上都会被resolve
转化成成功的状态,需要注意的是return 4
和return Promise.resolve(4)
还是有区别,比如上面创建2次微任务的例子,我们假设改成return 4
:
Promise.resolve() .then(() => { console.log(0); // then内部返回promise,默认理解成延迟2次后执行 return 4; }).then((res) => { console.log(res) }) Promise.resolve() .then(() => { console.log(1); }).then(() => { console.log(2); }).then(() => { console.log(3); }) // 0 1 4 2 3
可以看到此时4
跑到了2,3
前面,类似于只创建了一次微任务,return 4
和return Promise.resolve(4)
相比,后者比前者多创建一次微任务,这个细节在手写中也能体现,这里就不花篇幅再说了。
说了返回Promise
和非Promise
的情况,我们再来聊聊无返回的情况,比如:
Promise.resolve() .then(() => { console.log(0); // 没有返回 }).then((res) => { console.log(res) }) // undefined
为什么是undefined
,因为函数如果没返回值时,默认表示为返回undefined
,那既然return undefined
,不就是类似于return Promise.resolve(undefined)
,因此输出undefined
毫无疑问。
关于返回我们先聊到这,理解了这个知识点,我们可以回到文章开头的题目,看看执行2。
在手写Promise
的文章中,我们特意提及过.then()
方法的值穿透问题,如果你没看过本文,那么请将下面的话当成硬性的概念记下来。
当Promise.then()的没有提供callback,或者callback不是一个函数时,Promise会发生值穿透
我们先来看第一个例子,.then()
没有提供callback
的情况:
const P1 = Promise.resolve('值穿透'); P1.then((res) => { console.log(res); // 值穿透; }); P1.then() .then() .then() .then((res) => { console.log(res); //值穿透 });
上述代码中,我们创建了一个promise P1
,那么第一段执行毫无悬念肯定输出值穿透
。而第二个执行,我们链式调用了多个.then()
并且都没有提供成功或者失败的回调,这种情况就会导致状态和值发生穿透,因此最后一个.then()
还是能成功获取到值穿透
。
我们再来看callback
不是函数的情况,例子如下:
const P1 = Promise.resolve('值穿透'); const fn1 = () => { console.log(1) }; P1.then(fn1()) .then(1) .then('2') .then((res) => { console.log(res); //值穿透 });
在这个例子中,我们并没有为任何一个.then
提供一个函数,有的.then()
直接丢了一个数字进去了,有的then()
丢了字符串进去。这时候有同学就要说了,不对啊,你第一个then()
传递的fn1()
难道不是函数?
这里我们就要搞清楚一个概念了,所谓callback
是传递一个函数作为callback
,等状态确定了Promise
帮你调用,而fn1()
这玩意自己直接被调用,它哪里是一个函数呢?
我们用一个最基本的例子来解释函数调用与函数引用:
const fn2 = () => { return '函数调用与函数引用的区别'; } const o = { f1: fn2, // 这个叫函数引用 f2: fn2() // 这个叫函数调用 }; console.log(o.f1()); // 函数调用与函数引用的区别 console.log(o.f2); // 函数调用与函数引用的区别
我们定义了一个函数叫fn2
,然后把fn2
的引用赋予给了对象o
的f1
属性,因此我们能通过o.f1()
调用fn2
,这就是函数引用。
而我们将fn2()
赋予给了o
的f2
属性,因此o.f2
根本就不是一个函数,它保存的是fn2()
执行之后的返回结果,这就是函数调用。
所以回到上面值穿透第二个例子,我们传递的是一个函数调用,你自己调用了,我堂堂Promise
不要面子的吗,你让我等会状态改变了调用什么?而后面的.then(1) .then('2')
同理,它们都不是函数,Promise
会直接忽略这些无意义的传递,依旧值穿透。
而关于值穿透,若你要问我为什么,我只能说没有为什么,因为Promise
就是这么实现的。这就跟你问我为什么1 + 1 = 2
一样,没有为什么,因为1 + 1
就是等于2,此刻它不是作为要理解的概念,而是已成为我们理解更宏观概念的工具或者基石。值穿透的本质也是为了让Promise
实现链式调用,至于Promise
是如何实现的这种链式调用,请参考手写Promise
一文。
那么关于值穿透就说到这里,这时候你可以根据文章开头题目的执行结果,尝试的去理解题目的执行3了。
在前文,我们提到了函数引用与函数调用,而.then()
的callback
在简写上其实也跟函数引用有一定关系,比如:
const fn1 = (e) => { console.log(e) // 1 } Promise.resolve(1) .then(fn1); // 本质上等同于 Promise.resolve(2) .then((e) => { console.log(e) //2 })
比如上面的.then(fn1)
本质上跟下面的写法是一样的,无非是前者把函数提前定义了,然后把fn1
作为callback
传递给了.then
,后者是直接在.then
里面写了一个匿名箭头函数。
同类型的简写还有:
setTimeout(console.log, 0, 'echo'); // echo // 等同于 setTimeout((e) => { console.log(e) }, 0, 'echo'); // echo
知道了这个,我们可以回头看看文章开头的执行4。
上文我们阐述了这道题的4个考点,大家可以看完后结合我给的概念再回头根据输出,反向思考下为什么,接下来我来改写上面题目,给出我的题解分析:
function doSomething() { return Promise.resolve(1); }; function doSomethingElse() { return Promise.resolve(2); }; // 执行1 doSomething() .then(() => { // 因为返回了一个promise,它又在then里面,3次微任务后输出 a 2 return doSomethingElse() }) .then(val => console.log('a', val)) // 执行2 doSomething() .then(() => { // 这里有执行,但是没返回,所以等同于resolve(undefined),2次微任务后输出b undefined doSomethingElse() }) .then(val => console.log('b', val)) // 执行3 doSomething() // callback直接不是一个函数,值穿透,相当于把前面的状态和值传递下去,所以也是2次微任务后输出 c 1 .then(doSomethingElse()) .then(val => console.log('c', val)) // 执行4 doSomething() // .then(doSomethingElse) // callback是函数引用,等同于 .then(() => { // 与执行1相同,then里面返回promise,3次微任务后输出 d 2 return doSomethingElse(); }) .then(val => console.log('d', val)) // 综合一下,2次微任务的先执行,3次微任务的谁先注册谁先执行,因此输出为: // b undefined // c 1 // a 2 // d 2
先看执行1:
doSomething() .then(() => { return doSomethingElse() }) .then(val => console.log('a', val))
概念1说了,.then
里面返回Promise
时,创建2次微任务,因为自己又被包裹在.then
里面,结合起来就是doSomething().then()
创建了一次微任务,然后return
的doSomethingElse()
自己又是一个Promise
,所以doSomethingElse().then()
创建了2次微任务,一起三次微任务,我们记录为3-a-2
;
再来分析执行2:
doSomething() .then(() => { // 这里有执行,但是没返回,所以等同于resolve(undefined),2次微任务后输出b undefined doSomethingElse() }) .then(val => console.log('b', val))
根据概念2,很明显这个doSomethingElse()
前面没有return
,这就导致doSomethingElse
返回的Promise
不能被二次return
出去,既然没返回,那就是默认理解成resolve(undefined)
,由于不是返回了一个Promise
,所以只会创建2次微任务,我们记录为2-b-undefined
。
执行3:
doSomething() // callback直接不是一个函数,值穿透,相当于把前面的状态和值传递下去,所以也是2次微任务后输出 c 1 .then(doSomethingElse()) .then(val => console.log('c', val))
参考概念3,由于.then()
接收的是一个函数调用,根本就不是一个函数,这里直接值穿透,与执行2类似,一共创建2次微任务,这里我们记录为2-c-1
。(1是doSomething
穿透传递下来的)。
最后看执行4:
doSomething() .then(doSomethingElse) .then(() => { // 与执行1相同,then里面返回promise,3次微任务后输出 d 2 return doSomethingElse(); }) .then(val => console.log('d', val))
参考概念4,这里的doSomethingElse
是函数引用,所以等同于:
doSomething() .then(() => { return doSomethingElse(); }) .then(val => console.log('d', val))
这样一改,是不是跟执行1其实是一样的,.then
里面返回了一个Promise
,创建了一共3次微任务,所以这里我们记录为3-d-2
。
综合一下,创建微任务越少,肯定越先执行,而相同微任务次数的,谁先注册谁先执行,因此输出结果以及顺序为:
b undefined, c 1, a 2, d2
。
到这里,我们从概念普及到题解分析已经完成结束,不知道与你脑中的理解是否一致呢。
本来是一道看起来非常简单的执行题,结果真要拆开说,里面真的暗藏玄机,而我也没想到一篇题解居然写了四千多字,我想我自己应该是非常透彻了去介绍了这道题考核的知识点,若还有疑问欢迎留言,我会一一解答,那么到这里本文结束。