请说明浏览器中的事件循环 (Event Loop)
2022年10月21日
事件循环 (Event loop) 绝对是一定要准备的面试经典题之一、衍伸题型也相当多元,读者一定要对此概念有一定程度的掌握。在本篇文章中,我们将整理事件循环(Event loop) 的基本观念,如果想练习事件循环的模拟考题,可以前往《最常见的事件循环(Event Loop) 面试题目汇整》一文。
同步 (synchronous) 与异步 (asynchronous)
在讨论事件循环前,我们需要先了解同步与异步的概念。 JavaScript 是单执行绪的程式语言,一行代码执行完才会再执行下一行,这个概念称之为同步 (synchronous)。但这样其实会遇到一个问题,试想一个情境:假设有一网站需要去服务器端拿取资料,但需要等十秒之后才会拿到,此外等待途中网站无法执行任何动作,这对于使用者来说,会认为画面定格十秒钟、就像当机一样,绝对是很糟糕的使用者体验,于是就有了异步(asynchronous) 的概念。
异步的代码或事件,并不会阻碍主线程执行其他代码,以上面网站向服务器拿取资料为例,拿取资料当作是一个异步事件,异步事件会在完成之后再通知主线程,而在这之中,主线程可以继续执行其他代码、使用者互动也不受异步事件的阻挡。而浏览器或其他的执行环境 (例如 Node.js) 之所以能够实践异步,正是因为有事件循环 (Event loop) 的机制。透过事件循环机制,能有效解决 JavaScript 单执行绪的问题,让耗时的操作不会阻塞主线成。
事件循环 (Event loop) 的组成 - 执行栈和任务队列
事件循环不存在 JavaScript 本身,而是由 JavaScript 的执行环境 (浏览器或 Node.js) 来实现的,其中包含几个概念:
- 堆 (Heap):堆是一种数据结构,拿来储存物件
- 栈 (Stack):采用后进先出的规则,当函式执行时,会被添加到栈的顶部,当执行完成时,就会从顶部移出,直到栈被清空
- 队列 (Queue):也是一种数据结构,特性是先进先出 (FIFO)。在 JavaScript 的执行环境中,等待处理的任务会被放在队列(Queue) 里面,等待栈 (Stack) 被清空时,会从队列(Queue)中拿取第一个任务进行处理
- 事件循环 (Event loop):事件循环会不断地去查看栈(Stack) 是否空出,如果空出就会把队列 (Queue)中等待的任务放进栈(Stack)中执行
事件循环 (Event loop)
整个事件循环大概可以分为几个步骤
- 所有任务都会在主线程上执行,形成一个执行栈
- 如果遇到异步任务,例如:
setTimeout
,执行环境会调用相关的 API (例如在浏览器上会调用 Web API),等待此异步任务的结果之后,再被放置到任务队列中 - 一旦执行栈的所有同步任务完成之后,就会读取任务队列,并将任务队列第一个,加到执行栈中运行
- 只要执行栈空了之后,就会读取任务队列,不断重复这个步骤,直到所有任务完成,这个流程就是**事件循环 (Event loop) **
宏任务 (Macro Task) 与微任务 (Micro Task)
除了事件循环的流程以外,面对这个面试题,宏任务 (Macro Task) 与微任务 (Micro Task) 也是必提的概念。 JavaScript 中的异步任务又分成宏任务 (Macro Task) 和微任务 (Micro Task),这两者的执行顺序是不同的。如果不分清楚这两种类别的任务,很可能程式执行出的顺序会跟预期的不同。
举例来说,下面这段代码,印出的顺序会是什么呢?
console.log(1);
setTimeout(function () {
console.log(2);
}, 0);
Promise.resolve()
.then(function () {
console.log(3);
})
.then(function () {
console.log(4);
});
假如只单纯区分同步与异步,可能会回答 1234
;但是正确答案应该是 1342
。为什么是1342
? setTimeout
不是设定 0 毫秒,这样为什么会是Promise
里面的东西先执行呢? 原因是Promise
会进到微任务列队,而setTimeout
会是在宏任务列队。在一次事件循环中,宏任务一次只提取一个,所以console.log(1)
后,会先去看微任务列队,不断提取到执行栈中直到微任务列队为空,因此这边会先执行Promise
,然后才是setTimeout
。
常见的宏任务与微任务如下:
- 宏任务:
script
(整体代码)、setTimeout
、setInterval
、I/O、事件、postMessage
、MessageChannel
、setImmediate
(Node.js) - 微任务:
Promise.then
、MutaionObserver
、process.nextTick
(Node.js)。
执行顺序如下:
- 执行一次宏任务 (最开始会是整个
srcipt
所以上面的例子会先执行console.log(1)
- 执行过程中如果遇到宏任务,就放进宏任务列队
- 执行过程中如果遇到微任务,就放进微任务列队
- 当执行栈空了,先检查微任务列队,如果有微任务,就依序执行直到微任务列队为空
- 接着进行浏览器的渲染,渲然完后开始下一个宏任务 (回到最开始的步骤)
延伸题:requestAnimationFrame
与 requestIdleCallback
在事件循环的面试题中,也会问到 requestAnimationFrame
与 requestIdleCallback
在事件循环中的发生时机点。 requestAnimationFrame
发生的顺序会是在下次页面重绘之前操作(style calculation、layout、paint 这些渲染步骤前),因为浏览器在每次事件循环中,不一定会重新绘制页面;因此requestAnimationFrame
执行时机点其触发时间点跟任务列队关系比较小,而是跟页面重绘关系比较大。
而 requestIdleCallback
则是在浏览器渲染后,如果有闲余时间时则会触发。
常考的事件循环判读题目
除了上面那一题基础的判读题外,在许多前端面试中,会考更进阶的事件循环判读题。通常考法会是给一段代码,然后要你说出正确的顺序。如果想针对这类题目多做练习,欢迎前往《最常见的事件循环(Event Loop) 面试题目汇整》一文。