从EventLoop规范探究宏任务与微任务

1,580 阅读11分钟

来源出处

之前在GitHub上看到Yang JingZhuo 的一篇 “从event loop规范探究javaScript异步及浏览器更新渲染时机”的文章,打开了对event loop的新的见解。故在其文章的基础上进行化简和修改得到这份输出笔记。

1. 异步与EventLoop的关系

首先,说起EventLoop(事件轮询机制)我就会想起这幅图。其实也是对的,只是我们之前还没认清EventLoop如何处理事件轮询机制的。内部到底如何处理异步同步等问题,那么这个问题其中就与宏任务微任务有关系了。 js事件响应模型图

EventLoop隐藏得比较深,很多人对它很陌生。但提起异步,相信每个人都知道。异步背后的“靠山”就是EventLoop。这里的异步准确的说应该叫浏览器的EventLoop或者说是javaScript运行环境的EventLoop,因为ECMAScript中没有EventLoop,EventLoop是在HTML Standard定义的。

console.log('start')

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

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

console.log('end')

// start
// end
// promise1
// promise2
// setTimeout

上面的顺序是在chrome运行得出的,有趣的是在safari 9.1.2中测试,promise1 promise2会在setTimeout的后边,而在safari 10.0.1中得到了和chrome一样的结果。为何浏览器有不同的表现,了解tasks, microtasks队列就可以解答这个问题。

2. EventLoop/宏任务/微任务定义

2.1 EventLoop定义

EventLoop翻译出来就是事件循环,可以理解为实现异步的一种方式, 事件,用户交互,脚本,渲染,网络这些都是我们所熟悉的东西,他们都是由EventLoop协调的。触发一个click事件,进行一次ajax请求,背后都有EventLoop在运作。

2.2 task(也被称为macrotask)

一个EventLoop有一个或者多个task队列。

当用户代理安排一个任务,必须将该任务增加到相应的EventLoop的一个task队列中。

每一个task都来源于指定的任务源,比如可以为鼠标、键盘事件提供一个task队列,其他事件又是一个单独的队列。可以为鼠标、键盘事件分配更多的时间,保证交互的流畅。

task也被称为macrotask,task队列还是比较好理解的,就是一个先进先出的队列,由指定的任务源去提供任务。

哪些是task任务源呢

规范在Generic task sources中有提及:

DOM操作任务源
此任务源被用来相应dom操作,例如一个元素以非阻塞的方式插入文档。

用户交互任务源
此任务源用于对用户交互作出反应,例如键盘或鼠标输入。响应用户操作的事件(例如click)必须使用task队列。

网络任务源
网络任务源被用来响应网络活动。

history traversal任务源
当调用history.back()等类似的api时,将任务插进task队列。

task任务源非常宽泛,比如ajax的onload,click事件,基本上我们经常绑定的各种事件都是task任务源,还有数据库操作(IndexedDB ),需要注意的是setTimeout、setInterval、setImmediate也是task任务源。总结来说task任务源(macrotask宏任务)

  • setTimeout
  • setInterval
  • setImmediate(Node独有)
  • I/O
  • UI rendering(浏览器独有)

2.. microtask

每一个EventLoop都有一个microtask队列(这是与macrotask最大的区别),一个microtask会被排进microtask队列而不是task队列。

microtask 队列和task 队列有些相似,都是先进先出的队列,由指定的任务源去提供任务,不同的是一个 EventLoop里只有一个microtask 队列。

HTML Standard没有具体指明哪些是microtask任务源,通常认为是microtask任务源有:

  • process.nextTick(Node独有)
  • promises
  • Object.observe
  • MutationObserver

NOTE:
Promise的定义在 ECMAScript规范而不是在HTML规范中,但是ECMAScript规范中有一个jobs的概念和microtasks很相似。在Promises/A+规范的Notes 3.1中提及了promise的then方法可以采用“宏任务(macro-task)”机制或者“微任务(micro-task)”机制来实现。所以开头提及的promise在不同浏览器的差异正源于此,有的浏览器将then放入了macro-task队列,有的放入了micro-task 队列。在jake的博文Tasks, microtasks, queues and schedules中提及了一个讨论vague mailing list discussions,一个普遍的共识是promises属于microtasks队列。

