事件循环:微任务和宏任务

223 阅读9分钟

前言

浏览器中 JavaScript 的执行流程和 Node.js 中的流程都是基于 事件循环 的。

理解事件循环的工作方式对于代码优化很重要,有时对于正确的架构也很重要。

并发模型与事件循环

JavaScript 有一个基于事件循环的并发模型,事件循环负责执行代码、收集和处理事件以及执行队列中的子任务。

  • 执行代码(<script></script>中的代码会立即执行)
  • 收集和处理事件(如:mousemove
  • 执行队列中的子任务(如:setTimeout

js引擎(如:V8,jscore)是单线程的,如何并发执行,并发模型又是什么呢? 解答这个问题,大家得先了解并发和并行的概念,以及浏览器的多进程架构。

并发指的是代码交替执行,js引擎同一时刻还是只能执行一个任务。而JS异步处理的能力,是由浏览器渲染进程(定时器线程和HTTP请求线程——执行异步事件,并将回调加入到事件队列中)提供的。

MDN提供的并发模型:

js执行.svg

如图,js在执行过程中会创建:

  • 调用栈(或执行栈):调用函数时,调用栈会创建帧(存储函数的执行上下文),并加入到栈中。
  • 消息队列:JavaScript 运行时包含了一个待处理消息的消息队列。每一个消息都关联着一个用以处理这个消息的回调函数。
  • 堆内存:对象被分配在堆中,堆是一个用来表示一大块(通常是非结构化的)内存区域的计算机术语。

对象被分配在堆中,怎么理解?

js在执行代码前,会有一个预编译的过程,即JIT(Just-in-time compilation)。预编译过程中,会对全局作用域中的所有变量和函数的声明进行初始化(这一过程也叫变量提升),包括函数参数。代码开始执行时,会依次进行赋值,函数赋值的是一个堆内存中地址(函数体存放在堆内存中),引用数据类型(数组,{})也一样,闭包中被内部函数使用的私有变量也是。

事件循环

之所以称之为 事件循环,是因为它经常按照类似如下的方式来被实现:

while (queue.waitForMessage()) {
  queue.processNextMessage();
}

queue.waitForMessage() 会同步地等待消息到达 (如果当前没有任何消息等待被处理)。

如果消息队列有消息,执行栈会等待消息到达并执行。而消息队列也叫任务队列,分为宏任务队列和微任务队列,它们加入执行栈的时机是怎样的呢?

在此之前,我们先了解下宏任务和微任务。

  • 宏任务
    • 事件触发的回调函数,例如DOM EventsI/OrequestAnimationFrame
    • setTimeoutsetInterval的回调函数
    • 一段新程序或子程序被直接执行时(比如从一个控制台,或在一个 <script> 元素中运行代码)。
  • 微任务
    • promisesPromise.thenPromise.catchPromise.finally
    • MutationObserver:提供了监视对 DOM 树所做更改的能力。
    • queueMicrotask:将回调加入微任务队列。
    • process.nextTick:Node独有

更详细的事件循环图示如下(顺序是从上到下,即:首先是脚本,然后是微任务,渲染等): eventLoop

官网解释:每当一个任务存在,事件循环都会检查该任务是否正把控制权交给其他 JavaScript 代码。如若不然,事件循环就会运行微任务队列中的所有微任务。接下来微任务循环会在事件循环的每次迭代中被处理多次,包括处理完事件和其他回调之后。

微任务会在 执行任何其他事件处理、或渲染、或执行任何其他宏任务 之前完成。

这很重要,因为它确保了微任务之间的应用程序环境基本相同(没有鼠标坐标更改,没有新的网络数据等)。

如果我们想要异步执行(在当前代码之后)一个函数,但是要在更改被渲染或新事件被处理之前执行,那么我们可以使用 queueMicrotask 来对其进行安排(schedule),避免使用promise。

通过引入 queueMicrotask(),由晦涩地使用 promise 去创建微任务而带来的风险就可以被避免了。举例来说,当使用 promise 创建微任务时,由回调抛出的异常被报告为 rejected promises 而不是标准异常。同时,创建和销毁 promise 带来了事件和内存方面的额外开销,这是正确入列微任务的函数应该避免的。

我们通过示例来解释宏任务和微任务执行的时机:

console.log('1');

setTimeout(function() {
    console.log('2');
    window.queueMicrotask(function() {
        console.log('3');
    })
    new Promise(function(resolve) {
        console.log('4');
        resolve();
    }).then(function() {
        console.log('5')
    })
})
window.queueMicrotask(function() {
    console.log('6');
})
new Promise(function(resolve) {
    console.log('7');
    resolve();
}).then(function() {
    console.log('8')
})

setTimeout(function() {
    console.log('9');
    window.queueMicrotask(function() {
        console.log('10');
    })
    new Promise(function(resolve) {
        console.log('11');
        resolve();
    }).then(function() {
        console.log('12')
    })
})

结果为:1 7 6 8 2 4 3 5 9 11 10 12

具体执行步骤如下:(假定从宏任务队列中取一次消息为一次事件循环的开始)

  • 加载完js之后,它也会被当做一个异步任务加入宏任务队列中,此时执行栈为空,第一次事件循环开始。取出其中的代码执行,遇到setTimeout, queueMicrotask, Promise.then时,统统加入浏览器的定时器线程或其他异步线程中,执行完毕后将回调加入到各自的消息队列。所以执行栈中第一次执行完毕打印:1 7

    此时微任务队列中有queueMiscrotask,Promise.then执行完成后的回调;宏任务队列中有二个setTimeout的回调。

  • 执行栈执行完毕首次为空后,会执行微任务中的所有任务,再开始下一次事件循环:6 8
  • 进行第二次事件循环,取setTimeout执行:2 4
  • 执行完成后,取所有微任务执行: 3 5
  • 进行第三次事件循环,取setTimeout执行:9 11
  • 执行完成后,取所有微任务执行:10 12

通过以上结果可以知道宏任务是一个接一个取出来执行的,微任务是一次全部执行完的。

了解了宏任务和微任务,如何通过它们来优化代码呢?

代码优化

由于js代码和浏览器的用户界面(即UI渲染)共享一个事件循环。假如你的代码阻塞了或者进入了无限循环,则浏览器将会卡死。无论是由于 bug 引起还是代码中进行复杂的运算导致的性能降低,都会降低用户的体验。

当来自多个程序的多个代码对象尝试同时运行的时候,一切都可能变得很慢甚至被阻塞,更不要说浏览器还需要时间来渲染和绘制网站和 UI、处理用户事件等。

如何解决阻塞问题呢?

上述代码使用setTimeout可以实现类似进度条的效果,而queueMicrotask却像同步代码一样会造成卡顿。

为何setTimeout可以避免卡顿?count执行过程中,浏览器还可以正常使用?

这是因为setTimeoutcount任务拆分之后,会将回调加入到宏任务队列中,只有在js引擎空闲时才会执行,所以不会引起阻塞。setTimeout是一个接一个被执行的,每个执行完之后页面都会进行渲染(或执行同步代码,微任务,用户事件回调等),进而你才可以看到类型进度条的效果,以及鼠标点击事件的正常执行。 而queueMicrotask虽然也对任务进行了拆分,但是它们需要在下一次事件循环之前全部完成,所以看起来和同步代码一样。

知道了setTimeout的用法,何时使用微任务呢?

何时使用微任务

通过上述示例,我们知道微任务是一次全部执行完的,如果大量使用微任务,将会带来性能方面的问题,应避免过多使用微任务。

我们来看看微任务特别有用的场景。通常,这些场景关乎捕捉或检查 结果、执行清理等;其时机晚于一段 JavaScript 执行上下文主体的退出,但早于任何事件处理函数、timeouts 或 intervals 及其他回调被执行。

何时是那种有用的时候?

使用微任务的最主要原因简单归纳为:确保任务顺序的一致性,即便当结果或数据是同步可用的,也要同时减少操作中用户可感知到的延迟而带来的风险。

保证条件性使用 promises 时的顺序

微任务可被用来确保执行顺序总是一致的一种情形,是当 promise 被用在一个 if...else 语句(或其他条件性语句)中、但并不在其他子句中的时候。考虑如下代码:

customElement.prototype.getData = url => {
  if (this.cache[url]) {
    this.data = this.cache[url];
    this.dispatchEvent(new Event("load"));
  } else {
    fetch(url).then(result => result.arrayBuffer()).then(data => {
      this.cache[url] = data;
      this.data = data;
      this.dispatchEvent(new Event("load"));
    )};
  }
};

这段代码带来的问题是,通过在 if...else 语句的其中一个分支(此例中为缓存中的图片地址可用时)中使用一个任务而 promise 包含在 else 子句中,我们面临了操作顺序可能不同的局势;比方说,像下面看起来的这样:

element.addEventListener("load", () => console.log("Loaded data"));
console.log("Fetching data...");
element.getData();
console.log("Data fetched");

连续执行两次这段代码会形成以下两种结果:

数据未缓存

Fetching data
Data fetched
Loaded data

数据已缓存

Fetching data
Loaded data
Data fetched

我们可以通过在 if 子句里使用一个微任务来确保操作顺序的一致性,以达到平衡两个子句的目的:

customElement.prototype.getData = url => {
  if (this.cache[url]) {
    queueMicrotask(() => {
      this.data = this.cache[url];
      this.dispatchEvent(new Event("load"));
    });
  } else {
    fetch(url).then(result => result.arrayBuffer()).then(data => {
      this.cache[url] = data;
      this.data = data;
      this.dispatchEvent(new Event("load"));
    )};
  }
};

批处理操作

你还可以使用微任务将来自不同来源的多个请求收集到一个批次中,避免了多次调用处理同类工作可能涉及的开销。

下面的片段创建了一个函数,将多条消息分批放入一个数组中,当上下文退出时,使用一个微任务将其作为一个单一对象发送。

const messageQueue = [];

let sendMessage = (message) => {
  messageQueue.push(message);

  if (messageQueue.length === 1) {
    queueMicrotask(() => {
      const json = JSON.stringify(messageQueue);
      messageQueue.length = 0;
      fetch("url-of-receiver", json);
    });
  }
};

上面这个函数很有意思,花了好久才看懂。

当第一次调用 sendMessage() 时,会将消息放入 messageQueue 中,并执行微任务。后续调用只会将消息继续插入 messageQueue 中,而微任务并不会执行,因为 messageQueue.length > 1 。当执行栈为空,开始执行微任务时,注意微任务回调函数,它会将 messageQueue 转为JSON字符串,而此时 messageQueue 中存放了多条消息,然后通过fetch发送出去。

上述示例多次调用 sendMessage ,却只执行了一次微任务(queueMicrotask),但消息一直在收集中。等到微任务开始执行时,才发送出去,达到了批处理的要求。

参考