写在前面
如何打好JavaScript基础?深入剖析V8的代码执行机制--JS基础篇(一) - 掘金 (juejin.cn)
前面我分享了有关于JS引擎如何处理代码执行的一些内容。通过调用栈,JS高效地管理着全局执行上下文与函数执行上下文,这保证了JS在遇到异步任务时仍然有良好的响应性。
如果还不了解调用栈等知识的友友,请移步👆文章,因为这些基础知识对于我们理解JS的事件循环机制(Event loop)很有帮助。
正文
一、Event Loop的设计初衷
事件循环(Event Loop)机制是用于解决JS单线程环境中对异步操作的管理问题。因为JS是单线程的,执行任务时只能逐个执行。
在现代编程环境中,我们经常会进行一些耗时的操作,如:网络I/O操作(Ajax请求、Fetch API调用)和定时器(setTimeout和setInterval)等等。这些操作相比于简单的算术运算、变量赋值和结果打印繁重得多。
如果在JS的单线程环境下,任务的执行只是简单的顺序执行,那么可能有些简单任务或者说与耗时任务不相关的任务会被 耽误 很长时间。
我们举个🌰(例子):
在一条路上有一个关卡,要求出示证件才允许车辆通行。一天,小明骑着小毛驴经过的时候,发现前面有许多大型车辆排队,由于小明是一个老实人,就乖乖排队。可是过了半个小时,整个队伍一动不动,小明也忍不了了。于是小明凭借着车辆的优势去到了队伍前面,询问工作人员后得知,一辆大车手续不齐全,得补全手续才能过去。小明想着自己带齐了证件,就想着先过去,但是工作人员坚决不让其先过去,说得按先来后到!最后,小明等了好几辆手续不全的车通过才轮到他。
这就比较悲哀了,小明手续齐全能很快通过,相当于一个不耗时的任务;手续不全的其他车都不能立马通过,属于耗时任务。严格按照排序来通过,结果就是效率十分低下。
JS官方为了更好的处理这些不耗时和耗时任务(异步)的调度,就设计了一个事件循环(Event Loop)执行机制。
事件循环机制中的一个核心是将任务分成了两种:同步任务和异步任务。
二、Event Loop的两种任务
1 同步任务(Synchronous tasks)
这一类任务会直接在主线程上执行,JS会按照代码的编写顺序将所有同步任务放入同步队列。
-
执行顺序:遵循编写顺序,逐行执行。
-
执行环境:所有同步任务都是在JS的主线程上执行,主线程是JS引擎执行代码的唯一执行上下文(可以理解为代码的执行环境)。
-
阻塞行为:注意,如果同步任务中包含
耗时操作(非异步,如复杂的循环),整个主线程会被阻塞,直到这个同步的耗时操作完成,在这期间,其他同步任务都会在同步任务队列中等待。
2 异步任务(Asynchronous tasks)
这一类任务通常是耗时任务,当JS引擎执行代码时遇到异步代码,它会启动异步任务(交由浏览器或Node.js环境执行),然后继续执行后续代码,而不会等待异步任务完成。
当异步任务完成时,其回调函数(在JS主线程执行)会被放入异步队列,等待事件循环机制处理,当执行栈(call stack)清空后,由事件循环机制调度到JS主线程上执行。
-
执行顺序: 具体根据事件队列里的任务类型判断,稍后会重点讲。
-
执行环境: 异步任务启动后交由浏览器或者Node.js执行,异步任务完成后,其回调函数由JS主线程执行。
-
阻塞行为:异步任务不阻塞主线程(异步任务设计初衷),除了启动异步任务的代码(例如:setTimeout),异步任务不会在主线程执行。
来看个例子:
结果如下:
- ⬇️开始执行
⬇️同步任务 0
⬇️同步任务 1
⬇️同步任务 2
⬇️触发异步任务
⬇️同步任务继续执行
⬇️同步任务 3
⬇️同步任务 4
⬇️同步任务 5
⬇️所有同步任务执行完毕
⬇️异步任务执行完毕
异步任务启动后JS引擎继续执行同步代码。一般来说,只要没有在同步代码中引入新的异步任务,异步任务的回调函数会在所有同步任务执行完毕之后执行。这是JavaScript异步模型的基本行为之一。
注意:并非所有的耗时任务都是异步任务,任务类型的判断取决于是否阻塞主线程,同步任务会阻塞主线程,异步任务不会阻塞主线程。
三、Event Loop的主角--异步任务
如果说同步任务是事件循环机制中的核心,那么异步任务就是核心的核心,理解了事件循环机制中异步任务的处理过程,我们才算是理解JS的异步编程。
在上面的例子中:
启动异步任务后,JS引擎就继续将后面所有同步任务都执行完毕。那么在这个过程中,会不会出现这种情况呢?
在JS引擎执行剩余的同步代码过程中,交给浏览器线程或者Node.js环境的异步任务就已经执行完毕。当然有可能!
那么你有没有这样一个疑问呢?异步任务结束后,其回调函数又是一个同步任务,那么这个回调函数会何时执行呢?
看完例子你就知道了:
这里有一个异步任务setTimeout中内嵌了同步任务和另一个异步任务。执行结果如下:
上面的异步任务setTimeout等待时间为0,意味着在最后一个同步任务执行之前就完成了。但是从结果我们可以得出结论, 就算异步任务在同步任务之前完成,该异步任务的回调函数也不会先执行 ,而是会等待外部所有同步任务执行完毕。
解释原理之前,我们还要学习三个抽象概念:
🚀异步任务执行基础
1 调用栈(Call Stack)
调用栈用于维护函数间的调用关系,一个函数或者一个全局环境都可以作为一个元素压入调用栈。执行时,JS引擎从栈顶开始处理各个元素。(注:所有代码都在函数作用域或者全局作用域内)
2 宏任务队列(Macrotask Queue)
存放宏任务(异步任务)的回调函数的队列,宏任务是异步操作的类型之一。
包括:
setTimeout、setInterval、setImmediate、DOM操作和I/O操作等......
3 微任务队列(Microtask Queue)
存放微任务(异步任务)的回调函数的队列,微任务是异步操作的类型之一。
包括:
Promise的.then(), .catch(), .finally()等方法中的回调MutationObserver的回调process.nextTick(在Node.js中)
注:由于调用栈维护的是可执行的单元,即使异步任务内没有回调函数,其内部实现的代码也会被底层JS环境转换为可执行的单元。
♻️事件循环机制
了解了上述基本概念后,我们来看一张流程图:
看不懂?没关系,听我细细道来:
-
首先,JS引擎执行代码,判断同步还是异步,遇到同步任务直接在JS主线程上执行同步任务,具体过程为放入调用栈执行。
-
如果遇到异步任务就将其交给浏览器或者Node.js环境的线程执行,在异步任务结束后返回一个回调函数。
-
将回调函数划分为
宏任务和微任务放入对应任务队列。 -
检查微任务队列,异步任务中默认优先执行微任务,当微任务队列不为空,且调用栈为空(无同步任务),则将微任务队列的回调函数放入调用栈执行。
-
检查宏任务队列,当微任务队列为空(无微任务队列),将微任务的回调函数放入调用栈执行。
-
过程中,微任务遇到同步任务
会暂停执行下一个微任务,宏任务遇到微任务会暂停执行下一个宏任务。
同步任务与异步任务的处理可以同步进行,因为同步任务占用JS主线程,异步任务占用浏览器或者Node.js环境线程,但是对于异步任务的回调函数,其必须在所有同步任务后执行。
没看懂?那强烈建议多看几遍!!!
以上就是Event Loop机制的详细解释。
接下来我们分析之前的例子,如图:
下面我统一使用任务x代表数字x。
先执行同步任务1。
遇到异步任务setTimeout,启动异步任务,继续执行最后一行任务2。
外部同步任务执行完毕后,进入异步任务的回调函数。遇到任务3,执行。
遇到另一个异步任务,启动异步任务。继续执行任务4。
执行完成之后,执行异步任务的回调函数,任务5执行完毕。
🔍补充知识:
回调函数:
回调函数(Callback Function)是一种将函数作为参数传递给另一个函数的概念,这样传递的函数就可以在外部完成某些操作后再被调用,回调函数主要用于异步编程汇总,允许开发者指定在某个事件发生后要执行的代码。
总结
本期讲了:
- Event Loop的设计初衷
- Event Loop的两种任务
- 同步任务
- 异步任务
- Event Loop的主角--异步任务
- 异步任务执行基础
- 调用栈
- 宏任务队列
- 微任务队列
- 事件循环机制(重点❗️)
- 异步任务执行基础
- 回调函数概念
感谢看到这里的你,希望你有所收获,下一期Promise对象。如果你觉得本篇文章对你有帮助,还请给个小赞,这将是我持续创作的动力!