一、背景
日常开发中,不管你是js新手还是经验丰富者,深入理解js代码执行顺序对于你写出高质量代码和排查问题都有很大的用处。
二、关于JavaScript
我们都知道javascript是一门单线程语言,所以我们可以得出结论:
- javascript是按照语句出现的顺序执行的
看到这里读者要打人了:我难道不知道js是一行一行执行的?还用你说?稍安勿躁,正因为js是一行一行执行的,所以我们以为js都是这样的:
plain
const a = 1;
console.log(a);
const b = 2;
console.log(b);
然而实际上的js可能是这样的:
plain
setTimeout(function() {
console.log('setTimeout');
})
new Promise(function(resolve) {
console.log('promise');
}).then(function() {
console.log('then');
})
console.log('console');
依照js是按照语句出现的顺序执行这个理念,我自信的写下输出结果:
plain
//"setTimeout"
//"promise"
//"then"
//"console"
然而,去Chrome上验证下,结果完全不对,说好的一行一行执行呢?想知道为什么就需要我们深入的去理解js事件的循环机制。
三、JavaScript事件循环
1、宏任务/微任务
既然js是单线程,那就像只有一个窗口的银行,客户需要排队一个一个办理业务,同理js任务也要一个一个顺序执行。如果一个任务耗时过长,那么后一个任务也必须等着。那么问题来了,假如我们想浏览新闻,但是新闻包含的超清图片加载很慢,难道我们的网页要一直卡着直到图片完全显示出来?因此聪明的程序员将任务分为两类:
- 同步任务
- 异步任务
当我们打开网站时,网页的渲染过程就是一大堆同步任务,比如页面骨架和页面元素的渲染。而像加载图片音乐之类占用资源大耗时久的任务,就是异步任务。关于这部分有严格的文字定义,但本文的目的是用最小的学习成本彻底弄懂执行机制,所以我们用导图来说明:

导图要表达的内容用文字来表述的话:
- 同步和异步任务分别进入不同的执行"场所",同步的进入主线程,异步的进入Event Table并注册函数。
- 当指定的事情完成时,Event Table会将这个函数移入Event Queue。
- 主线程内的任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程执行。
- 上述过程会不断重复,也就是常说的Event Loop(事件循环)。
2、Event Loop(事件轮询)
除了广义的同步任务和异步任务,我们对任务有更精细的定义:宏任务(macro-task)和 微任务(micro-task)
常见的宏任务和微任务分类如下:
宏任务包括: script
, setTimeout
,setInterval
,setImmediate
,I/O
,UI rendering
微任务包括: process.nextTick
,Promise
( process.nextTick
为 Node 独有)
Tips:
- 微任务优先级高于宏任务的前提是:同步代码已经执行完成。因为
script
属于宏任务,程序开始后会首先执行同步脚本,也就是script
。 Promise
里边的代码属于同步代码,.then()
中执行的代码才属于异步代码。- async和await本身并不是一个宏任务也不是一个微任务,只是一个语法糖,帮助我们来梳理代码的执行顺序,async函数里await函数后面的代码要等await函数执行完成之后才会执行,async,await还可以用来控制函数的执行顺序
Event Loop 是一个程序结构,用于等待和发送消息和事件。其执行顺序如下所示:
- 首先执行同步代码(宏任务)
- 当执行完所有同步代码后,执行栈为空,查询是否有异步代码需要执行
- 执行所有微任务
- 当执行完所有微任务后,如有必要会渲染页面
- 然后开始下一轮 Event Loop,执行宏任务中的异步代码,也就是
setTimeout
中的回调函数
Tips:简化讲:先执行一个宏任务(script同步代码),然后执行并清空微任务,再执行一个宏任务,然后执行并清空微任务,再执行一个宏任务,再然后执行并清空微任务...如此循环往复(一个宏任务 -> 清空微任务 -> 一个宏任务 -> 清空微任务),流程图如下:

