# 搞清事件循环、宏任务、微任务

想要了解清楚宏任务微任务,就必须先了解事件循环(Event Loop)机制

想了解什么是宏任务,什么是微任务,什么时候会产生宏任务,就还得了解浏览器的线程

大家都知道js是单线程,却很少人知道浏览器是多线程的。 浏览器的线程,并不是单单指js的线程,js的线程只是浏览器线程的其中一条

# JS是单线程

JS是单线程的,即js的代码只能在一个线程上运行,也就说,js同时只能执行一个js任务,但是为什么要这样呢?这与浏览器的用途有关,JS的主要用途是与用户互动和操作DOM。假设想一段JS代码,分发到两个并行互不相关的线程上运行,一个线程在DOM上添加内容,另一个线程在删除DOM,那么会发生什么?以哪个为准?所以为了避免复杂性,JS从一开始就是单线程的,以后也不会变。

既然JS是单线程的,那么诸如onclick回调,setTimeout,Ajax这些异步都是怎么实现的呢?是因为浏览器或node(宿主环境)是多线程的,即浏览器搞了几个其他线程去辅助JS线程的运行。

# 浏览器线程

介绍浏览器线程前,先给大家简单普及下 什么是进程和线程

大家可以把浏览器当作是一个工厂,一个进程就当作是车间, 线程就是流水线, 一个车间可以有很多条流水线。

这也就是说,浏览器的tab标签页(一个页面)就是一个进程, 一个页面(进程)中又同时可以存在多个线程

想要更加详情的了解什么是进程和线程,可以去看看阮一峰老师介绍的文章 进程与线程的一个简单解释 (opens new window)

下面就给大家介绍浏览器的线程

1、GUI 渲染线程 (可以理解为html css渲染的线程)

负责渲染浏览器界面HTML元素,当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行。在Javascript引擎运行脚本期间,GUI渲染线程都是处于挂起状态的,也就是说被”冻结”了.

想要了解浏览器渲染流程可以看看另一篇文章,浏览器渲染原理浏览

2、JS 引擎线程

JS内核,负责处理Javascript脚本主程序。一直等待着任务队列(下面会将什么是任务)中任务的到来,然后解析Javascript脚本,运行代码。浏览器中的一个Tab标签页(一个页面,也就是一个进程)中无论什么时候都只有一个JS线程在运行JS程序。

GUI渲染线程与JS引擎线程是互斥的,所以如果JS执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。

3、定时器触发线程

  • 定时器setInterval与setTimeout所在线程
  • 浏览器定时计数器并不是由JavaScript引擎计数的 因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确, 因此通过单独线程来计时并触发定时是更为合理的方案。
  • 定时器到时间后就会把回调函数放到任务队列中,等待js引擎处理

4、浏览器事件线程

  • 用来控制事件,JS引擎自己忙不过来,需要浏览器另开线程协助
  • 当JS引擎执行代码块如鼠标点击click、onload等等事件,会将对应任务添加到事件触发线程中
  • 当对应的事件符合触发条件被触发时,该线程会把事件对应的函数添加到待处理任务队列的队尾,等待JS引擎的处理
  • 由于JS的单线程关系,所以这些待处理任务队列中的事件都得排队等待JS引擎处理(当JS引擎空闲时才会去执行)

5、http 请求线程

在XMLHttpRequest在连接后是通过浏览器新开一个线程请求, 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件放到 JavaScript引擎的任务队列中等待处理。

上面说了很多次的任务队列,那什么是任务队列呢,为什么要有任务队列呢?

大家现在都知道了js是单线程的语言,所有的任务都在一个线程上面执行,任务排成一队,形成了队列,所以我们就称为任务队列

注释:这里说的任务,其实就是我们程序员写的每一行代码, 或者每个代码块

排在前面的任务就先执行,排在后面的任务就要等前面的任务执行完成了,才能执行,这样就很容易造成阻塞,因此引入了事件循环(EventLoop)机制。

# 事件循环(EventLoop)机制

事件循环机制,有这几个概念, 任务队列同步任务异步任务宏任务微任务

任务队列上面介绍过了, 这里的任务呢,又分为同步任务,和异步任务,同步任务大家就很好理解,就是js代码一行一行进入任务队列,大家排好队执行,按照先进先出的规则,

