Promise执行顺序

1,679 阅读8分钟

前言

在上一篇文章中讨论了Promise原理解析, 我以为这回我终于搞懂了Promise,于是就在网上查看Promise相关的面试题,巩固一下知识, 当看到网上一到关于promise执行顺序的问题时.....😂😂我是谁? 我在哪? 我在干什么? 今天打算结合事件循环再细入分析一下Promise的执行过程

JS的事件循环机制

为了搞明白Promise执行顺序, 我们先回顾一下JS的事件循环机制,以及微任务(microtask)和宏任务(macrotask)

JS是单线程的

JavaScript是单线程的, 任意一时刻最多只有一个JS引擎线程在执行JavaScript代码.那么为啥JavaScript是单线程的呢? JavaScript语言设计之初就是为了操作DOM, 与用户互动; 如果用户的一个操作会触发两条指令(新增DOM、删除DOM), 假设JS有两条线程分别执行这两个指令,如果这两个指令同时执行完, 那么浏览器该如何显示呢? 所以, 为了避免复杂的并发问题,JS从诞生之初就规定它是单线程的.

虽然单线程能够避免复杂的并发问题, 但是单线程也带来了同步阻塞的问题. 单线程就意味着任务需要一个一个的排队等待执行, 而且必须是前一个处理完,才能处理后一个; 当前一个任务是高耗时的操作时, 后面的任务就只能处于干等着的状态了,可是往往这些高耗时的操作并不是由于JS引擎线程处理慢的缘故,像I/O任务定时器任务网络请求任务.....等都是由于外部原因导致的阻塞, 造成页面卡死、浏览器无响应的现象.为了解决这个问题JavaScript语言设计了异步模式: 当JS执行到上述这些高耗时的操作时, 挂起处于等待中的耗时任务, 跳过它, 继续执行它后面的任务, 当挂起的任务有了结果后,再回过头来执行.

任务队列

这样JS处理的任务就分成了两种,同步任务异步任务; 同步任务是那些没有被引擎挂起、排队等待执行的任务, 只有前一个任务执行完,才能执行后一个任务,直接在主线程上执行。异步任务是那些被引擎挂起的、被放入任务队列中的任务。在浏览器的一个渲染进程中,除了JS引擎线程之外,还存在着事件触发线程定时器线程http请求线程等来协同主线程处理异步任务,这些线程都有一个共同的作用, 那就是在异步操作结束时,将挂起的任务添加进任务队列等待主线程来执行。像我们常见的setTimeout(() => {}),这就是一个异步任务,当JS引擎线程执行到setTimeout时, 交由定时器线程负责处理计时, 在异步任务执行完毕后(定时结束),定时器线程将回调函数添加进任务队列,等待JS引擎线程来执行.

微任务与宏任务

在异步模式下创建的任务又分为两种: 微任务宏任务, ES6规范中规定: 微任务(Microtask) 称为Jobs,宏任务(Macrotask) 称为Task。微任务由JS自身发起 ,而宏任务是由宿主(浏览器)发起的。从执行顺序的优先级来讲, 微任务的优先级要高于宏任务, 即JS引擎是先执行微任务,再执行宏任务,浏览器环境下常见的微任务宏任务:

宏任务微任务
setTimeout()window.queueMicrotask()
setInterval()Promise.then/catch/finally()
事件队列
script整体代码快

为啥说script整体代码快也是宏任务呢? 看下面的例子就明白了

<script>
  console.log('script1 start')
  setTimeout(() => console.log('setTimeout1'),0)
  queueMicrotask(() => console.log('Microtask1'))
  console.log('script1 end')
</script>
<script>
  console.log('script2 start')
  setTimeout(() => console.log('setTimeout2'),0)
  queueMicrotask(() => console.log('Microtask2'))
  console.log('script2 end')
</script>

控制台打印顺序依次是: script1 startscript1 endMicrotask1script2 startscript2 endMicrotask2setTimeout1setTimeout2 ,在把第一个<script>标签内的同步任务执行完后,先去清空微任务,然后才会执行第二个<script>标签内的代码.

我们再回顾一下宏任务与微任务的区别:在MDN中的定义:

  • 当执行来自任务队列中的任务时, 在每一次新的事件循环开始迭代的时候运行时都会执行队列中的每个任务. 在每次迭代开始之后加入到队列中的任务需要在下一次迭代开始之后才会被执行.
  • 每次当一个任务退出且执行上下文为空的时候, 微任务队列中的每一个任务会被依次执行. 不同的是它会等到微任务队列为空才会停止执行--即使中途有微任务加入.换句话说, 微任务可以添加新的微任务到队列中, 并在下一个任务开始执行之前且当前事件循环结束之前执行完所有的微任务.

