js 事件循环(event loop)、宏任务、微任务

js 事件循环(event loop)、宏任务、微任务

前言

Google工程师Jake Archibald写了一篇关于事件循环以及宏任务微任务的文章,非常经典,本文的例子都来源于改文章,有兴趣的可以去浏览原文Tasks, microtasks, queues and schedules

在该文中也提供了Philip Roberts在youtube上的一个关于事件循环的视频What the heck is the event loop anyway?(需要梯子),视频里讲的非常好,有兴趣可以看下。

事件循环的基本概念

  • JavaScript是单线程的,同时只能执行一个代码片段。如果存在耗时的异步操作,则会被阻塞(blocking).
  • JavaScript代码的执行过程中,除了依靠函数调用栈来搞定函数的执行顺序外,还依靠任务队列(task queue)来搞定另外一些代码的执行,例如setTimeoutAJAX(XHR)等,这些都是Web API,由浏览器提供,不存在于V8引擎内。
  • 任务队列又分为macro-task(宏任务)与micro-task(微任务),在最新标准中,它们被分别称为taskjobs微任务在一次事件循环中,总是先于宏任务执行
  • 一个线程中,事件循环是唯一的,但是任务队列里的任务可以拥有多个。
  • macro-task大概包括:script(整体代码), setTimeout, setInterval, setImmediate, I/O, UI rendering
  • micro-task大概包括: process.nextTick, Promisethen catch finally, Object.observe(已废弃), MutationObserver(html5新特性)
  • setTimeout/Promise等我们称之为任务源。而进入任务队列的是他们指定的具体执行任务。
  • Promise的实例化会在主执行栈中执行

通过一个简单的例子来理解

代码

console.log('script start');

setTimeout(function () {
  console.log('setTimeout');
}, 0);

Promise.resolve()
  .then(function () {
    console.log('promise1');
  })
  .then(function () {
    console.log('promise2');
  });

console.log('script end');

打印结果:

script start
script end
promise1
promise2
setTimeout

为什么会有这样的结果?

逐步分析:

  1. 主执行栈开始,执行console.log('script start');,打印script start
  2. 执行栈往下执行setTimeout,由于setTimeout是属于宏任务,浏览器等待0ms,将setTimeoutcallback函数放入宏任务队列中
  3. 执行栈继续执行Promise,由于Promisethen属于微任务,则将第一个then中的回调函数放入到微任务队列中,第二个then需要等待第一个then执行完毕才会执行,因此这里不会讲第二个then的回调函数放入微任务队列中
  4. 执行栈执行console.log('script end');,打印script end
  5. 由于微任务总是先于宏任务执行,则先取出微任务队列中的第一个then的回调函数执行,打印promise1
  6. 第一个then执行完,紧接着又有一个then,再将其放入微任务队列中
  7. 只要微任务队列中还有任务没有执行完,则优先执行,因此执行第二个then的回调函数,打印promise2
  8. 微任务队列都执行完毕,此时执行宏任务队列里的任务,打印setTimeout

动画演示:

一个更复杂的例子

<!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>
    <style>
        .outer {
            background: #D4D4D4;
            padding: 25px;
            width: 92px;
            margin: 0 auto;
        }
        .inner {
            background: #ADADAD;
            padding: 46px;
            width: 0;
        }
    </style>
</head>
<body>
    <div class="outer">
        <div class="inner"></div>
    </div>
    <script>
        // Let's get hold of those elements
        var outer = document.querySelector('.outer');
        var inner = document.querySelector('.inner');

        // Let's listen for attribute changes on the
        // outer element
        new MutationObserver(function () {
        console.log('mutate');
        }).observe(outer, {
        attributes: true,
        });

        // Here's a click listener…
        function onClick() {
        console.log('click');

        setTimeout(function () {
            console.log('timeout');
        }, 0);

        Promise.resolve().then(function () {
            console.log('promise');
        });

        outer.setAttribute('data-random', Math.random());
        }

        // …which we'll attach to both elements
        inner.addEventListener('click', onClick);
        outer.addEventListener('click', onClick);
    </script>
</body>
</html>

打印结果:

click
promise
mutate
click
promise
mutate
timeout
timeout

我们这里就不逐步分析了,由于事件的冒泡机制,当第一个事件循环结束后,事件冒泡,会开启第二个事件循环,重复来一遍,看下动画演示:

使用代码派发事件会发生什么事?

上面的代码,我们是通过点击鼠标来触发事件,但是如果我们直接在代码中触发事件,会发生什么事呢?

// 其他代码不变
inner.click(); // 在代码中派发事件

打印结果:

click
click
promise
mutate
promise
timeout
timeout

为什么差距这么大,哪里影响了代码的执行顺序?实际上是我们触发事件的方式引起的区别(废话),当直接在代码中触发事件的时候,此时js主执行栈并不是空的,因此不会先去执行微任务宏任务队列中的任务,再去触发outer element的点击事件,而是直接触发outer element的点击事件,当主执行栈没有任务的时候,再去执行微任务宏任务队列中的任务。

为什么mutate只打印了一次,那是因为MutationObserver的监听是异步触发,在所有的DOM操作完成后才触发使回调函数进入微任务队列。

比如,程序中有10个修改DOM的操作,只有在第十个处理完之后,回调函数才进入微任务队列。

通过动画就一目了然了:

async/await的事件循环

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 start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

await之后的代码必须等await语句执行完成后(包括微任务完成),才能执行后面的,也就是说,只有运行完await语句,才把await语句后面的全部代码加入到微任务行列

await语句是同步的,await语句后面全部代码才是异步的微任务


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 289211569@qq.com