下面举个例子说明,

let a = 1;
console.log(a);
console.log(2);
console.log(3);

这样的代码执行就按照顺序输出: 1 2 3, 这个就是同步任务

那么异步是什么意思呢,

比如下面的代码

console.log('1');
setTimeout(() => {
    console.log(2);
}, 1000);
console.log(3);

上面代码输出的顺序是,1 3 2, 为什么呢,大家都知道setTimeout是延迟了一秒之后才执行, 所以这里我们可以这样理解,先console.log(1), 遇到setTimeout()后,就把setTimeout在重新开一条线程上面跑,这样就不会阻塞下面代码,console.log(3)就打印出来, 等一秒后,setTimeout定时器时间到了,它就执行回调函数, 打印console.log(2), 这样不阻塞下面的代码执行,我们也就称为异步

定时器延迟一秒输出,我们很好理解, 如果延迟0秒呢?

console.log('1');
setTimeout(() => {
    console.log(2);
}, 0);
console.log(3);

上面虽然定时器只是延迟了0秒,但是还是输出顺序为 1 3 2, 这个是为什么呢, 这里我们就得好好了解了解EventLoop(事件循环机制)。

通过上面简单的例子,我们大概知道了什么是同步,什么是异步。

浏览器执行代码的过程中,JS引擎会将代码进行分类,分别分到这两个队列中--宏任务(macrotask)微任务(microtask) .

哪些代码属于宏任务,哪些属于微任务呢?

常见的宏任务:

  • setTimeout, setInterval, setImmediate(nodejs的),的回调函数
  • ajax的回调函数
  • I/O(nodejs)

常见的微任务:

  • promise中的then()
  • process.nextTikc(nodejs)

微任务是宏任务的组成部分,微任务与宏任务是包含关系,并非前后并列,宏任务包含微任务.如果要谈微任务,需要指出它属于哪个宏任务才有意义.

EventLoop(事件循环机制) 就是执行代码的过程中,就是先遇到script,多个script标签也是当作合成一个script标签,就把它丢到宏任务队列中,然后拿出script这个宏任务出来执行,宏任务列表这时候就是空的了, 执行的过程当中遇到setTimeout的话,就把它丢到宏任务队列中,要是遇到promise的then就把它丢到微任务中,继续往下执行,直到script这个第一个宏任务执行完了,它就先去微任务队列看看,有任务的话,就会先执行完微任务队列的任务,执行完微任务队列的任务后,再去宏任务中拿任务来执行,一直这样循环下去,就算某一阶段是没宏任务也没有微任务的,它也是一直在监听任务的,等到其他线程给过来的宏任务,它又开始执行任务。

# 宏任务执行例子

上面的文字描述仍然有些生涩,下面来点例子,就很容易理解了。

setTimeout(()=>{ //宏任务2
    console.log(1);
}, 0)
setTimeout(()=>{  //宏任务3
    console.log(2); 
}, 0)
console.log(3);

这样的执行输出顺序是: 3 --> 1 --> 2

  • 浏览器开始执行代码时启动了第一个宏任务(script)并开始执行.
  • 在执行宏任务1途中遇到了第一个定时器,浏览器便会启用一个新线程去跑定时器的逻辑,而当前的js线程不会停直接跳过定时器继续往下执行.当定时器的那条线程跑完后,它的回调函数被添加到js线程的宏任务队列中等待,这就是宏任务2.
  • 而js线程这边又遇到了定时器又开启一条线程跑定时器的逻辑,js线程跳过这段继续往下执行.当定时器线程跑完后,它的回调函数被添加到宏任务队列等待,这就形成了宏任务3,宏任务3排在宏任务2的后面.
  • js线程走到最后输出了3,此时宏任务1就结束了.浏览器此刻就会去宏任务队列中寻找,排在最前面的是宏任务2,执行输出1.宏任务2结束又执行宏任务3输出2.

这里我们来验证下是不是定时器就是等它所在的定时器线程跑完才把回调函数放到js线程的宏任务队列中。这里我们稍微改下代码就可以

setTimeout(()=>{ //定时器1
    console.log(1);
}, 1000)
setTimeout(()=>{  //定时器2
    console.log(2); 
}, 0)
console.log(3);

这时的执行输出顺序是: 3 --> 2 --> 1

  • 这就是浏览器开始执行代码时启动了第一个宏任务(script)并开始执行.
  • 遇到了定时器1 开启了定时器线程去跑,
  • 再遇到定时器2,也放到了定时器线程去跑,
  • 由于定时器2的时间间隔比定时器1的短,它先跑完了,就把它的回调函数放到js线程的宏任务队列中
  • 定时器1要等一秒后才跑完,才把它的回调函数放到js线程的宏任务队列中
  • 定时器2的回到函数先放入js线程的宏任务队列中,定时器1后放,所以打印的结果,就是 3 --> 2 --> 1

这里给大家一个小练习

setTimeout(() => { //定时器1
    console.log(1);
}, 1000)
setTimeout(() => {  //定时器2
    console.log(2);
}, 0)
setInterval(() => { //定时器3
    console.log(3); 
}, 500);
console.log(4); 

上面代码的执行输出顺序是什么呢?

聪明的你们,已经理解了,setTimeout setTnterVal都是在定时器的线程,所以他们其实也是一样道理的,你们自己东西打印输出看看。

执行顺序是: 4 --> 2 --> 3 --> 1 -->> 3(一直打印)

因为宏任务234都是在一个线程跑,他们的时间间隔不一样,返回的回调函数放到js线程的时间就有了先后不一样了。

下面在加上ajax, 也都是一样道理的

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <script src="https://cdn.staticfile.org/jquery/1.10.2/jquery.min.js"></script>
    <script>
        $(function(){
            $.ajax({
                url: 'http://baidu.com',
                success(res){
                    console.log('success', res);
                },
                error(err){
                    console.log('err', err);
                },
                complete(res){
                    console.log('complete', res);
                }
            });
            setTimeout(() => {
                console.log('1');
            }, 0);
            console.log('2');
        });
       
    </script>
</body>
</html>

上面代码执行输出顺序是: 2 --> 1 --> ajax的回调

  • 浏览器开始执行代码时启动了第一个宏任务(script)并开始执行.
  • 遇到了ajax就开启了http 请求线程,然后等待服务器处理后,触发回调函数在放到js线程的宏任务队列中
  • 遇到定时器,开启定时器线程,一样等待定时器跑完之后把回调函数放到js线程的宏任务队列中
  • 遇到console.log('2')直接输出
  • 最后哪个线程先触发回调函数,就先放入js宏任务队列中,js线程的事件循环(EventLoop)一直在循环着等待任务的处理

上面都是介绍了宏任务,下面我们再加入微任务看看它们的执行顺序

# 微任务执行例子

微任务是宏任务的组成部分,微任务与宏任务是包含关系,并非前后并列.如果要谈微任务,需要指出它属于哪个宏任务才有意义.

setTimeout(() => { //定时器1
    console.log(1);
}, 0)
new Promise((resolve) => {
    resolve();
}).then(() => { // 宏任务1中的微任务1
    console.log(2)
})
console.log(3); 

上面输出的是: 3 --> 2 --> 1

  • 浏览器运行启动宏任务1
  • 遇到setTimeout定时器,就放到宏任务队列中,
  • 碰到Promise,将then的回调函数放入宏任务1的微任务队列中等待,线程继续往下.
  • 代码跑到最后一行输出3.此时同步代码执行完毕,开始检查当前宏任务中的微任务队列.
  • 运行微任务队列中的第一个then回调函数输出2.再检查微任务队列,没有发现其他任务.
  • 微任务队列执行完毕,就去宏任务队列,看到定时器1就拿出来执行输出1.

如果对EventLoop还不是很了解的,下面上一张图

EventLoop

从上图可知,EventLoop(事件循环)机制,把宏任务形成了一个拥有先后顺序的宏任务队列.每个宏任务中分为同步代码微任务队列.

  • 假设js当前的线程执行宏任务1,先执行宏任务1中的同步代码.
  • 如果碰到Promise或者process.nextTick,就把它们的回调放入当前宏任务1的微任务队列中.
  • 如果碰到setTimeout, setInterval之类就会另外开启线程去跑相应的逻辑,而js线程跳过这段继续往下执行.另起的线程执行完毕后再在当前宏任务1的队列后面创建新的宏任务并将定时器的回调函数放入其中宏任务队列中.
  • 同步代码执行完,开始执行当前宏任务的微任务队列,直到微任务队列的所有任务都执行完.
  • 微任务队列的所有任务执行完毕,宏任务1再看没有其他代码了,当前的宏任务循环结束后,js线程开始执行下一个宏任务,直到所有宏任务执行完毕.如此整体便构成了事件循环(EventLoop)机制.

