理解 JavaScript:事件循环详解

2024-02-11 • ☕️☕️ 9 min read

前言

《理解 JavaScript:调用栈、任务队列》这篇文章从同步和异步的角度来大致说明了 JavaScript 主线程的工作方式,但是限于篇幅和主题,还有个重要的概念--事件循环(Event Loop)没有谈到,所以在这另起一篇来单独介绍下事件循环。

事件循环其实就是 JavaScript 管理事件执行的一个流程,具体的管理办法由 JavaScript 具体的运行环境确定。目前 JavaScript 的主要运行环境有浏览器和 Node.js。不过这两个运行环境的事件循环还是有点区别的,下文我主要讲的是在浏览器环境下的事件循环。

在网上查找了很多资料,但是感觉不同资料之间关于事件循环的描述有些不一致,把我看懵了。根据第一手资料原则,只能硬着头皮尝试去看规范来学习。在浏览器环境中,关于事件循环的相关定义是在 HTML 标准中,这里顺便分享个有关 HTML 标准的瓜🍉。

之前 HTML 标准由万维网联盟 W3C 制定,但是由于 W3C 的标准制定速度慢,并且与浏览器厂商意见相左,浏览器厂商(苹果、谷歌、微软、Mozilla)们成立了名为 WHATWG 的组织,自行制定了 HTML5 标准。经过多年的合作和分歧,W3C 和 WHATWG 最终在 2019 年达成协议,将未来 HTML 和 DOM 标准的制定权完全交给 WHATWG。

本文的讲解主要也是以 WHATWG 标准为主,在 WHATWG event-loops 中定义了浏览器内核该如何的去实现它。

下文是我尝试对照规范中一些关键描述并结合自己的理解而输出,如果有错误,欢迎大家在评论区发邮件给我指出~

定义

规范中关于事件循环(Event loops)的定义中有几段话描述事件循环的作用。

  1. 为了协调事件、用户交互、脚本、渲染、网络等,用户代理必须使用本节中描述的事件循环。
  2. 每个代理都有一个关联的事件循环,该事件循环对于该代理来说是唯一的。

从上面这段描述可以看出,事件循环的角色是一个协调者,用于协调用户代理中各种事件的执行。这种事件循环机制是由 JavaScript 的宿主环境来实现,在浏览器运行环境中由浏览器内核引擎实现,而在 NodeJS 中则由 libuv 实现。

我们接着往下看和事件循环相关的几个重要概念。

Task

根据任务源(task source)字段,每个任务都被定义为来自特定的任务源。

很多文章会把规范中的 "Task" 称为 "宏任务/Macrotask",但是规范中并没有 "Macrotask" 相关的描述,本文还是会按照规范来称为 Task,大伙把他们视为一样就可以。

Task 是事件循环中的基本工作单元,它有一个任务源字段用于分类任务的来源等字段,Task 的任务源 字段主要包括以下这些(仔细回想下事件循环的作用):

  • DOM 操作:对 DOM 操作产生的任务,例如,document.body.style = 'background:yellow';
  • 用户交互:用户交互产生的任务,例如鼠标点击、移动产生的回调任务;
  • 网络:网络请求产生的任务,例如 fetch()
  • 渲染:用于更新渲染。
  • 定时器: setTimeout、setInterval。

总的来说,Task 代表了事件循环中需要异步执行的工作,不同的任务源会产生不同类型的 Task,事件循环会按照一定的顺序去执行这些 Task,具体执行顺序会在下面的运行流程小节中说明。

任务队列

  1. 事件循环有一个或多个任务队列(task queues)。
  2. 任务队列是一组任务(tasks)集合。
  3. 对于每个事件循环,每个任务源必须与特定的任务队列关联。

任务队列(task queues)是事件循环中重要的概念。每个事件循环包含一个或多个任务队列。每个队列是一组任务(Task)的集合,其中每个任务根据其任务源被分配到特定的队列中。

传统的队列(Queue)是一个先进先出(FIFO)的数据结构,总是排在第一个的先执行,而这里的任务队列类似一个集合,所以不是出列第一个任务,而是获取。

在事件循环中使用多个任务队列可以更有效地管理任务的执行顺序和优先级,用户代理可以在循环的每一轮中选择从哪个源获取任务。例如,为了保证界面的响应性,浏览器会在保持任务顺序的前提下,可能分配四分之三的优先权给用户交互事件(如鼠标和键盘事件),剩下的优先级分配给其他可能在后台执行不会影响用户体验的任务。

microtask

  1. 每个事件循环都有一个微任务队列,最初是空的。
  2. 微任务是指通过微任务算法队列创建的任务的通俗说法。
  3. 它不是一个任务队列 task queues,两者是独立的队列。

除了任务队列,每个事件循环还有一个微任务队列。微任务中的任务包括诸如 Promise 回调、MutationObserver 等,微任务仅来自于我们的代码。还有一个特殊的函数 queueMicrotask(func),它对 func 回调函数进行排队,以在微任务队列中执行。

运行流程

上面这些定义中的概念介绍完估计大伙还是云里雾里,我们接着规范往下看,结合事件循环的运行流程把这些概念串起来。在 8.1.7.3 Processing model 说明了事件循环的运行流程。直接看规范里的概念有点晦涩难懂,下面简单总结下重点步骤:

  1. 从任务队列中取出一个Task,进入调用栈,开始执行。
  2. 一旦调用栈中的所有任务执行完毕,检查微任务队列,执行并清空微任务队列,如果在微任务的执行中又加入了新的微任务,也会在此次循环执行完之前执行。
  3. 更新渲染。
  4. 回到第 1 步。

让我们使用伪代码对事件循环的运行流程进行建模:

while (EventLoop.waitForTask()) {
  const taskQueue = EventLoop.selectTaskQueue();
  if (taskQueue.hasNextTask()) {
    taskQueue.processNextTask();
  }
 
  const microtaskQueue = EventLoop.microTaskQueue;
  while (microtaskQueue.hasNextMicrotask()) {
    microtaskQueue.processNextMicrotask();
  }
 
  rerender();
}

根据以上流程可以清楚的知道,微任务队列会在每个 Task 结束时立即进行处理,一次循环意味着一个 Task 执行完成。

让我们以一个简单的例子来一步一步了解事件循环的整个流程。

Code
CallStack
WebAPI
Tasks
Microtasks
Log

参考资料