【前端进阶】深入理解浏览器事件循环----只知道宏任务跟微任务已经不够用了。

326 阅读6分钟

事件循环有多重要呢?

首先说这个事件循环到底有多重要呢?这么跟你说就是你要是不懂事件循环其实你根本就不懂前端。

因为事件循环是我们浏览器的核心原理,没有事件循环我们浏览器根本就跑不起来,而我们前端不就是跟浏览器打交道么?

JavaScript 的两个特点

重点: javascript 从诞生之日起就是一门单线程的非阻塞的脚本语言

  • 单线程: JavaScript 是单线程的,单线程是指 JavaScript 引擎中解析和执行 JavaScript 代码的线程只有一个(主线程),每次只能做一件事情。单线程存在是必然的,在浏览器中, 如果 javascript 是多线程的,那么当两个线程同时对某一个 dom 进行一项操作。例如:一个向其添加事件,而另一个删除了这个 dom,这个时候其实是它们是相互矛盾的。
  • 非阻塞: 当我们的 Javascript 代码运行一个异步任务的时候(例如 Ajax定时器 等),主线程会挂起这个任务,然后异步任务返回结果的时候再根据特定的结果去执行相应的回调函数。

非阻塞是如何实现的呢?这就得谈到我们的主角了----事件循环或者叫做消息循环(EventLoop or Message Loop)

补充小知识

为什么官方的描述叫做Event Loop(事件循环)而些人喜欢叫做Message Loop(消息循环)呢?

其实是因为在Chrome(谷歌浏览器)的源码中内部的实现时叫做Message Loop 浏览器源码.png 其实除了名字不一样,说的东西都是一样的没有任何区别。

浏览器中的事件循环

当 javascript 代码执行的时候会将不同的变量存于内存中的不同位置:堆(heap)和栈(stack)中来加以区分。其中,堆里存放着一些对象。而栈中则存放着一些基础类型变量以及对象的指针。

  • 栈是后进先出可以这么理解(把羽毛球放到球桶中、后放进去的是先拿出来)
  • 堆是一种树状的数据结构,可以理解为图书馆取书,通过书号索引的方式找到书籍

例如下面是一段同步代码的执行

    function a() {
        b(); 
        console.log('a'); 
    }
    function b() { 
        console.log('b')
    }
    a();
  • 执行函数 a()先入栈
  • a()中先执行函数 b() 函数b() 入栈
  • 执行函数b(), console.log('b') 入栈
  • 输出 b, console.log('b')出栈
  • 函数b() 执行完成,出栈
  • console.log('a') 入栈,执行,输出 a, 出栈
  • 函数a 执行完成,出栈

同步代码的话比较简单,没什么好说的,但涉及到异步执行的话,又是如何执行的呢?

消息队列(Message queue): javascript 引擎遇到一个异步事件后并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。当一个异步事件返回结果后,javascript 会将这个事件加入与当前执行栈不同的另一个队列,我们称之为消息队列。

被放入事件队列不会立刻执行起回调,而是等待当前执行栈中所有任务都执行完毕,主线程空闲状态,主线程会去查找事件队列中是否有任务,如果有,则取出排在第一位的事件,并把这个事件对应的回调放到执行栈中,然后执行其中的同步代码。

事件循环执行顺序

Chrome的源码中,它开启一个不会结束的for循环,每一次循环从消息队列中取出第一个任务执行,而其他线程只需要在合适的时候将任务加入到队列末尾即可。

在过去只把消息队列简单的分为宏队列微队列,循环过程如下:

  • 执行一个宏任务(一般一开始是整体代码(script)),如果没有可选的宏任务,则直接处理微任务
  • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
  • 执行过程中如果遇到宏任务,就将它添加到宏任务的任务队列中
  • 执行一个宏任务完成之后,就需要检测微任务队列有没有需要执行的任务,有的话,全部执行,没有的话,进入下一步
  • 检查渲染,然后 GUI 线程接管渲染,进行浏览器渲染
  • 渲染完毕后,JS线程继续接管,开始下一个宏任务...(循环上面的步骤)

这种说法目前已经无法满足复杂的浏览器环境,取而代之的是一种更加灵活多变的处理方式。

根据W3C官方的解释,每个任务有不同的类型,同类型的任务必须在同一个队列,不同的任务可以属于不同的队列,不同的任务队列有不同的优先级(一般有关用户交互的优先级会高一些),在一次事件循环中,由浏览器自行决定取哪一个队列的任务。但浏览器必须有一个微队列,微队列的任务一定是具备最高的优先级,必须优先调度执行。

示范例子

<!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="begin">开始</button>
    <button id="interaction">添加交互任务</button>
    <script>
        const interaction = document.querySelector("#interaction")
        const begin = document.querySelector("#begin")
        // 死循环指定的时间
        function delay(durations) {
            var start = Date.now();
            while (Date.now() - start < durations) { }
        }
        function addDelay() {
            console.log("添加延时任务");
            setTimeout(() => {
                console.log("执行延时任务");
            }, 100);
            delay(2000) //死循环2s保证事件进入到队列
        }
        function addNetWork() {
            console.log("添加微任务队列");
            new Promise(function (resolve) {
                resolve();
            }).then(function () {
                console.log('微任务队列执行')
            })
        }
        function addInteraction() {
            console.log("添加交互任务");
            interaction.addEventListener("click", () => {
                console.log("交互执行");
            })
            delay(2000) //死循环2s给用户2s的点击让其进入交互队列
        }
        begin.addEventListener("click", () => {
            addDelay()
            addInteraction()
            addNetWork()
            console.log("================================");
        })      
    </script>
</body>

</html>

我们来分析一下:

在上面有三个队列分别是:微任务队列(addNetWork)、交互队列(addInteraction)、延时队列(addDelay),然后当我们点击开始按钮后会依次到达各自的队列,顺序为addDelay、addInteraction、addNetWork。

目的是什么呢?就是要做成3个队列来看看究竟是哪一个队列先执行,按照我们上面最新的说法是:微队列优先级最高,交互队列其次(不管哪一个任务是先进入队列的哪怕是延时任务是先进入的依旧是按照上面所说的顺序来)。

如果是按照以前那些教程所说的任务只有宏任务队列跟微任务队列的话是完全解释不通的

gitff.gif

练习题

async function async1() {
    console.log('async1 start')
    await async2()
    console.log('async1 end')
}
async function async2() {
    console.log('async2')
}
console.log('script start')
setTimeout(function () {
    console.log('setTimeout')
}, 0)
async1();
new Promise(function (resolve) {
    console.log('promise1')
    resolve();
}).then(function () {
    console.log('promise2')
})
console.log('script end')

  • 执行全局 script,输出 ”script start“。
  • 执行 setTimeout 它会进入 延时 队列。
  • 执行函数async1,输出”async1 start“,然后执行async2函数输出:”async2“ 后面的 async1 end则进入到微队列。
  • 执行new Promise,输出“promise1” .then后的 “promise2”进入微队列。
  • 最后执行console.log('script end')输出“script end”当主线程执行完成后就从任务队列中取任务执行,微任务优级最高。所以先输出“async1 end”跟“promise2”最后输出“seTimeout”。
  • 结果为:script start-async1 start-async2-promise1-script end-async1 end-promise2-seTimeout(下图可作为参考)

例子2.png

建议可以自行到掘金上找这种练习题多锻炼一下。