# 扩展延伸

# dom操作属于宏任务还是微任务、

 console.log(1);
 document.getElementById("div").style.color = "red";
 console.log(2);

在实践中发现,当上面代码执行到第三行时,控制台输出了1并且页面已经完成了重绘,div的颜色变成了红色.

dom操作它既不是宏任务也不是微任务,它应该归于同步执行的范畴.

# requestAnimationFrame属于宏任务还是微任务

setTimeout(() => {
    console.log(performance.now(), 'settimeout')
}, 0);
requestAnimationFrame(() => {
    console.log(performance.now(),'requestAnimationFrame')
});
console.log(performance.now(), 'global');

使用 performance.now() 精确计算程序执行时间

上面的代码多次运行后会出现了两种结果

第一种: EventLoop

第二种: EventLoop

起初我将requestAnimationFrame归到宏任务中,原因是它绝大多数都会在setTimeout回调执行之后才执行。并将这个结果解释为是由于浏览器在执行渲染的时候,每次执行的时间会有差异,所以导致requestAnimationFrame和setTimeout被压入回调回来的时机不一致,也就导致了回调的时间不一致。

但这种强行解释还是站不住脚,嘿嘿,我等作为一名立志成为优秀 Programer 的有志青年,肯定还是需要找找论据。 ----____是南风

后来在查了一些资料,在看了这篇规范文档 (opens new window)后,发现在一个事件循环的宏任务中是包含浏览器渲染过程的,而requestAnimationFrame的触发是在浏览器重绘之前,MDN文档介绍如下:

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行

所以 requestAnimationFrame 它既不能算宏任务,也并非是微任务.它的执行时机是在当前宏任务范围内,执行完同步代码和微任务队列后再执行.它仍然属于宏任务范围内,但是是在微任务队列执行完毕后才执行.

# Promise的运行机制

包裹函数是同步代码

new Promise((resolve)=>{
    console.log(1);
	resolve();
  }).then(()=>{
    console.log(2);
 })

new Promise里面的包裹的函数,也就是输出1的那段代码是同步执行的.而then包裹的函数才会被加载到微任务队列中等待执行.

在平时开发中,在Promise链中通常会返回一个新的Promise做异步操作返回相应的值.如下.

new Promise((resolve)=>{
    console.log(1)
	resolve();
}).then(()=>{
     return new Promise((resolve)=>{
       resolve(2)
     })
}).then((n)=>{
    console.log(n);
})

执行结果: 1 -- 2

# Promise链条如果没有return

new Promise((resolve)=>{
    console.log(1)
	resolve();
}).then(()=>{
    console.log(2);
}).then(()=>{
    console.log(3);
}).then(()=>{
    console.log(4);
})

执行结果: 1 -- 2 -- 3 -- 4

但上述代码中,then函数的回调里没有返回任何东西.但是后续then包含的回调函数仍然会依次执行,返回 1 -- 2 -- 3 -- 4.并且它可以在末尾无限接then函数,这些函数也都会依次执行.

# 多个then函数执行次序

new Promise((resolve)=>{   // 1
    console.log("a")       // 2         
	resolve();             // 3
}).then(()=>{              // 4
    console.log("b");      // 5
}).then(()=>{              // 6
    console.log("c");      // 7
})                         // 8
console.log("d")           // 9

执行结果: a -- d -- b -- c

  • 1,2,3行为同步执行的代码,一气呵成输出 a.
  • 此时线程走到第4行碰到then函数的回调,将其放入微任务的队列等待.
  • 线程继续往后走直接跳到了第9行输出了 d,为什么会忽略第6行的then直接跳到第9行呢?因为第4行的then函数回调执行完毕后才会开始执行第6行的代码.(如果不理解为什么此刻会忽略掉第6行代码可以查阅一下函数柯里化的概念).
  • 同步代码执行完毕,开始执行微任务队列.此时微任务队列里面只包含了一个then的回调函数,执行输出b.
  • 4,5行执行完毕后,开始执行第6行代码.发现了then函数回调,将其放入微任务队列中.此时第一个微任务执行完了,将其清空. 微任务队列中还有一个刚放进去的微任务,执行输出 c.清除此微任务,至此微任务队列为空,全部任务执行完毕.

