一文读懂微任务、宏任务、事件循环Event-Loop(浏览器篇)

771 阅读12分钟

前言

浏览器中 JavaScript 的执行流程和 Node.js 中的流程都是基于 事件循环 的,所以有很多相似的地方,但是又因为浏览器需要操作DOM,并与用户交互(鼠标滑动、窗口缩放、点击)等等等,所以它与node环境中的事件循环又不太一样,但是大概思想差不多,本章我们一起来探究下浏览器中的事件循环。

tip:想要了解node中事件循环机制参考上一篇文章,微任务、宏任务、Event-Loop(nodejs篇)

思考

现在想象一个画面,我们正在浏览一个网页,不知你是否好奇过这个网页是怎么被浏览器渲染出来的?为什么滚动鼠标的滚轮,能够控制窗口上下移动?为什么通过按住Ctrl+滚动鼠标滚轮,能够控制浏览器窗口的放大和缩小?为什么点击网页上的某个隐藏按钮,浏览器中的某块内容会被隐藏起来?等等诸如此类的操作,每天都在发生,不知你们是否思考过这些都是因为什么?

其实,这些操作实际上都可以看作为浏览器需要执行的一个个小任务,浏览器就是通过事件循环,去调度这些任务,并在恰当的时机使用对应的处理引擎执行它们,这样就达到了想要的效果。比如负责处理页面显示效果的GUI引擎、负责处理Script脚本和执行回调函数的JavaScript引擎和负责请求第三方资源,作为总控制中心的主线程

一个小例子来理解浏览器加载页面的执行机制

我们先来看一个简单的例子来具体理解下上述的三个引擎,以浏览器首次加载页面为例。

tip:如对浏览器首次渲染页面的具体细节不太了解,请参考之前的几篇文章

优化关键路径(引言篇)构建DOM树和CSSDOM树构建渲染树阻塞渲染的CSS使用JavaScript添加交互评估关键渲染路径的几种方法分析关键渲染路径性能优化关键渲染路径优化首屏加载的规则和建议

<!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>
    <link rel="stylesheet" href="1.css">
    <script src="1.js"></script>
</head>

<body>
    <span>hello world</span>
    <img style="vertical-align: middle;"
        src="https://portrait.gitee.com/uploads/avatars/user/517/1553068_hrbust_cheny_1617868573.png!avatar200" alt="">
</body>

</html>

浏览器首屏渲染

代码分析如下:

  1. 首先主线程发起请求,去获取HTML文本资源。

  2. 获取到HTML资源后,交给GUI引擎解析。

  3. 解析过程中,遇到了<link href="xxx.css>标签,先暂停解析,将控制权交由主线程。因为CSS文件会影响页面样式,所以GUI引擎在生成CSSDOM树之前,如果遇到未加载的CSS文件时,会暂停解析(也就是我们常说的CSS文件会阻塞页面加载)

  4. 主线程发起请求,获取CSS文本资源。

  5. 获取完毕后,GUI引擎继续解析,遇到了<script src="xxx.js">,先暂停解析,将控制权交由主线程。因为浏览器中的JS文件中有可能会操作DOM或者改变CSS样式,所以GUI引擎在生成DOM树和CSSDOM树之前,如果遇到未加载的JS文件时,也会暂停解析(也就是我们常说的JS文件会阻塞页面加载)。

  6. 主线程发起请求,获取JS文本资源。

  7. 控制权交由JavaScript引擎,执行加载完成的JS文件。

  8. GUI引擎继续解析,遇到了<img src="http://xxx.png">标签,因为此刻的DOM树和CSSDOM树均未生成,浏览器会先抑制图片的onLoad事件,将该任务先放到任务队列中先不执行。即先不请求资源,等DOM 和 CSSOM 均准备完成后,再去请求(即图片资源并不会阻止DOM树和CSSDOM树的生成)。

    tip:虽然图片不会阻止DOM树和CSSDOM树的生成,但是会阻碍页面onLoad回调的触发时机。

    因为在触发页面的onLoad回调函数之前,需先触发domComplete回调,而domComplete回调触发即代表页面的所有资源都已请求完成,包括图片资源,所以图片资源假如没有请求完成时,会影响到onLoad回调触发的时机,并且很有可能在onLoad的回调中,也有处理DOM的操作,所以整体上看也会影响到页面的渲染,最好也做个优化,比如图片懒加载。

  9. HTML文件解析完毕,GUI引擎生成DOM树。

  10. GUI引擎生成CSSDOM树。

    tip:如果没有阻塞解析器的 JavaScript,则DOMContentLoaded 将在domInteractive 后立即触发。 即:生成DOM树后立即生成CSSDOM树

  11. GUI引擎根据DOM树和CSSDOM树生成渲染树。

  12. GUI引擎根据渲染树,开始布局(Layout),即计算元素的几何位置。

  13. GUI引擎开始绘制(Paint),将元素绘制到浏览器。

  14. 在触发domComplete回调之前,发现任务队列有未加载的图片,JavaScript引擎执行加载图片回调。

  15. 主线程发起请求,加载图片资源(所有资源都请求完毕,触发domComplete回调)

  16. 浏览器加载的最后一步,JavaScript引擎触发页面的onLoad回调(以便触发额外的应用逻辑)。

