通过可视化的方式搞懂 JavaScript 事件循环

434 阅读9分钟

我正在参加「掘金·启航计划」

大家好,我是晚天

我们知道,JavaScript 是一个单线程、非阻塞、异步、解释型语言。JavaScript 需要一个解释器将 JavaScript 代码转换成机器码。解释器也被称为引擎,例如我们常说的 chrome 中的 V8 引擎、Safari 中的 Webkit 引擎等。每个引擎都包含内存、回调栈、事件循环、timer、WebAPI,事件等。

JavaScript 是单线程的,意味着在 JavaScript 中两个语句是不能并行执行的。也就是说,每个 JavaScript 语句都是同步且阻塞的。但是,我们依然可以使用 setTimeout 等方式异步运行代码。

那么 JavaScript 是如何进行异步操作的呢?

基础概念

了解 JavaScript 如何进行异步操作之前,我们需要先了几个概念:

  • 调用栈(Call Stack)
  • 队列

调用栈(Call Stack)

调用栈是一个记录函数调用的后进先出(LIFO,Last in,First out)的数据结构。当一个函数开始执行时,这个函数的执行上下文会被推到调用栈顶部,当函数执行完成后,这个函数的执行上下文会被从调用栈顶部弹出。更多细节可以阅读文章《深入理解 JavaScript 的执行上下文和执行栈》。

堆(Heap)

堆是 JavaScript 存储引用类型数据的地方,比如对象、函数等。JavaScript 中堆和栈是两个令人迷惑的概念,详细理解可以阅读文章《2 分钟了解 JavaScript 中堆和栈的区别》。

队列(Queue)

与栈不同,队列是一种先进先出的数据结构。

JavaScript 运行时会维护一个消息队列,用于存储和处理回调函数消息。当栈有足够空间时,会从队列中取出一个消息,放置到调用栈顶部,执行消息关联的回调函数,当回调函数执行完成时,消息会从调用栈中弹出。

Web APIs

浏览器 Web API 是由浏览器创建的用 C++ 实现的线程,用于处理像 DOM 事件、HTTP 请求、setTimeout 之类的异步事件。

什么是事件循环?

JavaScript 是单线程语言,同时只能执行一个代码语句,也就是说如果一个语句运行时间过久,就可能导致 JavaScript 运行阻塞。比如一个 HTTP 请求或一个循环次数很多的循环语句就可能阻塞掉 JavaScript 的运行。

与此同时,JavaScript 又有一个非阻塞的特性,也就是说 JavaScript 有相应的机制解决这个阻塞问题。事件循环就是 JavaScript 解决这个问题的机制,也正因为事件循环,JavaScript 具有了异步性的特点,可以通过 setTimeout、setInterval、Promise 等方式实现异步操作。

用一张图来表示事件循环的过程:

当 WebAPI (DOM 事件、HTTP 请求、setTimeout 等)被触发后,相应的回调事件会在适当的时机放入队列中,当调用栈为空时,会从队列中取得回调函数推到调用栈顶部,然后执行回调函数。

不同 WebAPI 事件进入队列的时机:

  • DOM 事件:事件发生时,回调函数会立即被推到队列中去。
  • setTimeout

setTimeout 被调用时,不会立马将回调函数推到队列中去,setTimeout 会设置一个 timer,当 timer 过期时回调函数才会被推到队列中。

  • HTTP 请求:请求发送时,回调函数不会立即被推动队列中,而是会等待请求返回结果之后,回调函数才会被推动队列中。

需要特别注意的是,回调函数被推送到队列中,并不会立马被执行。举例说明:

setTimeout(myCallback,1000);

上述代码并不意味着 1000ms 之后,myCallback 函数一定会被执行。当上述代码执行后,等待 1000ms 之后 myCallback 函数会被推到队列中去。如果此时队列中还存在其他回调函数,myCallback 需要等待排在队列前面的回调函数执行完成之后,才会被推到调用栈中,然后被执行。

事件循环类型

  • Window 类型

一个 Window 类型的事件循环用于拥有相似 origin 的所有浏览器 window。这里的 window 实际上指的是浏览器级别容器,包括浏览器窗口、tab 或 frame 等。

在某些特定情况下,多个 window 可能会共享一个事件循环,比如:

    • 一个 window 打开了另一个 window;
    • 一个 window 中包含一个 ,该 iframe 和该 window 共享一个事件循环;
    • 在多进程浏览器中共享一个进程的多个 window;

以上情况可能在不同浏览器中实现是不同的。

  • Worker 类型

一个 worker 类型时间循环用于驱动一个 worker,包含所有类型的 worker,包括 web workers, shared workersservice workers。浏览器可能针对同一种类型的 worker 使用同一个事件循环,也可能使用多个事件循环。

  • Worklet 类型

一个 worklet 事件循环用于驱动 worklet 的代理,包括 Worklet, AudioWorkletPaintWorklet

宏任务(Macro Task)

宏任务指的是程序的初始执行、执行一个事件回调或者一个 setTimeoutsetInterval 方法等。

在以下时机,任务会被添加到任务队列:

  • 一段代码被直接执行时,比如从一个控制台或者在一个 <script> 标签中的运行代码;
  • 触发了一个事件,将其回调函数添加到任务队列时;
  • 执行到一个由 setTimeoutsetInterval 创建的 timeoutinterval,相应的回调函数被添加到任务队列时。

宏任务列表:

浏览器Node
I/O
setTimeout
setInterval
setImmediate
requestAnimationFrame
UI rendering

微任务(Micro Task)

微任务仅来自我们的代码,通常是由 promise 创建的:对 .then/catch/finally 处理程序的执行会称为微任务。通过 await 方式调用的函数也是微任务,因为 await 本质上是 promise 的另一种形式。

还有一种生成微任务的方式,就是使用 queueMicrotask(func) 函数,它会将 func 推到微任务队列中去。


微任务列表:

浏览器Node
process.nextTick
MutationObserver
Promise.then catch finally
queueMicrotask
Object.observe

宏任务 & 微任务区别

微任务和宏任务主要有两点区别:

  • 每次事件循环迭代运行时,都会执行宏任务队列中的每个任务,每次迭代开始之后加入到队列中的任务需要在下一个事件循环迭代开始之后才会被执行;
  • 每次当一个宏任务退出且调用栈为空的时候,微任务队列中的每一个微任务会依次被执行。值得注意的是,中途如果有微任务被加入到微任务队列,该微任务也会被执行。也就是说,微任务可以添加新的微任务到微任务队列中,并在当前时间循环迭代中下一个宏任务执行之前执行完成所有微任务。其中添加微任务到微任务队列的方法是 queueMicrotask()

为什么要有微任务?

微任务的出现,使得在宏任务执行完,到浏览器渲染之前,可以在这个阶段插入任务的能力。

一次事件循环迭代结束之后,浏览器会进行渲染工作。按照宏任务的运行机制,如果想在一次事件循环中插入一个任务,宏任务是无法完成的,这个时候微任务就可以发挥作用。在一次事件循环迭代中插入微任务,当所有微任务执行完成后,本次事件循环迭代才会结束。

如果只有宏任务,没有微任务,将无法在浏览器渲染前插入某个任务。

运行机制

每个宏任务之后,引擎会立即执行微任务队列中的所有任务,然后再执行其他的宏任务,或渲染,或进行其他任何操作。

任务队列中的都是已经完成的异步操作,而不是说注册一个异步任务就会被放在这个任务队列中。

在当前的微任务没有执行完成时,是不会执行下一个宏任务的。

简单总结事件循环的运行逻辑:同步代码-->全部微任务-->单个宏任务-->全部微任务-->单个宏任务.....(循环往复)。

一个令人迷惑的代码示例

这里有一个非常有迷惑性的代码实例,涵盖了 DOM 事件、setTimeoutPromiseMutationObserver

以下代码定义了 outer 和 inner 两个 DOM 元素的 Click 事件,其中 outer 是 inner 的容器。

接下来,让我们使用鼠标点击 inner 元素,以下代码会如果执行呢?

先不要看最终结果,大家在纸上画一画,看看能不能预测出上述代码的执行顺序。

// Let's get hold of those elements
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');

// Let's listen for attribute changes on the
// outer element
new MutationObserver(function () {
  console.log('mutate');
}).observe(outer, {
  attributes: true,
});

// Here's a click listener…
function onClick() {
  console.log('click');

  setTimeout(function () {
    console.log('timeout');
  }, 0);

  Promise.resolve().then(function () {
    console.log('promise');
  });

  outer.setAttribute('data-random', Math.random());
}

// …which we'll attach to both elements
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);

上述例子,可以访问 Tasks, microtasks, queues and schedules 查看动态效果。

当我们使用鼠标点击 inner 元素时,执行过程如下:

因此,上述代码的执行顺序:

  1. click
  2. promise
  3. mutate
  4. click
  5. promise
  6. mutate
  7. timeout
  8. timeout

此时,如果我们换一个事件的触发方式,直接通过执行如下代码来触发事件,上述代码会如何执行呢?

inner.click()

当我们直接执行代码 inner.click() 时,执行过程如下:

直接执行 inner.click() 代码的输出结果为:

  1. click
  2. click
  3. promise
  4. mutate
  5. promise
  6. timeout
  7. timeout

通过对比发现,通过执行 inner.click() 触发事件和通过鼠标点击触发事件的执行结果是非常不同的。通过上述可视化效果,我们可以逐步看出二者在执行顺序上的区别和原因。

值得注意的是:

  1. 整段代码脚本的执行也是一个宏任务;
  2. 执行栈如果不为空,则微任务队列不会被执行;
  3. 同一个 MutationObserver 在同一个微任务队列中不会被重复载入;
  4. 事件冒泡行为发生在下一个宏任务执行前。

事件循环可视化

发现了一个好用的事件循环可视化页面,可以运行各种异步代码示例或自定义代码,一步步查看事件循环的过程。

参考资料