浏览器中的宏任务和微任务

2,895 阅读10分钟

上一篇文章中我们讲了浏览器是如何实现 setTimeout 的,通过 setTimeout 的实现和前面浏览器消息队列和事件循环的文章,我们大体上知道了浏览器中消息循环系统是如何工作的。不过随着新技术的涌现,消息队列中这种粗时间颗粒度的任务已经不能满足一些新技术的需求。所以为了满足实时性和效率之间的平衡,浏览器中又出现了一种新的技术——微任务。对应的,你们一定听说”宏任务“,那么微任务和宏任务的具体区别是什么,它们是如何相互取长补短的呢?

宏任务

前面我们已经介绍过了,页面中的大部分任务都是在主线程上执行的,这些任务包括了:

  • 渲染事件(如解析 DOM、计算布局、绘制)
  • 用户交互事件(如鼠标点击、滚动页面、放大缩小等)
  • JavaScript 脚本执行事件
  • 网络请求完成、文件读写完成事件

为了协调这些任务有条不紊地在主线程上执行,页面进程引入了消息队列和事件循环机制,渲染进程内部会维护多个消息队列,比如延迟执行队列和普通的消息队列。然后主线程采用一个 for 循环,不断地从这些任务队列中取出任务并执行任务。我们把这些消息队列中的任务称为宏任务

宏任务可以满足我们大部分的日常需求,不过如果有对时间精度要求较高的需求,宏任务就难以胜任了,下面我们就来分析下为什么宏任务难以满足对时间精度要求较高的任务。

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

<!DOCTYPE html>
<html>
    <body>
        <div id='demo'>
            <ol>
                <li>test</li>
            </ol>
        </div>
    </body>
    <script type="text/javascript">
        function timerCallback2(){
          console.log(2)
        }
        function timerCallback(){
            console.log(1)
            setTimeout(timerCallback2,0)
        }
        setTimeout(timerCallback,0)
    </script>
</html>

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

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

3c2b9b474c4df544df61ebd62a7b3715.png (1142×351)

setTimeout 函数触发的回调函数都是宏任务,如图中,左右两个黄色块就是 setTimeout 触发的两个定时器任务。

现在你可以重点观察上图中间浅红色区域,这里有很多一段一段的任务,这些是被渲染引擎插在两个定时器任务中间的任务。试想一下,如果中间被插入的任务执行时间过久的话,那么就会影响到后面任务的执行了。

所以说宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的,对一些高实时性的需求就不太符合了,比如后面要介绍的监听 DOM 变化的需求。

微任务

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

现代浏览器产生微任务只有这两种方式:

  1. 第一种是 Promise ,当调用 Promise.resolve() 或者 Promise.reject() 的时候,会产生微任务,但 Promise 构造函数中的代码会立即执行。
  2. 第二种是使用 MutationObserver 接口,这个接口是 DOM3 Events 规范里的一部分。它的作用就是提供了监视对DOM树所做更改的能力。也就是说当监听的这个DOM节点发生改变的时候,比如增加子节点或者删除子节点等。就会产生 DOM 变化记录的微任务。

我们再从V8引擎的层面来分析一下微任务是怎么运转的。

当JS执行一段脚本的时候,V8会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8引擎也会在内部创建一个微任务队列。这个微任务队列就是用来存放微任务的,因为当前宏任务执行的过程中,有时候会产生多个微任务,这时候就需要使用这个微任务队列来保存这些微任务。不过这个微任务队列是给V8引擎内部使用的,我们是访问不到的。也就是说每个宏任务都关联了一个微任务队列。

微任务是怎么执行的呢?

通常情况下,在当前宏任务中的JS快执行完成时,也就在 JS 引擎准备退出全局执行上下文并清空调用栈的时候,JS 引擎会检测全局上下文中的微任务队列,然后按顺序执行队列中的微任务。这个时间点,在标准里叫做检查点。当然除了在退出全局执行上下文时的这个检查点外,还有别的的检查点,不过不是太重要,这里就不做介绍了。

如果在执行微任务的时候,又产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎会一直循环微任务队列中的任务,直到队列为空才算执行结束。

以上就是微任务的工作流程,我们来总结一下

  • 微任务和宏任务是绑定的,每个宏任务在执行的时候,都会创建自己的微任务队列。
  • 微任务的执行时长会影响到当前宏任务的时长。

举个例子

最后,我们通过一段代码,来分析一下微任务和宏任务的执行顺序。

function bar(){
  console.log('bar')
  Promise.resolve().then(
    (str) =>console.log('micro-bar')
  ) 
  setTimeout((str) =>console.log('macro-bar'),0)
}

function foo() {
  console.log('foo')
  Promise.resolve().then(
    (str) =>console.log('micro-foo')
  ) 
  setTimeout((str) =>console.log('macro-foo'),0)

  bar()
}
foo()
console.log('global')
Promise.resolve().then(
  (str) =>console.log('micro-global')
) 
setTimeout((str) =>console.log('macro-global'),0)

你可以先自己写一下最终打印的结果。最终打印的结果如下:

foo
bar
global
micro-foo
micro-bar
micro-global
macro-foo
macro-bar
macro-global