我们用文章最开始的那段代码来分析一下:
plain
setTimeout(function() {
console.log('setTimeout');
})
new Promise(function(resolve) {
console.log('promise');
}).then(function() {
console.log('then');
})
console.log('console');
- 这段代码作为宏任务,进入主线程。
- 先遇到
setTimeout
,那么将其回调函数注册后分发到宏任务Event Queue。(注册过程与上同,下文不再描述) - 接下来遇到了
Promise
,new Promise
立即执行,then
函数分发到微任务Event Queue。 - 遇到
console.log()
,立即执行。 - 好啦,整体代码script作为第一个宏任务执行结束,看看有哪些微任务?我们发现了
then
在微任务Event Queue里面,执行。 - ok,第一轮事件循环结束了,我们开始第二轮循环,当然要从宏任务Event Queue开始。我们发现了宏任务Event Queue中
setTimeout
对应的回调函数,立即执行。 - 结束。
四、案例分析
除了开篇的那段程序外,我们来分析几个经典复杂代码来帮助你真正理解js事件执行顺序:
1、案例1
1.1 案例代码
```plain setTimeout(function () { console.log(" set1"); new Promise(function (resolve) { resolve(); }).then(function () { new Promise(function (resolve) { resolve(); }).then(function () { console.log("then4"); }); console.log("then2 "); }); });
new Promise(function (resolve) {
console.log("pr1");
resolve();
}).then(function () {
console.log("then1");
});
setTimeout(function () {
console.log("set2");
});
console.log(2);
new Promise(function (resolve) {
resolve();
}).then(function () {
console.log("then3");
});
<h3 id="7MnBm">1.2 案例解析</h3>
执行所有同步代码(第一次宏任务):
```plain
setTimeout(function () { // setTimeout 内 function 放入宏任务
console.log(" set1");
new Promise(function (resolve) {
resolve();
}).then(function () {
new Promise(function (resolve) {
resolve();
}).then(function () {
console.log("then4");
});
console.log("then2 ");
});
});
new Promise(function (resolve) {
console.log("pr1"); // Promise里边的代码直接执行 打印 pr1
resolve();
}).then(function () {
console.log("then1"); // Promise.then 放入微任务
});
setTimeout(function () {
console.log("set2"); // setTimeout内function 放入宏任务
});
console.log(2); // 打印 2
new Promise(function (resolve) {
resolve();
}).then(function () {
console.log("then3"); //Promise.then 放入微任务
});
// 此时控制台打印 : pr1 > 2
// 异步任务队列:[微任务数:2][宏任务数:2]
// 执行并清空微任务
执行并清空微任务
plain
function () {
console.log("then1"); // 输出 then1
}
function () {
console.log("then3"); // 输出 then3
}
// 此时控制台打印 : then1 > then3
// 异步任务:[微任务数:0][宏任务数:2]
// 执行一个宏任务
执行一个宏任务
plain
function () {
console.log(" set1"); //打印 set1
new Promise(function (resolve) {
resolve();
}).then(function () { // Promise.then 放入微任务
new Promise(function (resolve) {
resolve();
}).then(function () {
console.log("then4");
});
console.log("then2 ");
});
}
// 此时控制台打印 : set1
// 异步任务:[微任务数:1][宏任务数:1]
// 执行并清空微任务
执行并清空微任务
plain
function () {
new Promise(function (resolve) {
resolve();
}).then(function () {
console.log("then4"); // Promise.then 放入微任务
});
console.log("then2 "); // 打印 then2
}
// 此时控制台打印 : then2
// 异步任务:[微任务数:1][宏任务数:1]
// 此时微任务列表增加并未清空,继续执行微任务
此时微任务列表增加并未清空,继续执行微任务
plain
function () {
console.log("then4"); // 打印 then4
}
// 此时控制台打印 : then4
// 异步任务:[微任务数:0][宏任务数:1]
// 执行宏任务
执行宏任务
plain
function () {
console.log("set2"); // 打印 set2
}
// 此时控制台打印 : set2
// 异步任务:[微任务数:0][宏任务数:0]
// 程序结束
完整输入顺序
plain
pr1
2
then1
then3
set1
then2
then4
set2
2、案例2
2.1 案例代码
```plain console.log('1');
setTimeout(function() {
console.log('2');
process.nextTick(function() {
console.log('3');
})
new Promise(function(resolve) {
console.log('4');
resolve();
}).then(function() {
console.log('5')
})
})
process.nextTick(function() {
console.log('6');
})
new Promise(function(resolve) {
console.log('7');
resolve();
}).then(function() {
console.log('8')
})
setTimeout(function() {
console.log('9');
process.nextTick(function() {
console.log('10');
})
new Promise(function(resolve) {
console.log('11');
resolve();
}).then(function() {
console.log('12')
})
})
<h3 id="vaV1A">2.2 案例解析</h3>
第一轮事件循环流程分析如下:
+ 整体script作为第一个宏任务进入主线程,遇到`console.log`,输出1。
+ 遇到`setTimeout`,其回调函数被分发到宏任务Event Queue中。我们暂且记为`setTimeout1`。
+ 遇到`process.nextTick()`,其回调函数被分发到微任务Event Queue中。我们记为`process1`。
+ 遇到`Promise`,`new Promise`直接执行,输出7。`then`被分发到微任务Event Queue中。我们记为`then1`。
+ 又遇到了`setTimeout`,其回调函数被分发到宏任务Event Queue中,我们记为`setTimeout2`。
| 宏任务Event Queue | 微任务Event Queue |
| :---: | :---: |
| setTimeout1 | process1 |
| setTimeout2 | then1 |
+ 上表是第一轮事件循环宏任务结束时各Event Queue的情况,此时已经输出了1和7。
+ 我们发现了`process1`和`then1`两个微任务。
+ 执行`process1`,输出6。
+ 执行`then1`,输出8。
好了,第一轮事件循环正式结束,这一轮的结果是输出1,7,6,8。那么第二轮时间循环从`setTimeout1`宏任务开始:
+ 首先输出2。接下来遇到了`process.nextTick()`,同样将其分发到微任务Event Queue中,记为`process2`。`new Promise`立即执行输出4,`then`也分发到微任务Event Queue中,记为`then2`。
| 宏任务Event Queue | 微任务Event Queue |
| :---: | :---: |
| setTimeout2 | process2 |
| | then2 |
+ 第二轮事件循环宏任务结束,我们发现有`process2`和`then2`两个微任务可以执行。
+ 输出3。
+ 输出5。
+ 第二轮事件循环结束,第二轮输出2,4,3,5。
+ 第三轮事件循环开始,此时只剩setTimeout2了,执行。
+ 直接输出9。
+ 将`process.nextTick()`分发到微任务Event Queue中。记为`process3`。
+ 直接执行`new Promise`,输出11。
+ 将`then`分发到微任务Event Queue中,记为`then3`。
| 宏任务Event Queue | 微任务Event Queue |
| :---: | :---: |
| | process3 |
| | then3 |
+ 第三轮事件循环宏任务执行结束,执行两个微任务`process3`和`then3`。
+ 输出10。
+ 输出12。
+ 第三轮事件循环结束,第三轮输出9,11,10,12。
整段代码,共进行了三次事件循环,完整的输出为1,7,6,8,2,4,3,5,9,11,10,12。
(请注意,node环境下的事件监听依赖libuv与前端环境不完全相同,输出顺序可能会有误差)
<h2 id="HlPnq">3、案例3</h2>
<h3 id="anlYM">3.1 案例代码</h3>
```plain
async function async1() {
console.log( 'async1 start' )
await async2()
console.log( 'async1 end' )
}
async function async2() {
console.log( 'async2' )
}
console.log( 'script start' )
setTimeout( function () {
console.log( 'setTimeout' )
}, 0 )
async1();
new Promise( function ( resolve ) {
console.log( 'promise1' )
resolve();
} ).then( function () {
console.log( 'promise2' )
} )
console.log( 'script end' )
3.2 案例解析
每次宏任务和微任务发生变化,我都会画一个图来表示他们的变化。 直接打印同步代码 console.log('script start')
plain
首先是2个函数声明,虽然有async关键字,但不是调用我们就不看。然后首先是打印同步代码 console.log('script start')

