JavaScript中的EventLoop 机制
前言
让我们先来看一个简单的例子:
Q: 请写出下列JS代码的运行结果。
1 | console.log(1); |
——题目来自SAST2020应用开发部Web部分招新题。
我们通过浏览器控制台可以很容易得出以下运行结果。
1 | 1 |
作为当时批改前端部分的我,我曾问那些写出运行结果的新同学为什么答案是这个顺序,结果并没有人可以答出个所以然来。当然了,作为刚接触web的同学来说,上来就要你谈JavaScript的事件循环机制确实难了一点。
但是这的确是一个在前端面试中非常常见的一个问题。
相关的问题还有:
- 请你谈一下浏览器的渲染原理。
- 请你聊一下JavaScript的运行机制。
- 请你说一说node的事件循环和浏览器的事件循环区别是什么。
- 什么是宏任务、微任务?为什么需要引入这样的设定?
这里就简要的讲一下我的一些理解。
事件循环
JavaScript的单线程性
JavaScript语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。
那么,为什么JavaScript不能有多个线程呢?这样能提高效率啊。
这和我们之前聊过的浏览器渲染原理有关系。
浏览器渲染原理:
用户请求的HTML数据通过浏览器的网络层到达渲染引擎后,浏览器的渲染工作开始。
这里我们以Webkit引擎渲染的流程为例,其余引擎大体上相似:
渲染流程有四个主要步骤:
- 解析HTML生成DOM树 - 渲染引擎首先解析HTML文档,生成DOM树
- 构建Render树 - 接下来不管是内联式,外联式还是嵌入式引入的CSS样式会被解析生成CSSOM树,根据DOM树与CSSOM树生成另外一棵用于渲染的树-渲染树(Render tree),
- 布局Render树 - 然后对渲染树的每个节点进行布局处理,确定其在屏幕上的显示位置
- 绘制Render树 - 最后遍历渲染树并用UI后端层将每一个节点绘制出来。
JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动、数据传输以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。
比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来基本上也不会改变。
当然,为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM,只能做一些计算性质的工作。所以,这个新标准并没有改变JavaScript单线程的本质。
执行栈和事件队列
因为JavaScript的单线程性,所以它的函数调用是通过一个执行栈来实现的。
执行栈: 同步代码的执行,按照顺序添加到执行栈中。
下面是一个简单的例子展现JS执行栈的过程:
1 | function a() { |
- 执行函数
a()
先入栈 a()
中先执行函数b()
函数b()
入栈- 执行函数
b()
,console.log('b')
入栈 - 输出
b
,console.log('b')
出栈 - 函数
b()
执行完成,出栈 console.log('a')
入栈,执行,输出a
, 出栈- 函数a 执行完成,出栈。
但是我们的JS中并不都是顺序执行的脚本,有一些异步执行的代码,例如setTimeout()
, setInterval()
…
或者当我们遇到一些执行时间特别长的代码,我们也需要对其进行异步,防止阻塞JS线程。
事件队列: 异步代码的执行,遇到异步事件不会等待它返回结果,而是将这个事件挂起,继续执行执行栈中的其他任务。当异步事件返回结果,将它放到事件队列中,被放入事件队列不会立刻执行起回调,而是等待当前执行栈中所有任务都执行完毕,主线程空闲状态,主线程会去查找事件队列中是否有任务,如果有,则取出排在第一位的事件,并把这个事件对应的回调放到执行栈中,然后执行其中的同步代码。
简单理解,就是JS在执行中,非异步的代码将放入执行栈中,先入后出,如果遇到异步的代码,则放入事件队列中挂起,等待回调,先入先出。
所谓的事件循环(EventLoop),其实就是异步事件被放入事件队列(Task Queen/Message Queen)中后又经过回调加入主线程中的这样一个循环过程。
事件循环:
(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
(4)主线程不断重复上面的第三步。
宏任务和微任务
在ES6中,引入了宏任务和微任务的概念。不同的任务被分为:宏任务和微任务
页面渲染事件,各种IO的完成事件等随时被添加到任务队列中,一直会保持先进先出的原则执行,我们不能准确地控制这些事件被添加到任务队列中的位置。但是这个时候突然有高优先级的任务需要尽快执行,那么一种类型的任务就不合适了,所以引入了微任务队列。
宏任务:
- script(整体代码)
- setTimeout()
- setInterval()
- postMessage
- I/O
- UI交互事件
微任务:
- Promise().then(回调)
- MutationObserver(html5 新特性)
此时的事件循环机制将对宏任务和微任务进行区分,运行机制如下:
- 执行一个宏任务(栈中没有就从事件队列中获取)
- 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
- 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
- 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
- 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)
使用较为形象的语言来说就是:
微任务是跟屁虫,一直跟在当前的宏任务后面,当前宏任务执行完成后,按照微任务队列执行所有微任务,再开始下一个宏任务。
回到最开始的例子:
1 | console.log(1); |
setTimeout()
和Promise()
都是异步事件,但是他们也有顺序。
前者是宏任务,后者是微任务。
值得注意的一点是,new
一个Promise
对象是瞬间执行的,异步的是promise.then
的部分。
下图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。
下面是一段代码的执行动画:
下面在招新题上来一个复杂一点的扩展:
1 | console.log(1); |
请你不通过控制台运行,直接写出执行结果吧!
[参考资料]: