Fwio

JavaScript 的事件循环(Event Loop)

参考资料

JavaScript 是一门单线程同步编程语言,任务只能在主线程上一个一个串行地执行。

为了让多类型的任务在主线程上有条不紊地执行,JavaScript 引入了任务队列(Task Queue)和事件循环(Event Loop)。

虽然 JS 为单线程,但一个页面(tab)是多线程的,其中最主要的是 JS 引擎线程(V8)、GUI 渲染引擎线程。

每一个线程有自己的事件循环,且同源的多个窗口之间共享一个事件循环,因此它们可以同步地通信。

一个事件循环有多个任务源(source),这些源维护它们内部任务的执行次序,但浏览器每次循环(每一次循环被称为一Tick)会挑选一个源,从中取出一个任务。这使得浏览器能优先考虑性能敏感的任务,比如用户输入。

任务是设置(scheduled)好的,因此浏览器能从其内部进入 JavaScript/DOM 域,以确保这些行为是顺序发生的。在任务之间,浏览器可能会进行渲染更新。

从一次鼠标点击中获取事件回调就需要设置一个任务,同样,解析 HTML、setTimeout也是。

微任务(Microtasks)一般是应该在当前执行的脚本完成后,立即发生的行为,它们可能有点异步,但不创建新的任务。

当执行栈中的任务执行完毕,没有其他的 JS 正在主线程运行时,微任务将被处理。在微任务中创建的微任务也会被添加到当前任务的微任务队列中。

注意:当引擎遇到一个微任务后,会在将其加入微任务队列后,跳过其整体表达式的执行。

Promise.resolve()
  // microtask1
  .then(() => {
    console.log('promise1')
  })
  // microtask2
  .then(() => {
    console.log('promise2')
  })

console.log('end')

上述代码中,当引擎遇到microtask1时,将其加入微任务队列,但此时不会扫描microtask2注册到微任务队列中(因为前一个 promise 还未落定);在执行完脚本后,取出microtask1执行,此时才扫描到microtask2,将其加入微任务队列。

由于执行微任务可以引入新的微任务,且这些微任务都会在当前宏任务下执行完毕,所以微任务可能一直占用当前的引擎,无限阻塞接下来的宏任务。

微任务可以产生新的微任务,阻塞后续宏任务

什么会创建宏任务(macrotask)?

常见的:

  • 直接执行一个新的 JavaScript 程序或子程序时(比方说从控制台、一个<script>元素 / JS 文件中)。
  • 触发一个 UI 交互事件时,事件的处理回调(event handler callback)将加入宏任务队列。

注意:

  • 由事件冒泡或捕获导致的多个事件处理回调属于同一个宏任务。
  • JS 命令式触发的事件也会传播
  • 在宏任务中,只要执行栈(JS Stack)不为空,就无法开始执行微任务,要注意用顶级函数调用触发微任务的情况。
  • 当一个定时器(setTimeout()setInterval())到达时,其回调被加入到宏任务队列中。

不常见的:

  • Node.js API setImmediate的回调会加入到宏任务队列。

  • 负责 Worker 或 Window 之间通信的portMessage回调。

  • MessageChannel、I/O …

什么会创建微任务(microtask)?

常见的:

  • Promise 对象用then()注册的回调。
  • async/await中的await语句(statement)。
  • MutationObserver的回调。

注意:当前宏任务下有pending状态的MutationObserver回调时,不会为其他MutationObserver回调再创建新的微任务

MutationObserver 的特殊异步处理

不常见的:

  • Node.js 中的process.nextTick()的回调。

什么是的执行栈?

上文中提到,微任务只在当前宏任务完成,且执行栈为空时,开始执行。

Microtasks execute in order, and are executed:

  • after every callback, as long as no other JavaScript is mid-execution
  • at the end of each task.

对执行栈的认识离不开作用域(Scoping)这个概念,一般来说,我们认为执行栈以函数调用为单位,将其以上下文帧的形式压入栈中, 每一帧包含函数的作用域

在我的理解里,如果出现闭包(或其他对上层作用域的引用)时,那个包含被引用值的上下文就无法出栈,即使它的函数体已经被完全执行(evaluated), 其上下文依然存在执行栈中,这种情况下的执行栈是否该被认为是空?

下面是一个该情境的例子:

// script.js
let ref = 1

new Promise((resolve) => {
  console.log('executor fire')

  setTimeout(() => resolve(ref))

  console.log('executor done')
}).then((val) => {
  console.log(`promise 1: ${val}`)
})

Promise.resolve(2).then((val) => console.log(`promise 2: ${val}`))

console.log('script ends')

// output:
// 1. executor fire
// 2. executor done
// 3. script ends
// 4. promise 2: 2
// 5. promise 1: 1

其中,在 script 中产生的 timeout 宏任务引用了 script 作用域中的ref变量,那么在 evaluate 完 script 后, script 作用域还保留在执行栈中,但第二个 promise 产生的微任务却被执行了。并且由于第二个 promise 是一个初始化时就已经 fulfilled 的Promise.resolve(),所以其then()回调在自上而下执行 script 时就已经被推入微任务队列。

上面的结果可以说明,一个执行上下文帧在执行栈中保留并不代表 JS 代码正在执行(mid-execution),关于作用域与上下文的知识,自己还需要更深入地了解。