宏任务Macrotask

image-20211103102111067

宏任务解释解释参考这篇,微任务、宏任务、Event-Loop(nodejs篇)

微任务Microtask

微任务

微任务解释参考这篇文章,微任务、宏任务、Event-Loop(nodejs篇)

每个宏任务之后,引擎会立即执行微任务队列中的所有任务,然后再执行其他的宏任务,或渲染,或进行其他任何操作。

示例:

<script>
    setTimeout(() => alert("timeout"));

    Promise.resolve()
      .then(() => alert("promise"));

    alert("code");
</script>
  1. code 首先显示,因为它是常规的同步调用。
  2. promise 第二个出现,因为 then 会通过微任务队列,并在当前代码之后执行。
  3. timeout 最后显示,因为它是一个宏任务。

queueMicrotask(func)

还有一个特殊的函数 queueMicrotask(func),它对 func 进行排队,以在微任务队列中执行。

事件循环

根据上面的例子,对于浏览器对页面的处理流程,我们脑海中应该已经有了一个清晰的轮廓。假设在页面加载之后,我们还有一些其它操作,如点击按钮,滚动窗口等等这些交互,浏览器都会交由对应引擎负责处理。JavaScript引擎首当其冲,会执行各种任务的回调函数。而事件循环实际就是在 JavaScript 引擎等待任务,执行任务和进入休眠状态等待更多任务这几个状态之间转换的无限循环。(概念来自:事件循环:微任务和宏任务

tip:JavaScript引擎负责执行各种任务,而GUI引擎负责改变页面布局和样式,它俩各司其职。当有一些JS操作改变DOM或样式时,会交由GUI引擎处理。

需要注意的两个细节

有两个细节需要注意下:

  1. 当Javascript引擎在执行任务时,永远不会进行渲染(render)。即,GUI引擎只有当JavaScript引擎执行完当前任务后,才可以处理页面样式,比如重排或者重绘,一些动画效果等等。
  2. 如果一项任务执行花费的时间过长,浏览器将无法执行其它任务,例如处理用户事件。因此,在一定时间后,浏览器会抛出一个如“页面未响应”之类的警报,建议终止这个任务。这种情况常发生在有大量复杂的计算或导致死循环的程序错误时。

事件循环的一般算法

  1. 当有任务时:
    • 从最先进入的任务开始执行。
  2. 休眠直到出现任务,然后转到第 1 步。

当我们浏览一个网页时就是上述这种形式。JavaScript 引擎大多数时候不执行任何操作,它仅在脚本/处理程序/事件激活时执行。

任务示例:

  • 当外部脚本 <script src="..."> 加载完成时,任务就是执行它。
  • 当用户移动鼠标时,任务就是派生出 mousemove 事件和执行处理程序。
  • 当安排的(scheduled)setTimeout 时间到达时,任务就是执行其回调。
  • ……诸如此类。

设置任务 —— 引擎处理它们 —— 然后等待更多任务(即休眠,几乎不消耗 CPU 资源)。

一个任务到来时,引擎可能正处于繁忙状态,那么这个任务就会被排入队列。

多个任务组成了一个队列,即所谓的“宏任务队列”(v8 术语)。

image.png

例如,当引擎正在忙于执行一段 script 时,用户可能会移动鼠标而产生 mousemove 事件,setTimeout 或许也刚好到期,以及其他任务,这些任务组成了一个队列,如上图所示。

队列中的任务基于“先进先出”的原则执行。当浏览器引擎执行完 script 后,它会处理 mousemove 事件,然后处理 setTimeout 处理程序,依此类推。

例子1:拆分CPU过载任务

假设我们有一个 CPU 过载任务。

例如,语法高亮(用来给本页面中的示例代码着色)是相当耗费 CPU 资源的任务。为了高亮显示代码,它执行分析,创建很多着了色的元素,然后将它们添加到文档中 —— 对于文本量大的文档来说,需要耗费很长时间。

当引擎忙于语法高亮时,它就无法处理其他 DOM 相关的工作,例如处理用户事件等。它甚至可能会导致浏览器“中断(hiccup)”甚至“挂起(hang)”一段时间,这是不可接受的。

我们可以通过将大任务拆分成多个小任务来避免这个问题。高亮显示前 100 行,然后使用 setTimeout(延时参数为 0)来安排(schedule)后 100 行的高亮显示,依此类推。

为了演示这种方法,简单起见,让我们写一个从 1 数到 1 000 000 000 的函数,而不写文本高亮。

<body>
    <button>click me</button>
    <a href="http://bnbiye.cn" target="_blank">噗噗博客</a>

    <script>
        let i = 0;
        let start = Date.now();

        function count() {
            // 做一个繁重的任务
            for (let j = 0; j < 1e9; j++) {
                i++;
            }
            alert("Done in " + (Date.now() - start) + 'ms');
        }

        const btn = document.getElementsByTagName('button')[0]
        btn.addEventListener('click', function () {
            count()
        })
    </script>
</body>

在上面的代码中,我们给button按钮增加了一个点击事件,点击后会调用count()方法,此处的count方法运行了1 000 000 000次的i++,非常耗费时间,执行完成后会弹出个alert弹框。假设在执行count的途中,我们去尝试点击页面上的<a>标签跳转网页,这时会发现引擎会“挂起”一段时间,在计数结束之前不会处理这个点击事件。只有当计数器执行完毕后,才会执行引擎在挂在时操作的一些点击事件,假如我们点击了三次,看一下浏览器是如何轮询这些事件的。我们模拟下面的操作(浏览器页面加载完毕之后的操作)

  1. 点击button
  2. 程序挂起后连续点击三次a标签

image.png

分析

image.png

  1. 点击按钮,执行执行count方法,因为会运行很长时间,浏览器被挂起,这期间不会做任何其它事,如果在这期间有用户事件,会先放置任务队列中,等待该操作执行完毕后,去任务队列轮询事件,按照先进先出原则,依次执行。
  2. count方法尚未执行完成,第一次点击a标签,将点击事件放置于任务队列中
  3. count方法尚未执行完成,第二次点击a标签,将点击事件放置于任务队列中
  4. count方法尚未执行完成,第三次点击a标签,将点击事件放置于任务队列中
  5. count方法执行完毕,弹出alert弹框alert弹框会阻塞浏览器渲染,不点击确认,浏览器会一直被挂起,点击确认按钮。
  6. JavaScript引擎任务为空,开始轮询任务队列,执行第一次的点击事件,在新窗口打开噗噗博客
  7. JavaScript引擎任务为空,开始轮询任务队列,执行第二次的点击事件,在新窗口打开噗噗博客
  8. JavaScript引擎任务为空,开始轮询任务队列,执行第三次的点击事件,在新窗口打开噗噗博客
  9. 所有任务执行完毕,引擎进入休眠状态。

tip:在执行count方法时,是非常浪费时间的,电脑CPU如果不行的话,浏览器甚至可能会显示一个“脚本执行时间过长”的警告。

使用setTimeout优化 (一)
<body>
    <button>click me</button>
    <a href="http://bnbiye.cn" target="_blank">噗噗博客</a>
    <span>优化1</span>

    <script>
        let i = 0;
        let start = Date.now();

        function count() {
            // 做一个繁重的任务
            do {
                i++;
            } while (i % 1e6 !== 0);

            if (i === 1e9) {
                alert("Done in " + (Date.now() - start) + 'ms');
            } else {
                setTimeout(count); // 安排(schedule)新的调用
            }
        }

        const btn = document.getElementsByTagName('button')[0]
        btn.addEventListener('click', function () {
            count()
        })
    </script>
</body>

如上述代码,我们把繁重的count任务,使用setTimeout拆分为多块,每次计数1 000 000次,这样浏览器就不会出现“悬挂”的感觉,整体给人的感觉就会非常流程,完全没有感觉,连接还是秒跳转。

image.png

代码流程如下:

  1. 首先执行计数:i=1...1000000
  2. 然后执行计数:i=1000001..2000000
  3. ……以此类推。

如果在引擎忙于执行第一部分时出现了一个新的副任务(例如 onclick 事件),则该任务会被排入队列,然后在第一部分执行结束时,并在下一部分开始执行前,会执行该副任务。周期性地在两次 count 执行期间返回事件循环,这为 JavaScript 引擎提供了足够的“空气”来执行其他操作,以响应其他的用户行为。

image.png

使用setTimeout优化 (二)
<body>
    <button>click me</button>
    <a href="http://bnbiye.cn" target="_blank">噗噗博客</a>
    <span>优化2</span>

    <script>
        let i = 0;
        let start = Date.now();

        function count() {
            // 做一个繁重的任务
            // 将调度(scheduling)移动到开头
            if (i < 1e9 - 1e6) {
                setTimeout(count); // 安排(schedule)新的调用
            }

            do {
                i++;
            } while (i % 1e6 !== 0);

            if (i == 1e9) {
                alert("Done in " + (Date.now() - start) + 'ms');
            }
        }

        const btn = document.getElementsByTagName('button')[0]
        btn.addEventListener('click', function () {
            count()
        })
    </script>
</body>

改进的代码中,我们将setTimeout移动到了count() 的开头,运行它,我们注意到花费的时间减少了。

image.png

image.png

这是因为,多个嵌套的 setTimeout 调用在浏览器中的最小延迟为 4ms。即使我们设置了 0,但还是 4ms(或者更久一些)。所以我们安排(schedule)得越早,运行速度也就越快。

所以通过任务调度(schedule),我们将一个大任务划分了多个小任务,解决了阻塞用户界面的问题,发现总耗时上也没有长很多。

例子2:进度指示

对浏览器脚本中的过载型任务进行拆分的另一个好处是,我们可以显示进度指示。

正如前面所提到的,仅在当前运行的任务完成后,才会对 DOM 中的更改进行绘制,无论这个任务运行花费了多长时间

从一方面讲,这非常好,因为我们的函数可能会创建很多元素,将它们一个接一个地插入到文档中,并更改其样式 —— 访问者不会看到任何未完成的“中间态”内容。

<body>
    <div id="progress"></div>
    <button>click me</button>
    <script>
        function count() {
            for (let i = 0; i < 1e6; i++) {
                i++;
                progress.innerHTML = i;
            }
        }

        const btn = document.getElementsByTagName('button')[0]
        const progress = document.getElementById('progress')
        btn.addEventListener('click', function () {
            count()
        })
    </script>
</body>

上面的例子中,点击button后,对 i 的更改在该函数完成前不会显示出来,所以我们将只会看到最后的值:

1.gif

画图分析:

image.png

代码流程如下:

  1. 我们点击了button后,会触发button的click回调,JavaScript引擎开始执行count方法
  2. 在count方法中,有1e6次的i++操作,并且在每次i++之后都会有个修改dom的操作progress.innerHTML,但是由于浏览器在当前任务未完成时,是不会修改DOM的,所以先将这些操作全部先移至任务队列中,等JavaScript引擎执行完当前任务后,再去轮询任务队列的任务,所以此时会执行1e6次的i++操作,也是会消耗很长时间的,所以这段时间浏览器会是个“悬挂”的状态。
  3. 当2中的任务执行完后,JavaScript引擎的当前执行任务为空,开始轮询任务队列里的任务,发现有1e6次的修改dom的操作,这时将控制权交由GUI引擎,由GUI引擎去执行它。
  4. GUI引擎会做一个优化,当有很多相同的操作去修改同一个dom时,只会执行一次,这里有1e6次的progress.innerHTML=999999操作,优化后只执行一次,最终的效果就如上述动图所示,浏览器卡了半天后,最后才将文字修改为999999
使用setTimeout优化

上述的例子,我们也可能想在任务执行期间展示一些东西,例如进度条。

这时我们就可以使用setTimeout 将繁重的任务拆分成几部分,那么变化就会被在它们之间绘制出来。看起来效果也会更好看。

<body>
    <div id="progress"></div>
    <button>click me</button>
    <script>
        let i = 0;

        function count() {
            // 做繁重的任务的一部分 (*)
            do {
                i++;
                progress.innerHTML = i;
            } while (i % 1e3 !== 0);

            if (i < 1e5) {
                setTimeout(count);
            }
        }

        const btn = document.getElementsByTagName('button')[0]
        const progress = document.getElementById('progress')
        btn.addEventListener('click', function () {
            count()
        })
    </script>
</body>

2.gif

画图分析:

image.png

  1. 我们点击了button后,会触发button的click回调,JavaScript引擎开始执行count方法,
  2. i每次自增到1000后都会往任务队列增加一个setTimeout,在每次自增时,还有一个innerHTML的DOM操作,也仍进任务队列。
  3. 这样就给浏览器预留了一个空白的空间,每次i自增1000个数时,当前任务都执行完毕,然后,通过事件循环,开始遍历任务队列,根据先进先出原则,执行任务队列中的任务。
  4. GUI引擎在执行innerHTML=i时,会做一个优化,所有相同的操作只执行一次。

所以最终就达成了上面动图的效果,每次i增加到1000后,浏览器就修改一次dom,整体上视觉效果更好,并且浏览器也没有了卡顿的感觉。

更详细的事件循环

image.png

更详细的事件循环图示如上图所示,(首先脚本、然后微任务、渲染等等)

这里只需注意几点:

  • 任务队列中的异步任务都遵循先入先出原则
  • 但是每次执行完一个任务后,再次切换到任务队列时,都会优先遍历微任务队列。

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

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

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

例子

这是一个与前面那个例子类似的,带有“计数进度条”的示例,但是它使用了 queueMicrotask 而不是 setTimeout。你可以看到它在最后才渲染。就像写的是同步代码一样:

<body>
    <div id="progress"></div>
    <button>click me</button>
    <span>queueMicrotask</span>
    <script>
        let i = 0;

        function count() {
            // 做繁重的任务的一部分 (*)
            do {
                i++;
                progress.innerHTML = i;
            } while (i % 1e3 !== 0);

            if (i < 1e6) {
                queueMicrotask(count);
            }
        }

        const btn = document.getElementsByTagName('button')[0]
        const progress = document.getElementById('progress')
        btn.addEventListener('click', function () {
            count()
        })
    </script>
</body>

</html>

3.gif

画图分析: image.png

上面分析只需注意下面几点:

  1. JavaScript引擎在当前任务未执行完成时,浏览器是不允许修改DOM的。
  2. 每次从JavaScript引擎切换到任务队列开始遍历异步任务时,都是先遍历微任务队列(和nodejs一样),只有微任务队列为空时,才会去遍历宏任务队列。
  3. 当JavaScript引擎在执行任务时,所有操作DOM的函数都先认定为宏任务,仍进宏任务队列。
  4. GUI引擎在执行DOM操作时,相同的操作会优化成一个,只执行一次。

参考

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