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

随着浏览器应用领域的广泛,消息队列这种粗时间颗粒度的任务已经不能适应部分领域的需求。所以出现了一种新的技术——微任务。微任务可以在实时性和效率之间做一个权衡

目前来看,基于微任务的技术有MutationObserver、Promise 以及以 Promise 为基础开发出来的很多其他的技术

1. 宏任务

页面中大部分任务都是在主线程上进行的:

  • 渲染事件(如解析 DOM、计算布局、绘制);
  • 用户交互事件(如鼠标点击、滚动页面、放大缩小等);
  • JavaScript 脚本执行事件;
  • 网络请求完成、文件读写完成事件。
    为了协调这些任务有条不紊地在主线程上执行,页面进程引入了消息队列和事件循环机制,渲染进程内部会维护多个消息队列,比如延迟执行队列和普通的消息队列。然后主线程采用一个 for 循环,不断地从这些任务队列中取出任务并执行任务。我们把这些消息队列中的任务称为宏任务

WHATWG是这样定义消息循环机制的:

  1. 先从多个消息队列中选出一个最老的任务,这个任务称为 oldestTask;
  2. 然后循环系统记录任务开始执行的时间,并把这个 oldestTask 设置为当前正在执行的任务;
  3. 当任务执行完成之后,删除当前正在执行的任务,并从对应的消息队列中删除掉这个oldestTask;
  4. 最后统计执行完成的时长等信息。
    下面我们来分析下为什么宏任务难以满足对时间精度要求较高的任务。
1
2
3
4
5
6
7
8
9
function foo() {
console.log('foo')
}
setTimeout(function() {
console.log('foo1')
setTimeout(function() {
foo()
},1000)
},1000)

我的目的是想通过 setTimeout 来设置两个回调任务,并让它们按照前后顺序来执行,中间也不要再插入其他的任务,如果插入,就可能会影响到第二个定时器的执行时间了。

前面我们说过,页面的渲染事件、各种 IO 的完成事件、执行 JavaScript 脚本的事件、用户交互的事件等都随时有可能被添加到消息队列中,而且添加事件是由系统操作的,JavaScript 代码不能准确掌控任务要添加到队列中的位置,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间

但实际情况是我们不能控制的,比如在你调用 setTimeout 来设置回调任务的间隙,消息队列中就有可能被插入很多系统级的任务。你可以打开 Performance 工具,来记录下这段任务的执行过程,也可参考文中我记录的图片:

2. 微任务

微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前

下面站在 V8 引擎的层面来分析下微任务是怎么运转起来的。

V8引擎为JS创建全局执行上下文的时候会创建一个微任务队列,这个微任务队列是给V8引擎内部使用的,无法通过JavaScript引擎访问,用于存放当前宏任务执行过程中产生的微任务。

2.1 微任务产生的时机

  1. 使用MutationObserver监控某个DOM节点,然后通过JavaScript来修改这个节点或者子节点,当DOM节点发生变化的时候,就会产生DOM变化记录的微任务
  2. 使用Promise,当调用Promise.resolve()和Promise.reject()的时候也会产生微任务。
  3. async/await异步
  4. node.js调用API把任务加入微任务队列,process.nextTick(task)

2.2 微任务执行的时机

在当前宏任务中的JavaScript引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行微任务。这个时间点叫做检查点,当然除了在退出全局执行上下文式这个检查点之外,还有其他的检查点,不过不是太重要,这里就不做介绍了。

如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束,不会推迟到下个宏任务中。

该示意图是在执行一个 ParseHTML 的宏任务,在执行过程中,遇到了 JavaScript 脚本,那么就暂停解析流程,进入到 JavaScript 的执行环境。

2.3 结合事件循环