特别注意:NodeJs的EventLoop的运行机制和浏览器的EventLoop的机制是不太一样的。虽然NodeJS中的JavaScript运行环境也是V8,也是单线程,但是,还是有一些与浏览器中的表现是不一样的。其实nodejs与浏览器的区别,就是nodejs的宏任务分好几种类型,而这好几种又有不同的任务队列,而不同的任务队列又有顺序区别,而微任务是穿插在每一种宏任务之间的。在node环境下,process.nextTick的优先级高于Promise,可以简单理解为在宏任务结束后会先执行微任务队列中的nextTickQueue部分,然后才会执行微任务中的Promise部分。这篇文章只谈我们主要谈浏览器中的EventLoop。

3. EventLoop处理过程

总体来说,EventLoop处理过程概括来说是:

  • EventLoop会不断循环的去取tasks队列的中最老的一个任务推入栈中执行,并在当次循环里依次执行并清空microtask队列里的任务。
  • 执行完microtask队列里的任务,有可能会渲染更新。(浏览器很聪明,在一帧以内的多次dom变动浏览器不会立即响应,而是会积攒变动以最高60HZ的频率更新视图)

3.1 图解宏任务和微任务

宏任务和微任务
过程:

  • 首先执行一个宏任务,执行结束后判断是否存在微任务

  • 有微任务先执行所有的微任务,再渲染,没有微任务则直接渲染(渲染也需要按频率去刷新渲染)

  • 然后再接着执行下一个宏任务

另外几点需注意的是:

  • 浏览器是多进程的,打开一个页面就是一个进程。
  • JS引擎是单线程的。
  • 宏任务/微任务都是在JS引擎这个线程里运作的。浏览器有个叫做GUI渲染线程。这两个线程是互斥的,当JS引擎执行时GUI线程会被挂起,反之也是。
  • GUI渲染线程主要负责:负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等。当我们修改了一些元素的颜色或者背景色,页面就会重绘(Repaint),当我们修改元素的尺寸,页面就会回流(Reflow),当页面需要Repaing和Reflow时GUI线程执行,绘制页面。

3.2 案例说明完整的EventLoop过程

规范晦涩难懂,做一个形象的比喻:
主线程类似一个加工厂,它只有一条流水线,待执行的任务就是流水线上的原料,只有前一个加工完,后一个才能进行。(JavaScript是单线程的,只有一个执行栈去执行代码)EventLoop就是把原料(微任务/宏任务)放上流水线(执行栈)的工人。只要已经放在流水线上的,它们会被依次处理,称为同步任务。一些待处理的原料,工人会按照它们的种类排序,在适当的时机放上流水线,这些称为异步任务。

举个简单的例子,假设一个script标签的代码如下:

<script>
Promise.resolve().then(function promise1 () {
       console.log('111111');
    })
setTimeout(function setTimeout1 (){
    console.log('222222')
    Promise.resolve().then(function  promise2 () {
       console.log('333333');
    })
}, 0)

setTimeout(function setTimeout2 (){
   console.log('444444')
}, 0)
</script>
// 输出结果:111111 -> 222222 -> 333333 -> 444444

运行过程:

script里的代码被列为一个task,放入task队列。

循环1:

【task队列:script ;microtask队列:暂无】
从task队列中取出script任务,推入栈中执行。promise1列为microtask,setTimeout1列为task,setTimeout2列为task。
【task队列:setTimeout1 setTimeout2;microtask队列:promise1】
(一个宏任务执行完,清空其所有的微任务队列)script任务执行完毕,执行microtask checkpoint,取出microtask队列的promise1执行。
循环2

【task队列:setTimeout1 setTimeout2;microtask队列:暂无】
从task队列中取出setTimeout1,推入栈中执行,将promise2列为microtask。
【task队列:setTimeout2;microtask队列:promise2】
(一个宏任务执行完,清空其所有的微任务队列。全局任务本身就是一个宏任务)执行microtask checkpoint,取出microtask队列的promise2执行。
循环3

【task队列:setTimeout2;microtask队列:暂无】
从task队列中取出setTimeout2,推入栈中执行。
setTimeout2任务执行完毕,执行microtask checkpoint。
【task队列:暂无;microtask队列:暂无】

3.3 再看一案例EventLoop执行过程

案例 这个例子分析方法与上面一个一样,主要一点:要把全局的同步代码当做一个宏任务

总结如下:
整体的script(作为第一个宏任务)开始执行的时候。执行完一个宏任务之后,就会清除所有的当前微任务队列。这就是一轮的EventLoop。从而进行重复循环这一操作。