最后给大家来一道比较难的面试题,搞定下面的面试题,这个事件循环机制,宏任务,和微任务都不成问题了

new Promise(resolve => {                  // 1
  setTimeout(()=>{                        // 2
      console.log(666);                   // 3
      new Promise(resolve => {            // 4
        resolve();                        // 5      
      })                                  // 6       
      .then(() => {console.log(777);})    // 7
  })                                      // 8       
  resolve();                              // 9
 })                                       // 10
 .then(() => {                            // 11
	     new Promise(resolve => {         // 12
	       resolve();                     // 13
	     })                               // 14
	     .then(() => {console.log(111);}) // 15
	     .then(() => {console.log(222);});// 16
 })                                       // 17
 .then(() => {                            // 18
	     new Promise((resolve) => {       // 19
	       resolve()                      // 20
	     })                               // 21
	    .then(() => {                     // 22
		     new Promise((resolve) => {   // 23
		       resolve()                  // 24
		     })                           // 25
		    .then(() => {console.log(444)})// 26
	     })                                // 27
	    .then(() => {                      // 28
	       console.log(555);               // 29
	    })                                 // 30
})                                         // 31
.then(() => {                              // 32
  console.log(333);                        // 33
})                                         // 34

大家先自己想想,做做看,先不急看答案

  • 线程执行第一行代码,同步执行Promise包裹的函数.
  • 在第二行发现定时器,启动一个宏任务,将定时器的回调放入宏任务队列等待,线程直接跳到第9行执行
  • 第9行执行完开始执行第11行代码发现then函数,放入当前微任务队列中.线程往后再没有可以执行的代码了,于是开始执行微任务队列.
  • 执行微任务队列进入第12行代码,运行到第15行代码时发现then函数放入微任务队列等待.随后线程直接跳到第18行,碰到then函数放到微队列中.后续没有可执行的代码了,再开始执行微任务队列的第一个任务也就是第15行代码输出111.
  • 15行执行完执行到16行碰到then回调放入微任务队列等待.随后线程跳到18行的微任务开始执行,一直执行到22行碰到then函数又放入微任务队列等待.此时线程继续往下跳到第32行碰到then函数放入微任务队列等待.后续没有可执行的代码了,再开始执行微任务队列的第一个任务.
  • 线程跳到第16行执行微任务输出 222,随后又跳到22行执行下一个微任务,在26行处碰到then函数放入微任务队列等待.线程继续执行下一个微任务跳到32行输出 333.至此这一轮的三个微任务全部执行完毕清空,又开始执行微任务队列的第一个任务,线程跳到第26行输出 444.
  • 线程执行到28行碰到then函数回调放入微任务队列等待.后续没有可执行的代码了,再开始执行微任务队列的第一个任务即29行代码输出 555.
  • 所有微任务执行完毕,当前宏任务结束.线程开始执行下一个宏任务,线程跳到第三行输出 666.
  • 线程继续往后第7行碰到then回调放入微任务队列,后续没有可执行的代码了,再开始执行微任务队列的第一个任务输出 777.第二个宏任务执行完毕.

综上所述:输出分别为 111 -- 222 -- 333 -- 444 -- 555 -- 666 -- 777

大家第一次看,可能还有有点懵,正常,多看几次, 自己设置断点跑几次, 这里的promise的链条then,主要是要等前面的then函数执行后,后面的then才能执行,就是柯里化函数,如果了解了柯里化函数是什么,就能很好的理解了。

参考文献:

从Promise链理解Event-Loop (opens new window)

今天,我明白了JS事件循环机制 (opens new window)

彻底明白 JS 线程 (opens new window)

浏览器多线程和js单线程 (opens new window)

JavaScript的宏任务与微任务 (opens new window)

更新时间: 2021年2月6日星期六晚上7点30分