系列文章:
前言
上篇文章分析了Promise的重要性以及使用上容易犯错的点,本篇将重点分析EventLoop/宏任务/微任务在浏览器和Node.js里的表现。
通过本篇文章,你将了解到:
- JS引擎与运行时
- 浏览器里的进程和线程
- JS单线程如何运作
- 宏任务/微任务–相得益彰
- 宏任务/微任务易混点实战
- 一些未解的讨论
1. JS引擎与运行时
1.1 JS 引擎
1.1.1 简单概念
JavaScript 是门语言,使用JS编写的源代码是程序员可以看懂的,而机器(CPU)最终识别的是机器码,因此需要一个工具将源代码转为机器码,这个工具称为JS引擎。
1.1.2 常见的JS引擎
Chrome V8:
Google 使用C++开发的高效、开源的JS引擎,目前主要用在Node.js和Chrominum(Chrome开源版本)系列的浏览器上。
Chakra:
微软开发开源的JS引擎,目前主要用在Internet Explorer和Microsoft Edge浏览器上。
JavaScriptCore:
苹果开发,主要用在Safari浏览器上。
SpiderMonkey:
以前用在Netscape Navigator上,现在用在Firefox上。
还有一些其他的JS引擎,如:Rhino、KJS、Nashorn等。
1.2 JS运行时
有了JS引擎就可以执行JS代码了,但这还不够,单纯的JS代码并不能满足我们的业务需求,比如网络请求,I/O操作等,于是需要一套环境将JS引擎和相关的API整合起来。
我们知道JS是单线程语言,而网络请求是耗时的任务,JS是怎么处理这些耗时任务的呢?不可能干等着每个耗时任务的完成吧?怎么做呢?
答案是异步任务,当异步任务处理完成后,JS引擎线程再执行异步任务的回调函数,因此这套环境还需要一个机制来处理不同的异步任务的回调,异步任务的回调将会被放入回调队列里等待合适的时机执行,如下图:
通过EventLoop(事件循环)处理回调队列里的函数,从而使得JS能够操作异步任务。
JS运行时也称JS环境、JS宿主(中文资料通常称它)。
常见的JS宿主有哪些呢:
- 各类的浏览器(如Chrome、Safari、Edge)
- 用于服务端的Node.js
1.3 JS/H5 规范
ECMAScript是JavaScript的标准,比如规定了变量、函数等的写法以及一些语言上的特性。通常说的某某JS引擎支持ECMAScript5.1、ES6、ES7等,指的是该引擎实现了ECMAScript标准哪个版本的规范。
ECMAScript
HTML5的标准是由WHTWG和W3C共同制定(HTML5)
2. 浏览器里的进程和线程
2.1 单进程时代
上面只是宏观说明了宿主实现的一些功能,本节从微观的角度(进程/线程)说明这些功能是怎么分布的。
早期的浏览器是单进程实现的,该进程里包含了一些线程。
单进程优势:
没有进程间通信,比较简单。
劣势:
- 不稳定,比如插件崩了就会影响浏览器的其它功能
- 不流畅,单个页面线程里承载了太多功能,影响渲染效率
2.2 多进程时代
后面浏览器进化到了多进程时代:
多进程的劣势:
占用更多的系统资源(内存、CPU等)
多进程的优势:
- 稳定,如插件在单独的进程,因为进程之间是隔离的,即使插件进程有问题也不会影响浏览器的核心功能
- 每个Tab新开进程单独处理,充分利用多核CPU并行能力
不是每个应用场景下都严格按照多进程来实现,多进程是可以配置的,比如在嵌入式设备上的浏览器就不会有那么多的进程,毕竟资源有限,可以配置为共享进程。
3. JS单线程如何运作
3.1 看病的Demo
3.1.1 同步任务
上面说的点貌似和题目不符呢?其实以上内容仅仅只是打下基础,我们需要有个印象哪些功能是哪个模块实现的,是同一线程还是不同的线程运行的。
在讲EventLoop之前先看个小Demo:
我们将门诊医生比作JS引擎线程,单个医生同一时间只能为一个病患看病,没轮到的病患需要排队。
正常的就诊过程是这样的流程:
- 病患自诉病情,医生边问边记录
- 医生最终给出结论,开药或做其它项检测
3.1.2 异步任务
若每位病患都是:"陈述病情–>开药"流程,就诊的效率就会很高(JS主线程执行同步任务),然而实际场景大都是"陈述病情–>设备检测–>分析报告–>开药"流程。
以病患1为例说明整个流程:
- 病患1:医生我最近经常感觉到头痛
- 医生:我给你开个单,你去拍个CT看看,叫号:下一位进来
- 病患2:医生我拉肚子了,喷射了好几天都不停歇…
- 医生:有可能是肠胃感染了,我给你开个单,你先去抽个血,看看是不是细菌感染还是病毒感染,叫号:下一位进来
- 医生:继续处理后续的病患,叫号:下一位…下一位…
- 病患1:医生CT报告出来了,帮忙看看呢
- 医生:片子没看出问题,我给你开几副安神补脑的药,回去多休息,如果还有不适再来就诊,叫号:下一位
- 病患2:医生我化验单出来了,帮我看看呢
- 医生:你这是细菌感染,我先给你开止泻的药,平时多注意个人卫生,饭前便后要洗手…
由上片段可以看出当前的就诊医生并没有直接给病患进行CT或抽血,他如果做了,那么后续等待的病患就不能及时得到就诊,影响了整个就诊的效率,因此此时选择让另一位医生(另一个线程)进行检测,检测的过程类比JS代码里的异步任务,当检测结果出来后重新找医生看报告(异步任务结果回调)。
通过与其他同事(线程)配合,就诊医生能够高效处理病患的需求。
当然啦,如果我们将医生比作线程,医院比作进程,那么上述的就诊过程均发生在同一家医院(同一进程),若是该医院的CT设备不够先进,无法做相应的检查,那么医生可能建议去其它医院(其它进程)做,拿到检验结果后回来再找他看(不同医院传输报告,类似于不同进程间的通信-IPC)。
- 因此,JS里的异步任务有可能是不同线程处理,也有可能是不同的进程处理,不过最终的处理结果都在JS引擎线程处理
- 异步任务的设计使得单线程能够处理更多的事情
3.2 EventLoop–动静结合
3.2.1 怎么动
有了异步任务,还需要解决哪个任务先执行、它什么时候执行,这些策略都是由EventLoop统筹管理。
EventLoop顾名思义:事件循环,事件指的是任务,任务从队列里来(这里队列不一定是Queue,有可能是其它如Set等),循序指的是不断地从队列里取出任务进行执行。
你可能看到关于EventLoop的其它论述:
EventLoop检测Call Stack(JS Engine里) 是否为空,若为空则从Callback Queue里取出任务塞到Call Stack里,若不为空则等待Callback为空
如果觉得以上论述不好理解,可以这么理解:
EventLoop是单线程运行,该线程循环不断地处理队列里的任务。
3.2.2 怎么静
如果队列里没有了任务怎么办?当前线程会挂起(不占用CPU),当有新的任务到来时将会唤醒当前挂起的线程,线程被唤醒之后就继续干活(取任务执行)。
3.2.3 其它的EventLoop
不仅JS里有事件循环,其它语言里也有,比如Android里的Looper,再比如iOS里的RunLoop,都是单线程(UI线程/主线程),实现的思路都是类似的。
4. 宏任务/微任务-相得益彰
4.1 宏任务
4.1.1 setTimeout 为例
EventLoop 不同的宿主有不同的实现,以浏览器里的EventLoop为例,EventLoop里的任务主要分为宏(MacroTask)任务和微(MicroTask)任务。
先看代码:
function sayHello() {
console.log('hello world')
}
sayHello()
这里并没有异步任务,整个同步的代码被当做一个宏任务。
加个异步任务:
function sayHello() {
console.log('hello world')
setTimeout(()=>{
console.log('hello world2')
}, 2000)
}
sayHello()
总共涉及到了两个宏任务:
第一个是同步的宏任务,它里面产生了一个异步的宏任务,当然在这个宏任务里再开启另一个宏任务:
function sayHello() {
console.log('hello world')
setTimeout(()=>{
console.log('hello world2')
setTimeout(()=>{
console.log('hello world3')
})
}, 2000)
}
sayHello()
最终按顺序输出:hello world–>hello world2–>hello world3
setTimeout并不是JS Engine实现的,而是由宿主实现的,它可能的实现方式:
- setTimeout调用后将回调加入到延迟队列,JS引擎线程不断地检测延迟队列里的任务是否到了可执行时间,若是则执行该任务(setTimeout的回调函数)
- setTimeout调用后由定时器线程负责计时,当时间到达后定时器线程往EventLoop队列里加入任务(setTimeout回调函数),等待JS引擎线程执行
可以看出,不论哪种实现方式,setTimeout回调函数执行时间均受JS引擎线程执行时长的影响,如:
function sayHello() {
let starTime = Date.now()
console.log('hello world')
setTimeout(()=>{
console.log('hello world2 interval:', Date.now() - starTime)
}, 2000)
}
sayHello()
延时2s打印,此时打印出来的interval刚好就是2s(误差在毫秒级),说明setTimeout还是比较符合预期。
若此时在setTimeout之后做一些耗时的动作:
function sayHello() {
let starTime = Date.now()
console.log('hello world')
setTimeout(()=>{
console.log('hello world2 interval:', Date.now() - starTime)
}, 2000)
for(let i = 0; i < 1e10; i++){}
}
sayHello()
添加了个空循环,最终打印出来的interval是11s。
由此说明了两个问题:
- JS代码执行是单线程(JS引擎线程)
- 异步任务的回调在JS引擎线程里执行
因此为了让JS引擎线程在单位时间里能够处理更多的宏任务,需要降低每个宏任务的耗时。
4.1.2 其它的宏任务
除了setTimeout,还有其它的宏任务:
- setInterval、setImmediate(Node.js)事件
- 用户交互(鼠标悬停、点击等事件)
- I/O(网络和文件事件)
- 页面渲染相关事件
4.2 微任务
4.2.1 Promise为例
还是上面医生的例子,每位病患当做宏任务,当病患1的CT结果出来后交给医生查看,病患1的CT片子好几个,医生要多花时间分析。
当医生正在查看病患1的片子时,其他病患说你们怎么看了那么久?剩下的片子能不能待会再看呢,先给我看看吧?
此种场景下医生当然不能答应了,毕竟他要一起看完了所有片子才能下结论,不能半途而废,不然又要重新来看,于是医院就规定:
其他病患需要等待医生看完当前病患的所有片子
我们把病患当做宏任务,那么把医生看病患的片子当做微任务,在微任务未执行完成之前,其它宏任务是不能执行的。
EventLoop是宿主实现的,微任务则是JS Engine实现的,微任务典型的代表:Promise。
先看Demo:
function sayHello() {
new Promise((resolve, reject) => {
console.log('before resolve')//同步执行
resolve('hello world')
}).then(value => {
console.log(value)//异步执行
})
console.log('sayHello end')//同步执行
}
sayHello()
当第一个宏任务执行结束(sayHello end)后,Promise.then回调执行。
需要注意的是:此时的’before resolve’是同步执行的。
JS Engine维护了微任务队列,每当一个宏任务执行完毕后就会检查微任务队列是否有任务,如有依次拿出来执行,等到微任务队列清空后再执行下一个宏任务。
那Promise什么时候加入到微任务队列呢?
Promise.then()/Promise.catch()/await Promise执行的时候并没有把回调加入到微任务队列,而是当Promise状态由pending变为fullfilled或rejected时才加入的微任务队列
function sayHello() {
new Promise((resolve, reject) => {
console.log('before resolve')//同步执行
setTimeout(()=>{
resolve('hello world')
})
}).then((value)=>{
console.log(value)
})
new Promise((resolve, reject)=>{
resolve('hello world2')
}).then(value => {
console.log(value)
})
console.log('sayHello end')//同步执行
}
sayHello()
如上,尽管第一个Promise的then()先执行,但是却是第二个Promise的then()回调先执行。
4.2.2 其它的微任务
除了Promise.then Promise.catch Promise.finally await,还有其它微任务,如:
MutationObserver、process.nextTick(Node.js)、queueMicrotask
4.3 微任务/宏任务结合
如果存在第二个宏任务,看看和微任务执行时序:
function sayHello() {
//添加一个宏任务
setTimeout(()=>{
console.log('timeout')
})
new Promise((resolve, reject) => {
console.log('before resolve')//同步执行
resolve('hello world')
}).then(value => {
console.log(value)//异步执行
})
console.log('sayHello end')//同步执行
}
sayHello()
可以看出,'hello world’先于’setTimeout’打印,虽然书写顺序上setTimeout在前,Promise在后,但是第一个宏任务结束后会先去寻找微任务执行,执行完所有微任务后再执行下个宏任务。
再来看多个宏任务+多个微任务的场景:
function sayHello() {
//添加一个宏任务
setTimeout(()=>{
console.log('timeout')
new Promise((resolve, reject) => {
resolve('hello world3')
}).then(value => {
console.log('第三个微任务回调', value)//异步执行
})
})
new Promise((resolve, reject) => {
console.log('before resolve')//同步执行
resolve('hello world')
}).then(value => {
console.log('第一个微任务回调', value)//异步执行
return 'hello world2'
}).then(value => {
console.log('第二个微任务回调', value)//异步执行
})
console.log('sayHello end')//同步执行
}
sayHello()
第一个宏任务关联了两个微任务(第一个微任务和第二个微任务),第二个宏任务关联了一个微任务(第三个微任务),最终打印结果:
before resolve
sayHello end
第一个微任务回调 hello world
第二个微任务回调 hello world2
timeout
第三个微任务回调 hello world3
最后看看浏览器里的EventLoop+宏任务+微任务+渲染的流程。
- 先执行宏任务
- 宏任务执行完后查看微任务队列,若有微任务则执行
- 微任务执行完后查看是否需要渲染,若需要则进行渲染
- 从宏任务队列里找出宏任务,重复1~3
注:以上步骤均在JS引擎线程执行。
宏任务里可以发起微任务,也可以发起宏任务,微任务里可以发起微任务,也可以发起宏任务。
5. 宏任务/微任务易混点实战
5.1 Promise里executor同步执行
new Promise((resolve, reject)=>{
console.log(1)
resolve(3)
}).then(value => {
console.log(value)
})
console.log(2)
打印结果:1 2 3
console.log(1)是executor里的代码,它是同步执行的
5.2 async 函数是同步执行
function buildPromise() {
return Promise.resolve('1')
}
async function test() {
console.log(2)
let value = await buildPromise()
console.log(value)
console.log(3)
}
console.log(4)
test()
console.log(5)
打印结果:4 2 5 1 3
test函数里在没有遇到await 前是同步执行的,因此2在5之前打印
5.3 await 函数是异步执行的
使用5.2的例子,1、3在5之后打印,await 等待Promise完成返回
5.4 宏/微互相嵌套
先执行完微任务再执行宏任务。
new Promise((resolve, reject)=>{
console.log(1)
setTimeout(()=>{
Promise.resolve(2).then(value => {
console.log(value)
})
})
new Promise((resolve, reject)=>{
console.log(5)
setTimeout(()=>{
resolve(6)
})
}).then(value => {
console.log(value)
})
})
setTimeout(()=>{
Promise.resolve(3).then(value => {
console.log(value)
})
setTimeout(()=>{
console.log(7)
})
})
console.log(4)
打印结果:1 5 4 2 6 3 7
6. 一些未解的讨论
6.1 执行时序不一样
有可能你执行上述的Demo结果和文中描述的不一致,或者在实际使用过程中发现和网上说的不一致,不要担心。
EventLoop是宿主实现的,不同的宿主,比如Chrome和Node.js实现方式会有差异(现在两者在SetTimeout和Promise处理时序都趋同了),同一宿主的不同版本都可能有差异,当遇到差异时对比不同的实现方式,找到差异原因进行控制处理。
6.2 只有一个宏任务队列吗?
有些说宏任务队列有多个,不同的宏任务队列的优先级不一样,比如在浏览器里同样是宏任务,渲染任务的优先级高于定时器任务。
你觉得呢?
本篇简单梳理了一些前端的基础知识,我们下篇来整整装饰器,让你的代码"装起来",敬请关注~