请说明浏览器中的事件循环 (Event Loop)

2022年10月21日

💎 加入 E+ 成長計畫 與超過 300+ 位軟體工程師一同在社群中成長,並且獲得更多的軟體工程學習資源

事件循环 (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 的堆(Heap)、栈(Stack)和队列(Queue)
事件循环的堆(Heap)、栈(Stack)和队列( Queue)

事件循环 (Event loop)

整个事件循环大概可以分为几个步骤

  1. 所有任务都会在主线程上执行,形成一个执行栈
  2. 如果遇到异步任务,例如:setTimeout,执行环境会调用相关的 API (例如在浏览器上会调用 Web API),等待此异步任务的结果之后,再被放置到任务队列中
  3. 一旦执行栈的所有同步任务完成之后,就会读取任务队列,并将任务队列第一个,加到执行栈中运行
  4. 只要执行栈空了之后,就会读取任务队列,不断重复这个步骤,直到所有任务完成,这个流程就是**事件循环 (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(整体代码)、setTimeoutsetInterval、I/O、事件、postMessageMessageChannelsetImmediate (Node.js)
  • 微任务:Promise.thenMutaionObserverprocess.nextTick (Node.js)。

执行顺序如下:

  • 执行一次宏任务 (最开始会是整个 srcipt 所以上面的例子会先执行 console.log(1)
  • 执行过程中如果遇到宏任务,就放进宏任务列队
  • 执行过程中如果遇到微任务,就放进微任务列队
  • 当执行栈空了,先检查微任务列队,如果有微任务,就依序执行直到微任务列队为空
  • 接着进行浏览器的渲染,渲然完后开始下一个宏任务 (回到最开始的步骤)

延伸题:requestAnimationFramerequestIdleCallback

在事件循环的面试题中,也会问到 requestAnimationFramerequestIdleCallback 在事件循环中的发生时机点。 requestAnimationFrame 发生的顺序会是在下次页面重绘之前操作(style calculation、layout、paint 这些渲染步骤前),因为浏览器在每次事件循环中,不一定会重新绘制页面;因此requestAnimationFrame 执行时机点其触发时间点跟任务列队关系比较小,而是跟页面重绘关系比较大。

requestIdleCallback 则是在浏览器渲染后,如果有闲余时间时则会触发。

常考的事件循环判读题目

除了上面那一题基础的判读题外,在许多前端面试中,会考更进阶的事件循环判读题。通常考法会是给一段代码,然后要你说出正确的顺序。如果想针对这类题目多做练习,欢迎前往《最常见的事件循环(Event Loop) 面试题目汇整》一文。

🧵 如果你想收到最即時的內容更新,可以在 FacebookInstagram 上追蹤我們