总结一下: 二者都是在执行栈清空(当前同步代码执行完毕)之后执行的, 二者主要的区别就是执行时机的区别, 在事件循环的时候如果微任务队列不为空, 则先清空微任务队列中的所有微任务, 哪怕是在执行微任务时添加了新的任务到微任务队列,也要清空后,才进行下一个事件循环; 而任务队列中的任务,在一次事件循环的过程中,只执行队列最前面的一个任务;

通常我们放在队列中的所谓的这些任务, 目前我们遇到的貌似都是回调函数的形式,我们姑且把<script><script/>标签也当成队列,把里面的代码块也当是一个回调函数即一个任务, 那么整体代码快的执行过程是不是就像宏任务的执行过程了

事件循环

  1. 所有同步任务都在主线程上执行,形成一个执行栈(调用栈);
  2. 主线程之外还存在着任务队列, 包括宏任务队列微任务队列, 只要异步操作结束就会往对应的任务队列中添加任务(回调函数);
  3. 只要执行栈清空(主线程上的同步任务执行完),JS引擎就会先依次读取并执行微任务队列中的所有任务,清空微任务队列后, 再去读取并执行宏任务队列排在最前面的一个任务;
  4. 主线程不断重复上门第三步操作, 这就是所谓的事件循环Event Loop

Promise执行顺序

理清了上面的知识后, 再来看看Promise的执行顺序就容易理解多了

setTimeout(()=>{
   console.log(1) 
})
new Promise((resolve)=>{
    console.log(2)
    resolve()
}).then(()=>{
   console.log(3) 
})
console.log(4) 

为了方便称呼, 我们假定代码中打印的是几,就称为第几步; 代码中第1步是一个宏任务, 第2步是在主线程上最先执行的同步任务, 第3步是一个微任务, 第4步也是一个在主线程上执行的同步任务. 所以打印顺序依次是: 2、4、3、1

new Promise((resolve, reject) => {
    console.log("1");
    resolve();
  })
.then(() => {
    console.log("2");
    new Promise((resolve, reject) => {
        console.log("3");
        resolve();
    })
    .then(() => {
        console.log("4");
    })
    .then(() => {
        console.log("5");
    });
})
.then(() => {
    console.log("6");
});

比上个复杂点,还是一步步来:

  1. Promise构造函数接受的参数是一个需要立即执行的函数, 是一个同步任务, 所以先输出1;
  2. 第一个Promise执行完后,就会往微任务队列添加它的第一个then方法中的任务, 由于当前执行栈为空(没有同步代码需要执行), 所以会执行微任务队列中的任务, 先输出2, 再执行后面跟着的第二个Promise构造函数, 输出3;
  3. 第二个Promise执行完后, 就会往微任务队列添加它的第一个then方法, 此时已没有同步任务需要执行,相当于这个微任务暂时执行完,即第一个Promisethen方法执行完, 所以会接着将第一个Promise的第二个then方法添加进微任务队列;
  4. 此时的微任务队列应该是长这样: [ console.log("4"), console.log("6")],主线程依次执行微任务队列, 第一个微任务执行完后输出4, 此时也意味着第二个Promise的第一个then方法执行完毕, 同理会接着往微任务队列添加第二个Promise的第二个then方法, 此时的微任务队列应该是长这样: [ console.log("6"), console.log("5")], 程序继续依次执行微任务队列, 依次输出6、5, 程序执行完毕

所以整个过程打印顺序依次是: 1、2、3、4、6、5

new Promise((resolve, reject) => {
    console.log("1");
    resolve();
  })
.then(() => {
    console.log("2");
    return new Promise((resolve, reject) => {
        console.log("3");
        resolve();
    })
    .then(() => {
        console.log("4");
    })
    .then(() => {
        console.log("5");
    });
})
.then(() => {
    console.log("6");
});

跟上面的一个例子很相似, 区别就是在第一个Promisethen方法中多了一个return. 看过Promise源码的就知道, then方法内的函数的返回值类型,决定下一个then方法内部的状态, 所以如果then方法内有return, 就需要把return后面的表达式执行完毕后, 才能再调用下一个then方法; 第一个Promise的第二个then方法相当于是挂在这个return返回值上的.所以整个过程打印顺序依次是: 1、2、3、4、5、6

总结

Promise构造函数接收的参数是一个同步任务, 需要同步执行的,Promise实例的then方法是一个微任务, 而且then方法的调用是在Promise构造函数执行完毕之后; 如果在执行微任务的过程中,又产生了新的微任务,会直接将该微任务添加至微任务队列末尾, 并且会在当前事件循环结束之前执行掉.