整个事件循环的流程大致如下:

  1. 同步代码先执行,把函数执行上下文压入调用栈。
  2. 遇到异步代码,先用消息队列保存下来。
  3. 等同步代码执行完毕,也就是call stack空了之后,event loop开始工作。
  4. event loop机制会循环取出消息队列中的任务执行(宏任务),执行过程中遇到新的宏任务就加入消息队列,遇到微任务就加入当前宏任务的微任务队列,当前宏任务执行完毕之前,存在一个检查点,会去检查执行微任务队列中的任务。
  5. 微任务一般在DOM渲染前触发,而宏任务一般在DOM渲染后触发。也就是说,两个宏任务间往往间隔了一次DOM渲染,如果DOM结构改变,则会重新渲染,然后再去触发下次的事件循环事件。

画第五条重点:每次Call Stack清空(即每次轮询结束),即同步任务执行完,都是Dom重写渲染的机会,Dom结构如有改变则重新渲染,然后再去触发下一次Event Loop。

从函数调用上来理解,微任务是可以通过代码控制的,是ES6语法层面,是主动触发的。而宏任务通常是由浏览器或者系统内部任务被动触发的。

明白了上述这些内容,一些奇怪的现象就好解释了。比如人工合成事件dispatchEvent手动调用click事件为什么是同步调用?

  • 手动调用click会直接进入函数调用栈,此时调用栈不为空,事件循环压根还没开始,就是普通的函数调用。
  • 不同于浏览器因为DOM变化触发的原生事件通过event loop异步派发,人工合成事件是在当前同步代码执行过程中就派发了事件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>

<body>
<button id="demo">点我</button>
<button id="demo1">点我</button>

<script>
const btn = document.getElementById('demo')
btn.addEventListener('click', function() {
console.log('浏览器派发的click事件');
})

const event = new Event('dispatchEvent')
btn.addEventListener('dispatchEvent', function() {
console.log('手动派发dispatchEvent事件');
})
btn.dispatchEvent(event)

const btn1 = document.getElementById('demo1')
btn1.addEventListener('click', function() {
console.log('手动调用click事件');
})
btn1.click()
</script>
</body>

</html>

明白了浏览器的DOM事件是通过Event loop异步派发之后,那么接下来关于DOM监听方法的演变想必你就更好理解了。

2.4 监听DOM变化方法演变

许多 Web 应用都利用 HTML 与 JavaScript 构建其自定义控件,与一些内置控件不同,这些控件不是固有的。为了与内置控件一起良好地工作,这些控件必须能够适应内容更改、响应事件和用户交互。因此,Web 应用需要监视 DOM 变化并及时地做出响应。

2.4.1 轮询检测

早期页面并没有提供对监听的支持,那时要观察 DOM 是否变化,唯一能做的就是轮询检测,比如使用 setTimeout 或者 setInterval来定时检测 DOM 是否有改变。这种方式简单粗暴,但是会遇到两个问题:

  • 如果时间间隔设置过长,DOM 变化响应不够及时
  • 如果时间间隔设置过短,又会浪费很多无用的工作量去检查 DOM,会让页面变得低效

2.4.2 Mutation Event

2000 年的时候引入了Mutation Event,Mutation Event 采用了观察者的设计模式,当 DOM 有变动时就会立刻触发相应的事件,这种方式属于同步回调

解决了实时性的问题,但是会导致页面性能问题,事件执行事件过长会导致页面卡顿。

2.4.3. MutationObserver

从 DOM4 开始,推荐使用 MutationObserver 来代替 Mutation Event。MutationObserver API 可以用来监视 DOM 的变化,包括属性的变化、节点的增减、内容的变化等。那么MutationObserver 相较于Mutation Event做了哪些改进呢?

MutationObserver 将响应函数改为异步调用的方式,等多次DOM变化完成之后,一次触发调用。还会使用一个数据结构来记录这期间所有的DOM变化。即使频繁地操作DOM,也不会对性能造成太大的影响。

MutationObserver 在DOM 节点发生变化的时候,渲染引擎将变化记录封装成微任务,并将微任务添加进当前的微任务队列中。保证了实时性

值得一提的是MutationObserver 触发的是微任务,而DOM变化由浏览器触发的事件是宏任务。