Gatsby Starter Blog

工程化: macrotask和microtask

January 22, 2020

本文翻译自 secrets of javascript ninja ,重点讲解 macrotask 和 microtask。

event loop

如下图所示,浏览器的 event loop 至少包含两个队列,macrotask 队列和 microtask 队列。

Macrotasks 包含生成 dom 对象、解析 HTML、执行主线程 js 代码、更改当前 URL 还有其他的一些事件如页面加载、输入、网络事件和定时器事件。从浏览器的角度来看,macrotask 代表一些离散的独立的工作。当执行完一个 task 后,浏览器可以继续其他的工作如页面重渲染和垃圾回收。

Microtasks 则是完成一些更新应用程序状态的较小任务,如处理 promise 的回调和 DOM 的修改,这些任务在浏览器重渲染前执行。Microtask 应该以异步的方式尽快执行,其开销比执行一个新的 macrotask 要小。Microtasks 使得我们可以在 UI 重渲染之前执行某些任务,从而避免了不必要的 UI 渲染,这些渲染可能导致显示的应用程序状态不一致。

ECMAScript 规范并没有提及到 event loop。实际上 event loop 是定义在 [ HTML 规范

](https://link.zhihu.com/?target=https%3A//html.spec.whatwg.org/%23event- loops) 里的,这里也讨论了 microtask 和 macrotask。ECMAScript 规范在谈及处理 promise 回调时提到了 jobs (其和 microtask 类似)。即使 event loop 是定义在 HTML 规范里的,其他的宿主环境如 Node.js 也使用了相同的概念。

Event loop 的实现应该至少使用一个队列用于处理 macrotasks,至少一个队列处理 microtasks。Event loop 的实际实现通常分配几个队列用于处理不同类型的 macrotasks 和 microtasks。这使得可以对不同的任务类型进行优先级排序。例如优先考虑一些性能敏感的任务如用户输入。另一方面,因为实际上存在很多 JavaScript 宿主环境,所以有的 event loop 使用一个队列处理这两种任务也不应该感到奇怪。

Event loop 基于两个基本原则:

  • 同一时间只能执行一个任务。
  • 任务一直执行到完成,不能被其他任务抢断。

v2 ad1a251cb91d37625185a4fb874494fc b 如上图所示,在单次的迭代中,event loop 首先检查 macrotask 队列,如果有一个 macrotask 等待执行,那么执行该任务。当该任务执行完毕后(或者 macrotask 队列为空),event loop 继续执行 microtask 队列。如果 microtask 队列有等待执行的任务,那么 event loop 就一直取出任务执行知道 microtask 为空。这里我们注意到处理 microtask 和 macrotask 的不同之处:在单次循环中,一次最多处理一个 macrotask(其他的仍然驻留在队列中),然而却可以处理完所有的 microtask。

当 microtask 队列为空时,event loop 检查是否需要执行 UI 重渲染,如果需要则重渲染 UI。这样就结束了当次循环,继续从头开始检查 macrotask 队列。

上图还包含了一些细节:

  • 两个任务队列都放置在 event loop 外,这表明将任务添加和任务处理行为分离。在 event loop 内负责执行任务(并从队列里删除),而在 event loop 外添加任务。如果不是这样,那么在 event loop 里执行代码时,发生的任何事件都被忽略,这显然不合需求,因此我们将添加任务的行为和 event loop 分开进行。
  • 两种类型的任务同时只能执行一个,因为 JavaScript 基于单线程执行模型。任务一直执行到完成而不能被其他任务中断。只有浏览器可以停止任务的执行;例如如果某个任务消耗了太多的内存和时间的话,浏览器可以中断其执行。
  • 所有的 microtasks 都应该在下次渲染前执行完,因为其目的就是在渲染前更新应用状态。
  • 浏览器通常每秒尝试渲染页面 60 次,以达到每秒 60 帧(60 fps),这个帧速率通常被认为是平滑运动的理想选择。这意味着浏览器尝试每 16ms 渲染一帧。上图中“update rendering”操作在事件循环中进行,这是因为在呈现页面时,页面内容不应该被另一个任务修改。这意味着如果我们应用实现平滑的效果,单个事件循环中不能占据太多时间。单个任务和由该任务生成的所有 microtasks 应该在 16 毫秒内完成。

当浏览器完成页面渲染后,下次 event loop 循环迭代中可能发生三种情况:

  • event loop 在另一个 16 ms 之前执行的“is rendering needed”的判断处。因为更新 UI 是一个复杂的操作,如果没有明确要求渲染页面,浏览器可能在本次迭代中不执行 UI 渲染。
  • event loop 在上次渲染后约 16 ms 处达到“Is rendering needed”判断处。在这种情况下,浏览器更新 UI,用户会认为应用比较流畅。
  • 执行下次任务(及其所有相关的 microtask)花费时间大大超过 16ms。这样浏览器将无法按照目标的帧速率重新渲染页面,UI 也将不会更新。如果运行任务代码不占用太多时间(超过几百毫秒),这种延迟甚至可能感知不到,尤其是对于没有太多动画的页面。另一方面,如果我们花费太多时间,或者页面中含有动画,用户可能认为网页缓慢和没有响应。在最坏的情况下,如果任务执行超过几秒钟,用户的浏览器会显示“无响应脚本”消息。

处理事件时应注意其发生的频率和处理所需时间。如在处理鼠标移动事件时应该格外小心。移动鼠标会导致大量的事件排队,因此在该鼠标移动处理程序中执行任何复杂的操作都可能导致应用变得很不流畅。