说到事件循环就不可避免的会谈到到任务队列,宏任务,微任务等等这些名词。那么问题来了,设计事件循环系统是为了解决什么问题,有了宏任务为什么还要有微任务??
单线程的JavaScript和多进程的浏览器
JavaScript这个语言在设计之初就是单线程,原因当然不是当初多核CPU还不够普及。作为主战场在浏览器的脚本语言,JavaScript的主要用途是与用户交互相关,以及操作DOM。这个场景决定了单线程比多线程更合适。比如同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程的操作结果为准?
随着多核CPU的普及以及JavaScript的应用场景的转换,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以新标准并没有改变JavaScript单线程的本质。
虽然JavaScript是单线程的,但是具有复杂功能的现代浏览器却是多线程的。
Chrome浏览器包括:1个浏览器(Browser)主进程、1个 GPU 进程、1个网络(NetWork)进程、多个渲染进程和多个插件进程
- 主进程: 界面显示、用户交互、子进程管理,同时提供存储等功能
- 插件进程:每个启动的插件都会创建一个进程
- GPU进程:最初GPU是为了实现3D效果,后来Chrome选择采用GPU来绘制UI界面
- 网络进程:主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。
- 渲染进程:也是所谓的浏览器内核,核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎Blink和JavaScript引擎V8都是运行在该进程中,默认情况下,Chrome会为每个Tab标签创建一个渲染进程。
任务队列和事件循环
首先JavaScript的使用场景决定了它是单线程的。现在假定JavaScript引擎在执行script代码时,用户点击了页面上某个按钮,如果这个时候JavaScript引擎还在执行代码,那么只能由浏览器起一个线程来记录这个点击,等JavaScript引擎忙完了再来告诉它某个时候鼠标在某个坐标点击了一个按钮,需要你去执行按钮注册的回调函数。
再进一步升级,如果有期间有多个事件发生了,那么需要一个队列来存储所有的事件,按照先来后到的顺序依次被执行。为了让这个事情更加的高效,事件循环系统就应运而生了,JavaScript引擎不断的从调用栈中取出任务执行,事件循环系统一次次的检查调用栈是否为空。如果调用栈为空,那么就会从任务队列中取出最早的一个任务来加入到调用栈中。
宏任务和微任务
上面提到的任务队列就是宏任务队列,里面都是宏任务。可能看起来宏任务和事件循环已经可以满足需求了,那为什么还要有微任务和微任务队列。首先看看哪些任务属于宏任务哪些又属于微任务
- 宏任务(macrotask)
- 渲染事件(如解析 DOM、计算布局、绘制)
- JavaScript 脚本执行事件
- 用户交互事件(如鼠标点击、滚动页面、放大缩小等)
- 网络请求完成(XMLHttpRequest)、文件读写完成(I/O)
- 定时器任务(setTimeout,setInterval)
- 微任务(microtask)
- MutationObserver
- Promise
宏任务可以满足大多数对时效要求不高的需求。一个最简单的例子setTimeout回调函数的执行时机问题,定时器触发线程只能保证在指定间隔时间之后,将回调函数加入到宏任务队列的队尾。因为每轮事件循环只有一个宏任务被执行,如果队列中已经有了很多任务,那么必定会影响后添加的宏任务的执行时机。所以类似setTimeout这些宏任务时间粒度比较粗,并不能精准的控制执行时机。
主流浏览器中目前还在用的产生微任务的两种方法。1,使用MutationObserver来观察DOM变化,当被监控的DOM属性,子节点,文本发生变化时,指定的回调函数便作为一个微任务添加到微任务队列。2,Promise.resolve()和Promise.reject()时,Promise的状态由pending变化为resolved或者rejectd之后,相应的回调函数也作为一个微任务添加到微任务队列。
一轮完整的事件循环
1,在JavaScript引擎解析DOM并绘制页面之后,执行script脚本
2,控制台打印script start script end
3,如果点击了按钮1,按钮1的click回调函数添加到宏任务队列
4,调用栈为空,将回调函数从宏任务队列取出到调用栈中执行
5,控制台打印firstBtn click,Promise回调函数添加到微任务队列,定时器回调添加到宏任务队列
6,调用栈为空,依次执行微任务,直至清空微任务队列,控制台打印Promise3 callback,然后浏览器可以选择是否重新渲染页面
7,定时器触发线程经过100ms后触发,根据id将对应的定时器回调函数添加到宏任务队列中
8,调用栈为空,将定时器回调函数从宏任务队列取到调用栈中执行,控制台打印setTimeout1,宏任务和微任务队列都为空。
9,如果此时点击了按钮2,同样会将按钮2点击事件回调函数加入到宏任务队列
10,控制台打印secondBtn click,Promise回调添加到微任务队列,定时器回调添加到宏任务队列,然后执行for循环中的DOM修改操作
11,100次for循环执行完,调用栈为空,MutationObserver因为监听到DOM修改,回调函数作为微任务添加到微任务队列
12,控制台打印Promise4 callback,mutations callback(仅打印一次),setTimeout2
这个打印顺序(mutations callback 在 setTimeout2之前)也验证了MutationObserver回调函数是个微任务。而且可以注意到for循环100次中每次都修改了DOM属性,但是MutationObserver仅触发了一次,也验证了MutationObserver回调函数是异步执行的,最后只统一执行一次,这也是MutationObserver 比MutationEvent性能更佳的原因
如果想让mutations callback打印100次该怎么做呢?
for(let i=0;i<100;i++){
setTimeout(function(){
el.style.color = i % 2 == 0 ? "blue" : "red";
},0)
}
参考文档
JavaScript忍者秘籍(第2版)
浏览器工作原理
In The Loop Jake Archibald@JSconf 2018
Philip Roberts- What the heck is the event loop anyway? | JSConf EU 2014