我们可以清晰地看出,微任务是处于宏任务之前执行的。接下来,我们就来详细分析下 V8 是怎么执行这段 JavaScript 代码的。

首先,当 V8 执行这段代码时,会将全局执行上下文压入调用栈中,并在执行上下文中创建一个空的微任务队列。那么此时:

  • 调用栈中包含了全局执行上下文
  • 微任务队列为空

此时的消息队列、主线程、调用栈的状态图如下所示:

b9bf0027185405e762b46cd4b77c892a.jpg (2284×1171)

然后,执行 foo 函数的调用,V8 会先创建 foo 函数的执行上下文,并将其压入到栈中。接着执行 Promise.resolve,这会触发一个 micro-foo1 微任务,V8 会将该微任务添加进微任务队列。然后执行 setTimeout 方法。该方法会触发了一个 macro-foo1 宏任务,V8 会将该宏任务添加进消息队列。那么此时:

  • 调用栈中包含了全局执行上下文、foo 函数的执行上下文
  • 微任务队列有了一个微任务,micro-foo
  • 消息队列中存放了一个通过 setTimeout 设置的宏任务,macro-foo

此时的消息队列、主线程和调用栈的状态图如下所示:

f85b1b316f669316cef23c4714d4ce2d.jpg (2284×1197)

接下来,foo 函数调用了 bar 函数,那么 V8 需要再创建 bar 函数的执行上下文,并将其压入栈中,接着执行 Promise.resolve,这会触发一个 micro-bar 微任务,该微任务会被添加进微任务队列。然后执行 setTimeout 方法,这也会触发一个 macro-bar 宏任务,宏任务同样也会被添加进消息队列。那么此时:

  • 调用栈中包含了全局执行上下文、foo 函数的执行上下文、bar 的执行上下文
  • 微任务队列中的微任务是 micro-foo、micro-bar
  • 消息队列中,宏任务的状态是 macro-foo、macro-bar

此时的消息队列、主线程和调用栈的状态图如下所示:

338875c3ff58e389af86cf2acab5bd1c.jpg (2284×1207)

接下来,bar 函数执行结束并退出,bar 函数的执行上下文也会从栈中弹出,紧接着 foo 函数执行结束并退出,foo 函数的执行上下文也随之从栈中被弹出。那么此时:

  • 调用栈中包含了全局执行上下文,因为 bar 函数和 foo 函数都执行结束了,所以它们的执行上下文都被弹出调用栈了
  • 微任务队列中的微任务同样还是 micro-foo、micro-bar
  • 消息队列中宏任务的状态同样还是 macro-foo、macro-bar

此时的消息队列、主线程和调用栈的状态图如下所示:

d24acef2cc39b1688dff9f19b9cdb9e0.jpg (2284×1153)

主线程执行完了 foo 函数,紧接着就要执行全局环境中的代码 Promise.resolve 了,这会触发一个 micro-global 微任务,V8 会将该微任务添加进微任务队列。接着又执行 setTimeout 方法,该方法会触发了一个 macro-global 宏任务,V8 会将该宏任务添加进消息队列。那么此时:

  • 调用栈中包含的是全局执行上下文
  • 微任务队列中的微任务同样还是 micro-foo、micro-bar、micro-global
  • 消息队列中宏任务的状态同样还是 macro-foo、macro-bar、macro-global

此时的消息队列、主线程和调用栈的状态图如下所示:

34fb1a481b60708360b48ba04821f6db.jpg (2284×1143)

等到这段代码即将执行完成时,V8 便要销毁这段代码的环境对象,此时环境对象的析构函数被调用(注意,这里的析构函数是 C++ 中的概念),这里就是 V8 执行微任务的一个检查点,这时候 V8 会检查微任务队列,如果微任务队列中存在微任务,那么 V8 会依次取出微任务,并按照顺行执行。因为微任务队列中的任务分别是:micro-foo、micro-bar、micro-global,所以执行的顺序也是如此。

此时的消息队列、主线程和调用栈的状态图如下所示:

267e549592913e25bdb6dfe716eeddc9.jpg (2284×1142)

等微任务队列中的所有微任务都执行完成之后,当前的宏任务也就执行结束了,接下来主线程会继续重复执行取出任务、执行任务的过程。由于正常情况下,取出宏任务的顺序是按照先进先出的顺序,所有最后打印出来的顺序是:macro-foo、macro-bar、macro-global。

等所有的任务执行完成之后,消息队列、主线程和调用栈的状态图如下所示:

6caa4a24f1ddd918af62f6dbcb1c464a.jpg (2284×1171)

以上就是完整的执行流程的分析,到这里,相信你已经了解微任务和宏任务的执行时机是不同的了,微任务是在当前的任务快要执行结束之前执行的,宏任务是消息队列中的任务,主线程执行完一个宏任务之后,便会接着从消息队列中取出下一个宏任务并执行。

总结

通过上面的例子,我们已经知道了宏任务和微任务在浏览器中是如何执行的。理解了宏任务和微任务的执行顺序,对于面试题中常见的手写一段代码运行结果就变得驾轻就熟。关于这类面试题中常见的 setTimeout、Promise、generate 和 async/await ,我们只介绍了前两个,不过后两个也和微任务有很大关系,具体的我们后面的文章中会介绍。