在前端的面试中经常会问到关于代码执行顺序的问题,尤其是下面的一段代码
1 | setTimeout( () => console.log(4)) |
问题是:在浏览器上面 1 2 3 4 5 的打印的顺序。
上面这个问题看起来对有的同学可能很简单,到有的同学可能会比较复杂。对你不管是复杂还是简单,这其中涉及到的只是点都是一样的。JavaScript单线程,宏任务与微任务,EventLoop。这些就是这个题目的考点,理解了这些,那么上面的这道题对你来说那就是信手拈来,游刃有余。
我猜你应该知道,JavaScript除了在浏览器环境中运行,还可以在Node环境中运行,虽说都是JavaScript代码,但是在这两种环境下面执行的结果是可能不一样的。所以,我们需要分两种情况来分析他们的EventLoop。
什么是EventLoop
EventLoop是一个执行模型,在不同的有不同的实现,浏览器和NodeJS基于不同的技术实现了各自的EventLoop。
- 浏览器的EventLoop是在HTML5规范中明确定义了的
- NodeJS的EventLoop是基于libuv实现的。可以在libuv官网和NodeJS官网查看
- libuv已经对NodeJS的EventLoop做出了实现,但是浏览器的HTML5规范只是定义了EventLoop的实现模型,具体的实现留给了浏览器厂商。
JavaScript中的单线程
JavaScript是单线程脚本语言。所以,在一行代码的执行过程过,必然不会执行另一行代码的,就行你在使用了alert(1)
以后在后面疯狂的console.log()
,如果执行到 alert(1)
,你没有关闭这个弹窗,后面的console.log()
是永远都不会执行的,因为 alert()
这个任务还没有执行完成,下面的代码没法执行。通俗一点就是:如果你去食堂打饭,前面排了很长的队,如果你想要打到饭,那么你需要等前面的小可爱都能够顺利的打完饭才可以,你是不能够插队的。那什么是宏任务,什么又是微任务呢?
同样是打饭的例子,你要打饭这件事请就是宏任务。这是一个大的事件。当轮到你打饭的时候,事件执行到你这里了,这个时候阿姨开始给你打饭,后面的同学还在等待着。但是你去打饭不单单的就是打饭,你会询问每种菜是什么,价格是多少,有没有XXX菜,有没有汤一样,那这些询问可以比作是微任务。当你的宏任务与微任务都执行完成了,相当于你的这一轮时间执行完成,这个时候开始执行下一轮事件,也就是下一个同学开始打饭了。同样的,下面的一轮循环中也可能存在微任务。
通过上面的例子,如果能有大概的明白了什么是宏任务,什么是微任务了。
宏任务
macrotask,也叫 tasks,主要的工作如下
- 创建主文档对象,解析HTML,执行主线或者全局的javascript的代码,更改url以及各种事件。
- 页面加载,输入,网络事件,定时器。从浏览器角度看,宏任务是一个个离散的,独立的工作单元。
- 运行完成后,浏览器可以继续其他调度,重新渲染页面的UI或者去执行垃圾回收
一些异步任务的回调会以此进入 macrotask queue(宏任务队列)
,等等后续被调用,这些异步函数包括:
- setTimeout
- setInterval
- setImmediate (Node)
- requestAnimationFrame (浏览器)
- I/O
- UI rendering (浏览器)
微任务
microtask,也叫 jobs,注意的工作如下
- 微任务是更小的任务,微任务更新应用程序的状态,但是必须在浏览器任务继续执行其他任务之前执行,浏览器任务包括重新渲染页面的UI。
- 微任务包括Promise的回调函数,DOM发生变化等,微任务需要尽可能快地,通过异步方式执行,同时不能产生全新的微任务。
- 微任务能使得我们能够在重新渲染UI之前执行指定的行为,避免不必要的UI重绘,UI重绘会使得应用状态不连续
另一些异步回调会进入 microtask queue(微任务队列)
,等待后续被调用,这些异步函数包括:
- process.nextTick (Node)
- Promise.then()
- catch
- finally
- Object.observe
- MutationObserver
这里有一点需要注意的:
Promise.then()
与new Promise(() => {}).then()
是不同的,前面的是一个微任务,后面的new Promise()
这一部分是一个构造函数,这是一个同步任务,后面的.then()
才是一个微任务,这一点是非常重要的。
浏览器中的EventLoop
关于宏任务与微任务我们看看下面的执行流程
最开始有一个执行栈,当执行到带有异步操作的宏任务的时候,比如 setTimeout 的时候就会将这个异步任务存在背景线程里面,待本次的事件执行完成以后再去执行微任务。即图中 Stack --> Background Thread
。但是需要注意到,从 Stack --> Microtask Queue
还有一条路线,意思就是在当前这轮的任务中还有执行微任务的操作。当前轮的微任务优先于宏任务异步操作先执行,执行完成到 loop
中,进入到下一轮。下一轮执行之前的宏任务的异步操作,比如 setTimeout
。此时,如果这个异步任务中还有微任务,那么就会执行完成这个微任务,在执行下一个异步任务。就这样一次的循环。
回到最开始的那道题上面
1 | setTimeout( () => console.log(4)) |
整个这一串代码我们所在的层级我们看做一个任务,其中我们先执行同步代码。第一行的 setTimeout
是异步代码,跳过,来到了 new Promise(...)
这一段代码。前面提到过,这种方式是一个构造函数,是一个同步代码,所以执行同步代码里面的函数,即 console.log(1)
,接下来是一个 then
的异步,跳过。在往下,是一个Promise.then()
的异步,跳过。最后一个是一段同步代码 console.log(2)
。所以,这一轮中我们知道打印了1, 2
两个值。接下来进入下一步,即之前我们跳过的异步的代码。从上午下,第一个是 setTimeout
,还有两个是 Promise.then()
。setTimeout
是宏任务的异步,Promise.then()
是微任务的异步,微任务是优先于宏任务执行的,所以,此时会先跳过 setTimeout
任务,执行两个 Promise.then()
的微任务。所以此时会执行 console.log(3)
和 console.log(5)
两个函数。最后就只剩下 setTimeout
函数没有执行,所以最后执行 console.log(4)
。
综上:最后的执行结果是 1, 2, 3, 5, 4
。
这只是我们的推测的结果,我们来看看在浏览器中的实际的打印结果是什么?
从图中可以看到,实际的运行结果与我们推测的结果是一一致的。所以,我们上面的分析步骤是正确的。
但是有一个问题,什么呢?可以看到,在浏览器中,会有一个 undefined
的返回值。为什么呢?这是因为浏览器将上面的一整段代码当成一个函数,而这个函数执行完成以后返回了 undefined
。那么?这就完了吗?没有。我们看看浏览器返回的截图中,3,5
两个数字其实是在 undefined
前面。3,5
两个数是两个 Promise.then()
中的 console.log()
的打印值,而 undefined
在这里可以作为一轮任务的结束。这表明的意思就是,微任务会在下一轮任务开始前执行。
这一切都是针对于浏览器的EventLoop。在NodeJS的环境中,可能就会有不同的结果。至于结果如何,我们暂时先不讨论,在来看一段代码。
1 | setTimeout( () => { |
在浏览器中执行结果:
上面就是关于在浏览器中的EventLoop。附上浏览器上面的可视化操作
NodeJS中的EventLoop
虽然NodeJS中的JavaScript运行环境也是V8,也是单线程,但是,还是有一些与浏览器中的表现是不一样的。
上面的图片的上半部分来自NodeJS官网。下面的图片来自互联网。
同样的两段代码,我们在node环境中执行一下,看看结果。
从上面的图中可以看到,实际的运行结果与浏览器中的运行结果并无二致。
在来看看另一段代码
1 | setTimeout( () => { |
他的执行结果是:1,2,3,5,4,8,7,6,9
。
与浏览器的1,2,3,5,4,7,8,6,9
不同。
对比浏览器与NodeJS的不同
在大部分情况下,浏览器与NodeJS的运行没有区别,唯一有区别的是在第二轮事件执行的时候,如果有多个宏任务(setTimeout
),浏览器会依次的执行宏任务,上一个宏任务执行完成了在执行下一个宏任务。在NodeJS中,则是相当于并行执行,相当于把所有的宏任务组合到一个宏任务中,再在这个组合后宏任务中,依次执行同步代码 --> 微任务 --> 宏任务
。
NodeJS中的process.nextTick
关于 process.nextTick
,就只需要记住一点,那就是 process.nextTick
优先于其他的微任务执行。
所以,下面的代码中:
1 | console.log('1'); |
分析(以Node作为运行环境,因为process在node中才存在):
第一轮事件循环流程:
- 整体的script代码作为第一个宏任务进入主线程,执行同步代码,遇到
console.log(1)
,输出1
- 遇到setTimeout,其回调函数被分发到宏任务的 Event Queue 中,等待执行。这里标记为
setTimeout1
- 遇到
process.nextTick
,其回调函数被分发到微任务的 Event Queue 中,等待执行。 - 遇到new Promise,这是一个构造函数,new Promise构造函数直接执行,遇到
console.log(7)
,输出7
。接着Promise.then()函数被分发到微任务的 Event Queue 中,等待执行。
遇到setTimeout,其回调函数被分发到宏任务的 Event Queue 中,等待执行。这里标记为setTimeout2
将上面的统计一下
宏任务Event Queue | 微任务Event Queue |
---|---|
setTimeout1 | process.nextTick |
setTimeout2 | Promise.then() |
第一轮事件循环同步代码执行完成,接下来执行微任务
。
微任务有两个,一个是 process.nextTick
,里一个是 Promise.then()
。
前面说了,process.nextTick
优先于其他的微任务执行,所以
- 执行process.nextTick:输出
6
- 执行Promise.then():输出
8
到此,第一轮事件循环结束,最终第一轮事件的输出为 1,7,6,8
。开始执行第二轮事件循环(setTimeout)。
第二轮事件循环分析
- 在
setTimeout1
与setTimeout2
中先找同步代码 - setTimeout1 中遇到 console.log(2),输出
2
- setTimeout1 中遇到 process.nextTick,放在第二轮的微任务的Event Queue中,等待执行。这里标记为
process_1
- setTimeout1 中遇到 new Promise ,执行同步代码,输出
4
, Promise.then() 放到微任务的Event Queue中,等待执行。这里标记为Promise_1
- setTimeout2 中遇到 console.log(9),输出
9
- setTimeout2 中遇到 process.nextTick,放在第二轮的微任务的Event Queue中,等待执行。这里标记为
process_2
- setTimeout2 中遇到 new Promise ,执行同步代码,输出
11
, Promise.then() 放到微任务的Event Queue中,等待执行。这里标记为Promise_2
第二轮的统计
第二轮宏任务Event Queue | 第二轮微任务Event Queue |
---|---|
process_1 | |
Promise_1 | |
process_2 | |
Promise_2 |
第二轮没有事件循环中没有宏任务,有四个微任务。
四个微任务中,有两个 process
- 依次执行
process_1
和process_2
。输出:3, 10
- 一次执行
Promise_1
和Promise_2
。输出:5, 12
所以第二轮输出:2,4,9,11,3,10,5,12
最终的输出为:1,7,6,8,2,4,9,11,3,10,5,12
。
如果是在浏览器中,排除掉process
的输出,结果为:1,7,8,2,4,5,9,11,12
NodeJS中 setImmediate 与 setTimeout 的区别
在官方文档中的定义,setImmediate 为一次Event Loop执行完毕后调用。setTimeout 则是通过计算一个延迟时间后进行执行。
但是同时还提到了如果在主进程中直接执行这两个操作,很难保证哪个会先触发。因为如果主进程中先注册了两个任务,然后执行的代码耗时超过XXs,而这时定时器已经处于可执行回调的状态了。所以会先执行定时器,而执行完定时器以后才是结束了一次Event Loop,这时才会执行setImmediate。
1 | setTimeout(() => console.log('setTimeout')) |
node环境下执行上面的代码,可以看到如下结果
这两个console的结果是随机的。
我们可以通过一些处理,使得我们可以先执行 setTimeout
或者是 setImmediate
。
但是如果后续添加一些代码以后,就可以保证setTimeout一定会在setImmediate之前触发了:
1 | setTimeout(_ => console.log('setTimeout')) |
如果在另一个宏任务中,必然是setImmediate先执行:
1 | require('fs').readFile(__dirname, _ => { |
上面的为什么有这样的解决方法,从上面的定义中就可以看出来。
关于 async/await 函数
因为,async/await本质上还是基于Promise的一些封装,而Promise是属于微任务的一种。所以在使用await关键字与Promise.then效果类似
1 | setTimeout(() => console.log(4)) |
输出的结果是:1,2,3,4
。
可以理解为,await
以前的代码,相当于与 new Promise
的同构代码,以后的代码相当于 Promise.then
。到await
的时候就会执行await
后面的函数(相当于和前面的代码同步执行)。
总结
之前了解过JavaScript单线程,也了解过JavaScript代码的执行顺序,但是宏任务与微任务也是最近才听说的,这对于一个从事两年前端的开发者真的是,我自己的过失。或需又是因为我是转行的,没有过相关的基础,没有接触到这方面的只是。不过现在我很高兴,因为我对JavaScript的执行有了更多的了解,相比于之前的只是,真的是了解了很多。学习永远都不晚,就怕你从来都不想去了解。在了解EventLoop,宏任务与微任务,JavaScript单线程的时候,参考了一些文档