浏览器中的 Event Loop

# event loop

通过HTML5规范 (opens new window)的定义来看event loop的定义

为了协调时间,用户交互,脚本,界面渲染,网络等等,用户代理必须使用下一节描述的 event loops。event loops 分为两种:浏览器环境及为 Web Worker 服务的。

在这里只关注浏览器部分,JavaScript 引擎并不是独立运行的,它需要运行在宿主环境中, 所以其实用户代理(user agent)或者称为运行环境或者宿主环境,也就是浏览器。

每个用户代理必须至少有一个browsing context event loop (opens new window),但每个unit of related similar-origin browsing contexts (opens new window) 最多只能有一个。

关于unit of related similar-origin browsing contexts (opens new window),节选一部分规范的介绍:

Each unit of related browsing contexts (opens new window) is then further divided into the smallest number of groups such that every member of each group has an active document (opens new window) with an origin (opens new window) that, through appropriate manipulation of the document.domain attribute, could be made to be same origin-domain (opens new window) with other members of the group, but could not be made the same as members of any other group. Each such group is a unit of related similar-origin browsing contexts.

简而言之就是一个浏览器环境(unit of related similar-origin browsing contexts.),只能有一个事件循环(event loop)。

# event loop做了什么?

每个event loop都有一个或多个 task queues. 一个 task queue (opens new window)tasks的有序的列表, 是用来响应如下工作的算法:

  • 事件

    EventTarget触发的时候发布一个事件Event 对象,这通常由一个专属的 task 完成。

    注意:并不是所有的事件都从是task queue (opens new window)中发布,也有很多是来自其他的 tasks。

  • 解析

    HTML解析器 (opens new window)令牌化然后产生token的过程,是一个典型的task

  • 回调函数

一般使用一个特定的task来调用一个回调函数。

  • 使用资源(其实就是网络)

    当算法 获取 (opens new window) 到了资源,如果获取资源的过程是非阻塞的,那么一旦获取了部分或者全部的内容将由task来执行这个过程。

  • 响应DOM的操作

    有一些元素会对DOM的操作产生task,比如当元素被 插入到 document (opens new window)

可以看到,一个页面只有一个event loop,但是一个 event loop可以有多个task queues

每个来自相同task source并由相同event loop(比如,Document的计时器产生的回调函数,Document的鼠标移动产生的事件,Document的解析器产生的 tasks) 管理的task都必须加入到同一个task queue中,可是来自不同 task sources (opens new window)tasks可能会被排入到不同的task queues中。

规范对task source进行了分类:

如下task sources 被大量应用于本规范或其他规范无关的特性中:

  • DOM操作的task source

    这种task source用来对DOM 的操作进行反应,比如像inserted into the document的非阻塞的行为。

  • 用户操作的task source

    这种task source 用来响应用户的反应,比如鼠标和键盘的事件。这些用来反应用户输入的事件必须由user interaction task source (opens new window)来触发并排入tasks queued

  • 网络task source

    这种task source用来反应网络活动的响应。

  • 时间旅行task source

    这种task source用来将history.back()API排入task queue

规范中明确表示了是有多个task queues,并举例说明了这样设计的意义:

举例来说,一个用户代理可以有一个处理键盘鼠标事件的 task queue(来自user interaction task source),还有一个task queue 来处理所有其他的。用户代理可以以75% 的几率先处理鼠标和键盘的事件,这样既不会彻底不执行其他 task queues的前提下保证用户界面的响应, 而且不会让来自同一个task source的事件顺序错乱。

接着:

当用户代理将要排入任务时,必须将任务排入相关的event looptask queues

这句话很关键,是用户代理(宿主环境/运行环境/浏览器)来控制任务的调度,这里就引出了下面的Web APIs

接下来我么来看看event loop是如何执行task的。

# 处理模型

event loop会在整个页面存在时不停的将task queues 中的函数拿出来执行,具体的规则如下:

一个event loop在它存在的必须不断的重复一下的步骤:

  1. task queues中取出event loop的最先添加的 task,如果没有可以选择的task,那么跳到第 Microtasks步。
  2. 设定event loop当前执行的task为上一步中选择的 task。 执行:执行选中的task
  3. 执行:执行选中的task
  4. event loop的当前执行task设为null
  5. task queue中将刚刚执行的task移除。
  6. Microtasks执行microtask 检查点的任务 (opens new window)
  7. 更新渲染,如果是浏览器环境中的event loop(相对来说就是Worker中的event loop)那么执行以下步骤:
  8. 如果是Worker环境中的event loop(例如,在WorkerGlobalScope (opens new window)中运行),可是在event looptask queues中没有 tasks并且 WorkerGlobalScope (opens new window)对象为关闭的标志,那么销毁event loop,终止这些步骤的执行,恢复到run a worker (opens new window)的步骤。
  9. 回到第1步。

# microtask

规范引出了microtask

每个event loop都有一个microtask queuemicrotask是一种要排入microtask queue的而不是task queue的任务。有两种microtasks (opens new window)solitary callback microtaskscompound microtasks

规范只介绍了solitary callback microtasks (opens new window)compound microtasks 可以先忽略掉。

当一个microtask要被排入的时候,它必须被排入相关 event loopmicrotask queue,microtasktask sourcemicrotask task source.

# microtasks检查点

当用户代理执行到了microtasks检查点的时候,如果performing a microtask checkpoint flag (opens new window)false,则用户代理必须运行下面的步骤:

  1. performing a microtask checkpoint flag置为true
  2. 处理microtask queue:如果event loopmicrotask queue是空的,直接跳到Done步。
  3. 选择event loopmicrotask queue中最老的 microtask
  4. 设定event loop当前执行的task为上一步中选择的 task
  5. 执行:执行选中的task

注意:这有可能涉及相关的脚本回调函数,这些回调函数最后都会执行clean up after running script (opens new window),这回导致再次执行microtask 检查点的任务 (opens new window),这就是我们要使用 performing a microtask checkpoint flag的原因。

  1. event loop的当前执行task设为null
  2. 将上一步中执行的microtaskmicrotask queue 中移除,然后返回 处理microtask queue步骤。
  3. 完成: 对每一个responsible event loop (opens new window)就是当前的event loopenvironment settings object (opens new window),给environment settings object (opens new window)发一个rejected promises (opens new window)的通知。
  4. 清理IndexedDB 的事务 (opens new window)
  5. performing a microtask checkpoint flag设为 false

为啥要用microtask?根据HTML Standard,在每个 task运行完以后,UI都会重渲染,那么在microtask 中就完成数据更新,当前task结束就可以得到最新的UI了。反之如果新建一个task 来做数据更新,那么渲染就会进行两次。

整个流程如下图:

image

# task & microTask

task主要包含:

  • script(整体代码)
  • setTimeout
  • setInterval
  • setImmediate
  • I/O
  • UI rendering

microtask主要包含:

  • process.nextTickNode.js环境)
  • Promises(这里指浏览器实现的原生Promise
  • Object.observe(已被MutationObserver替代)
  • MutationObserver
  • postMessage

# Web APIs

在上面讲到了用户代理(宿主环境/运行环境/浏览器)来控制任务的调度,task queues 只是一个队列,它并不知道什么时候有新的任务推入,也不知道什么时候任务出队。event loop 会根据规则不断将任务出队,那谁来将任务入队呢?答案是 Web APIs。 我们都知道JavaScript 的执行是单线程的,但是浏览器并不是单线程的,Web APIs就是一些额外的线程,它们通常由C++ 来实现,用来处理非同步事件比如DOM事件,http 请求,setTimeout等。他们是浏览器实现并发的入口,对于Node.JavaScript来说,就是一些C++APIs

WebAPIs本身并不能直接将回调函数放在函数调用栈中来执行,否则它会随机在整个程序的运行过程中出现。每个 WebAPIs会在其执行完毕的时候将回调函数推入到对应的任务队列中,然后由event loop 按照规则在函数调用栈为空的时候将回调函数推入执行栈中执行。event loop 的基本作用就是检查函数调用栈和任务队列,并在函数调用栈为空时将任务队列中的的第一个任务推入执行栈中,每一个任务都在下一个任务执行前执行完毕。

WebAPIs提供了多线程来执行异步函数,在回调发生的时候,它们会将回调函数和推入任务队列中并传递返回值。

# 参考文献

HTML5规范 (opens new window)

Tasks, microtasks, queues and schedules (opens new window)

跟着Event loop 规范理解浏览器中的异步机制 (opens new window)

Vue中如何使用MutationObserver 做批量处理? (opens new window)