将setTimeout放入宏任务队列
plain
默认<script></script>所包裹的代码,其实可以理解为是第一个宏任务,所以这里是宏任务2

调用async1,打印 同步代码 console.log( 'async1 start' )
plain
我们说过看到带有async关键字的函数,不用害怕,它的仅仅是把return值包装成了promise,其他并没有什么不同的地方。所以就很普通的打印 console.log( 'async1 start' )

分析一下 await async2()
plain
1. 前文提过await,1.它先计算出右侧的结果,2.然后看到await后,中断async函数
2.
3. - 先得到await右侧表达式的结果。执行async2(),打印同步代码console.log('async2'), 并且return Promise.resolve(undefined)
4. - await后,中断async函数,先执行async外的同步代码
5.
6. 目前就直接打印 console.log('async2')

被阻塞后,要执行async之外的代码
执行new Promise(),Promise构造函数是直接调用的同步代码,所以 console.log( 'promise1' )

代码运行到promise.then()
plain
代码运行到promise.then(),发现这个是微任务,所以暂时不打印,只是推入当前宏任务的微任务队列中。
注意:这里只是把promise2推入微任务队列,并没有执行。微任务会在当前宏任务的同步代码执行完毕,才会依次执行

打印同步代码 console.log( 'script end' )
plain
1. 没什么好说的。执行完这个同步代码后,「async外的代码」终于走了一遍
2.
3. 下面该回到 await 表达式那里,执行await Promise.resolve(undefined)了

回到async内部,执行await Promise.resolve(undefined)
这部分可能不太好理解,我尽量表达我的想法。
对于 await Promise.resolve(undefined) 如何理解呢?
根据 MDN 原话我们知道
如果一个 Promise 被传递给一个 await 操作符,await 将等待 Promise 正常处理完成并返回其处理结果。
在我们这个例子中,就是Promise.resolve(undefined)正常处理完成,并返回其处理结果。那么await async2()就算是执行结束了。
目前这个promise的状态是fulfilled,等其处理结果返回就可以执行await下面的代码了。
那何时能拿到处理结果呢?
回忆平时我们用promise,调用resolve后,何时能拿到处理结果?是不是需要在then的第一个参数里,才能拿到结果。
(调用resolve时,会把then的参数推入微任务队列,等主线程空闲时,再调用它)
所以这里的 await Promise.resolve() 就类似于
plain
1. Promise.resolve(undefined).then((undefined) => {
2.
3. })
把then的第一个回调参数 (undefined) => {} 推入微任务队列。
then执行完,才是await async2()执行结束。
await async2()执行结束,才能继续执行后面的代码
如图

此时当前宏任务1都执行完了,要处理微任务队列里的代码。
微任务队列,先进先出的原则,
- 执行微任务1,打印promise2
- 执行微任务2,没什么内容...
但是微任务2执行后,await async2()语句结束,后面的代码不再被阻塞,所以打印
console.log( 'async1 end' )
宏任务1执行完成后,执行宏任务2
宏任务2的执行比较简单,就是打印
console.log('setTimeout')