4.宏任务/微任务执行与渲染之间的关系

  • 在一轮event loop中多次修改同一dom,只有最后一次会进行绘制。
  • 渲染更新(Update the rendering)会在event loop中的tasks和microtasks完成后进行,但并不是每轮event loop都会更新渲染,这取决于是否修改了dom和浏览器觉得是否有必要在此时立即将新状态呈现给用户。如果在一帧的时间内(时间并不确定,因为浏览器每秒的帧数总在波动,16.7ms只是估算并不准确)修改了多处dom,浏览器可能将变动积攒起来,只进行一次绘制,这是合理的。
  • 如果希望在每轮event loop都即时呈现变动,可以使用requestAnimationFrame。

下面通过一些案例去说明宏任务/微任务执行与渲染之间的注意点。

4.1 渲染案例一

document.body.style = 'background:black';
document.body.style = 'background:red';
document.body.style = 'background:blue';
document.body.style = 'background:pink';

案例一
我们看到上面动图背景直接渲染了粉红色,根据上文里讲浏览器会先执行完一个宏任务,再执行当前执行栈的所有微任务,然后移交GUI渲染,上面四行代码均属于同一次宏任务,全部执行完才会执行渲染,渲染时GUI线程会将所有UI改动优化合并,所以视觉上,只会看到页面变成粉红色。

4.2 渲染案例二

document.body.style = 'background:blue';
setTimeout(()=>{
    document.body.style = 'background:pink'
},0)

案例二 上述代码中,页面会先卡一下蓝色,再变成粉色背景。
之所以会卡一下蓝色,是因为以上代码属于两次宏任务,第一次宏任务执行的代码是将背景变成蓝色,然后触发渲染,将页面变成蓝色,再触发第二次宏任务将背景变成粉色。

4.3 渲染案例三

document.body.style = 'background:blue'
console.log(1);
Promise.resolve().then(()=>{
    console.log(2);
    document.body.style = 'background:pink'
});
console.log(3);

案例三 控制台输出 1 3 2 。是因为先进行全局代码第一个宏任务,从上至下去解析输出1、注册promise、输出3。但是promise.then是异步的,这时会把其压入微任务队列。再其结束宏任务时,去清除所有微任务队列。再进行一次渲染。所以蓝色是看不到的,直接显示粉色。

4.4 综合案例

function test() {
  console.log(1)
  setTimeout(function() {
    console.log(8)
  }, 1000)
}

test()

setTimeout(function() {
  console.log(5)
})

new Promise(resolve => {
  console.log(2)
  setTimeout(function() {
    console.log(7)
  }, 100)
  resolve()
}).then(function() {
  setTimeout(function() {
    console.log(6)
  }, 0)
  console.log(4)
})

console.log(3)
// 输出结果依次为:1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8
// 不需要解释了吧。

4.5 带async/await的案例

    console.log('1')
    async function async1() {
      console.log('2')
      await async2()
      console.log('3')
    }
    async function async2() {
      console.log('4')
    }

    async1()

    new Promise(function(resolve) {
      console.log('5')
      resolve()
    }).then(function() {
      console.log('6')
    })
    console.log('7')
    // 输出1 -> 2 -> 4 -> 5 -> 7 -> 3 -> 6

上面的主要是对await async2()的理解,见下。

//同步函数
const a = await 'hello world'
// 相当于
const a = await Promise.resolve('hello world');

引擎遇到await关键字后,会将紧跟await后的函数先执行一遍,然后await后面的代码加入到microtask中。所以,上面的函数如下:

async function async1() {
	console.log('async1 start');
	await async2();
	console.log('async1 end');
}
//等价于
async function async1() {
	console.log('async1 start');
	Promise.resolve(async2()).then(() => {
                console.log('async1 end');
        })
}

因为,async/await本质上还是基于Promise的一些封装,而Promise是属于微任务的一种。所以在使用await关键字与Promise.then效果一致。
async函数在await之前的代码都是同步执行的,可以理解为await之前的代码属于new Promise时传入的代码,await之后的所有代码都是在Promise.then中的回调。 async function本质上就是生成器+promise+run模式的语法糖。

参考

一次搞懂JS运行机制
从event loop规范探究javaScript异步及浏览器更新渲染时机
微任务、宏任务与Event-Loop