🗒️Event Loop
type
status
date
slug
summary
tags
category
icon
password
Status
Javascript 是单线程语言,使用的是异步非阻塞的运行方式,很多情况下需要通过事件和回调函数进行驱动,那么这些注册的回调函数,是在什么时候被运行环境调用的,彼此之间又是以怎样的顺序执行的?这就绕不开一个机制——Event Loop ,也就是事件循环。
什么是 JS Event Loop
JS Event Loop 即事件循环,是运行在浏览器环境 / Node 环境中的一种消息通信机制,它是主线程之外的独立线程。当主线程内需要执行某些可能导致线程阻塞的耗时操作时(比如请求发送与接收响应、文件 I/O、数据计算)主线程会注册一个回调函数并抛给 Event Loop 线程进行监听,自己则继续往下执行,一旦有消息返回并且主线程空闲的情况下,Event Loop 会及时通知主线程,执行对应的回调函数获取信息,以此达到非阻塞的目的。
执行栈和消息队列
在解析
Event Loop
运行机制之前,我们要先理解栈(stack
)和队列(queue
)的概念。栈和队列,两者都是线性结构,但是栈遵循的是后进先出(
last in first off LIFO
),开口封底。而队列遵循的是先进先出 (fisrt in first out,FIFO
),两头通透。Event Loop
得以顺利执行,它所依赖的容器环境,就和这两个概念有关。我们知道,在
js
代码执行过程中,会生成一个当前环境的执行上下文( 执行环境 / 作用域),用于存放当前环境中的变量,这个上下文环境被生成以后,就会被推入js
的执行栈。一旦执行完成,那么这个执行上下文就会被执行栈弹出,里面相关的变量会被销毁,在下一轮垃圾收集到来的时候,环境里的变量占据的内存就能得以释放。这个执行栈,也可以理解为浏览器执行
JavaScript
的主线程,所有代码都跑在这个里面,以同步阻塞的方式依次执行,这是同步的场景。那么异步场景呢?显然就需要一个独立于执行栈之外的容器,专门管理这些异步的状态,于是在“主线程”、“执行栈”之外,有了一个
Task
的消息队列结构,专门用于管理异步逻辑。所有异步操作的回调,都会暂时被塞入这个队列。Event Loop
处在两者之间,扮演一个大管家的角色,它会以一个固定的时间间隔不断轮询,当它发现主线程空闲,就会去到 Task
队列里拿一个异步回调,把它塞入执行栈中执行,一段时间后,主线程执行完成,执行栈弹出上下文环境,再次空闲,Event Loop
又会执行同样的操作。。。依次循环,于是构成了一套完整的事件循环运行机制。上图比较简洁地描绘了整个过程,只不过其中多了
heap
(堆)的概念,堆和栈,简单来说,堆是留给开发者分配的内存空间,而栈是原生编译器要使用的内存空间,二者独立。microtask 和 macrotask
如果只想应付普通点的面试,上面一节的内容就足够了,但是想要答出下面的这条面试题,就必须再次深入
Event Loop
,了解任务队列的深层原理:microtask
(微任务)和 macrotask
(宏任务)。如果只有一个单一的
Task
队列,就不存在上面的顺序问题了。但事实情况是,浏览器会根据任务性质的不同,将不同的任务源塞入不同的队列中,任务源可以分为微任务(microtask
) 和宏任务(macrotask
),介于浏览器对两种不同任务源队列中回调函数的读取机制,造成了上述代码中的执行顺序问题。微任务包括
process.nextTick
,promise
,MutationObserver
,其中 process.nextTick
为 Node 独有。宏任务包括
script
, setTimeout
,setInterval
,setImmediate
,I/O
,UI rendering
。浏览器 Event Loop 的执行机制
了解了微任务宏任务的概念后,我们就可以完整地分析一边 Event Loop 的执行机制了。
- 初始状态:执行栈为空,micro 队列为空,macro 队列里有且只有一个 script 脚本(整体代码)
- script 脚本执行:全局上下文(script 任务)被推入执行栈,代码以同步的方式以此执行。在执行的过程中,可能会产生新的
macro-task
与micro-task
,它们会分别被推入各自的任务队列里
- script 脚本出队:同步代码执行完了,script 脚本会被移出 macro 队列,这个过程本质上是宏任务队列的执行和出队的过程。
- 微任务队列执行:上一步已经将
script
宏任务执行并出队了,这时候执行栈为空,Event Loop 会去micro-task
中将微任务推入主线程执行,这里的微任务的执行方式和宏任务的执行方式有个很重要的区别,就是:宏任务是一个一个执行,而微任务是一队一队执行的。也就是说,执行一个宏任务,要执行一队的微任务。(注意:在执行微任务的过程中,仍有可能有新的微任务插入micro-task
那么这种情况下,Event Loop 仍然需要将本次Tick (循环)
下的微任务拿到主线程中执行完毕)
宏任务按个执行,微任务按队执行
- 浏览器执行渲染操作,更新界面(这块是重点)
- 检查是否存在 Web worker 任务,如果有,则对其进行处理 。
- 上述过程循环往复,直到两个队列都清空
浏览器渲染时机
在上面浏览器 Event Loop 的执行机制中,有很重要的一块内容,就是浏览器的渲染时机,浏览器会等到当前的
micro-task
为空的时候,进行一次重新渲染。所以如果你需要在异步的操作后重新渲染 DOM 最好的方法是将它包装成 micro
任务,这样 DOM 渲染将会在本次 Tick
内就完成。面试题解析
看到这里,相信你已经明白上面的那条面试题是怎么一回事了,我们可以用对 Event Loop 的理解来分析一下这道题目的执行:
script
脚本开头碰到了console.log
于是打印script start
- 解析至
async1()
,async1
执行环境被推入执行栈,解析引擎进入async1
内部
- 发现
async1
内部调用了async2
,于是继续进入async 2
,并将async 2
执行环境推入执行栈
- 碰到
console.log
,于是打印async2 end
async2
函数执行完成,弹出执行栈,并返回了一个Promise.resolve(undefined)
,此时,由于 Promise 已经变成 resolve 状态,于是async1
then
注册的回调被推入 microtask
- 解析至
setTimeout
,等待 0ms 后将其回调推入 macrotask
- 继续执行,直到碰到了
Promise
,new Promise
的内部注册的回调是立即执行的,解析进入注入函数的内部,碰到console.log
,于是打印'Promise'
,再往下,碰到了resolve
,将第一个then
中的回调函数推入micro-task
,然后碰到了第二个 then ,继续将其中的回调函数推入micro-task
。
- 执行到最后一段代码,打印
script end
- 自此,第一轮 Tick 中的一个宏任务执行完成,开始执行微任务队列,通过前面的分析可以得知,目前
micro-task
中有三个任务,依次为:console.log('async 1')
、console.log('promise1')
、console.log('promise2')
于是 Event Loop 会将这三个回调依次取到主线程执行,控制台打印:async1、promise1、promise2
- 自此,
micro-task
为空,浏览器开始重新渲染(如果有 DOM 操作的话)
- Event Loop 再次启动一个新的 Tick ,从宏任务队列中拿出一个(唯一的一个)宏任务执行,打印出:setTimeout
- Twikoo