JavaScript进阶之浏览器Event-Loop
浏览器除了JS引擎(JS执行线程,后面我们只关注JS引擎中的执行栈)以外,还有Web APIs(浏览器提供的接口,这是在JS引擎以外的)线程(如下表)
JS引擎在执行过程中,如果遇到相关的事件(DOM操作、鼠标点击事件、滚轮事件、AJAX请求、setTimeout等),并不会因此阻塞,它会将这些事件移交给Web APIs线程处理,而自己则接着往下执行。
Web APIs线程
前面我们说到过JavaScript虽然是单线程的,但是不代表JavaScript执行过程中只有一个线程参与,而是会有其它线程参与该过程,但是永远只有JS引擎线程在执行JS脚本程序,其他的线程只协助,不参与代码解析与执行。参与JavaScript执行过程的线程分别是:
JavaScript是单线程的,这一点确实没错,但是代码执行是在浏览器中进行,浏览器提供了其它Web APIs线程来协助JS执行引擎,那么它们是如何协助JS执行引擎的呢❓
举个例子🌰:
console.log(“script start”)
setTimeout(function(){
console.log(“setTimeout”)},
100)
console.log(“script end”)
复制代码
如上图所示,JS引擎开始执行代码,将console.log(“script start”)推入到执行栈中,执行console.log(“script start”)结束,继续执行setTimeout,发现是个异步操作,这时候JS执行引擎也将该延时事件发放给Web API处理(如果该延迟事件结束,将回调函数推进任务队列中),JS执行引擎继续执行代码,遇到console.log(“script end”),推进执行栈执行,这时候代码都执行完毕,JS引擎处于空闲状态,这时候就会去任务队列去拿setTimeout的回调事件,推进执行栈执行
输出:
script start
script end
setTimeout
复制代码
总结一下
JS引擎只要遇到异步函数,则会交给Web APIs线程处理
Web APIs线程处理完毕以后,就会往队列放入该事件回调函数
事件循环
上面👆的例子其实就是JavaScript的异步执行机制,也就是事件循环,接下来我们来好好聊聊它吧
JavaScript是单线程的,为了避免代码解析阻塞使用了异步执行,那么它的异步执行机制是怎么样的?
答案就是事件循环,JavaScript的异步执行机制就是事件循环,来看MDN的介绍
MDN:JavaScript有一个基于事件循环的并发模型,事件循环负责执行代码、收集和处理事件以及执行队列中的子任务。这个模型与其它语言中的模型截然不同,比如 C 和 Java。
事件循环可以理解成由三部分组成(如下图),分别是:
主线程执行栈
异步任务等待触发
任务队列
主线程执行栈
JavaScript代码在执行时,会进入一个执行上下文中。执行上下文可以理解为当前代码的运行环境,JavaScript引擎会以栈的方式处理这些环境
执行栈在我们分析JavaScript进阶之执行上下文章节就仔细分析过了,这里不再赘述
异步任务等待触发
这里的异步任务等待触发也就是我们的Web APIs组成部分
JS线程负责处理JS代码,当遇到一些异步操作的时候,则将这些异步事件移交给Web APIs 处理,自己则继续往下执行
Web APIs线程将接收到的事件按照一定规则按顺序添加到任务队列中
任务队列
首先在谈任务队列之前,我们先来认识队列这种数据结构,它的特点就是先入先出
🤔队列的实现不在我们这章节的讨论范围内,这里给出代码,感兴趣的同学可以尝试一下
class MyCircularQueue {
constructor(k) {
this.capacity = k + 1
this.arr = new Array(this.capacity)
this.front = 0
this.rear = 0
}
isEmpty() {
return this.front === this.rear
}
isFull() {
return (this.rear + 1) % this.capacity === this.front
}
enQueue(val) {
if (this.isFull()) {
return false
}
this.arr[this.rear] = val
this.rear = (this.rear + 1) % this.capacity
return true
}
deQueue() {
if (this.isEmpty()) {
return false
}
const value = this.arr[this.front]
this.front = (this.front + 1) % this.capacity
return true
}
Front() {
if (this.isEmpty()) {
return -1
}
return this.arr[this.front]
}
Rear() {
if (this.isEmpty()) {
return -1
}
return this.arr[(this.rear-1+this.capacity)%this.capacity]
}
}
复制代码
❗❗️️❗️事件循环中,可以有一个或者多个任务队列(task queue),一个任务队列便是一系列有序任务(task)的集合;每个任务都有一个任务源(task source),源自同一个任务源的 task 必须放到同一个任务队列,从不同源来的则被添加到不同队列。 setTimeout/Promise 等API便是任务源,而进入任务队列的是他们指定的具体执行任务(回调函数)。
宏任务(macrotask )
有哪些是宏任务源❓如下图所示:
规范在Generic task sources中有提及:
DOM操作任务源:此任务源被用来相应dom操作,例如一个元素以非阻塞的方式插入文档。
用户交互任务源:此任务源用于对用户交互作出反应,例如键盘或鼠标输入。响应用户操作的事件(例如click)必须使用宏任务队列。
网络任务源:网络任务源被用来响应网络活动。
history traversal任务源:当调用history.back()等类似的api时,将任务插进宏任务队列。
宏任务源非常宽泛,比如ajax的onload,click事件,基本上我们经常绑定的各种事件都是宏任务源源,还有数据库操作(IndexedDB),需要注意的是setTimeout、setInterval、setImmediate也是宏任务源。总结来说宏任务源:
微任务
microtask(又称为微任务),可以理解是在当前macrotask 执行结束后立即执行的任务。也就是说,在当前macrotask任务后,下一个macrotask之前,在渲染之前。
有哪些是微任务源❓如下图所示:
事件循环的处理流程:
在事件循环中,每进行一次循环操作称为tick,每一次 tick 的任务处理模型是比较复杂的,但关键步骤如下:
执行一个宏任务(栈中没有就从事件队列中获取)
执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
当前微任务队列中的所有微任务执行完毕,开始检查渲染,然后GUI线程接管渲染
渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取),如此不断的重复循环
任务的优先级❓
❗️宏任务和微任务是没有优先级比较的(包括我,以前也认为微任务优先于宏任务执行,实际这是错误❌的),判断代码执行顺序,你应该按照事件循环的处理流程去判断
举个例子🌰:
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’);
复制代码
执行过程如下:
开始进入执行阶段,当JS引擎主线程执行到console.log(‘script start’);,JS引擎主线程认为该任务是同步任务,所以立刻执行输出script start,然后继续向下执行;
JS引擎主线程执行到setTimeout(function() { console.log(‘setTimeout’); }, 0);,JS引擎主线程认为setTimeout是异步任务API,则交给Web Apis线程进行计时和控制该setTimeout任务。由于W3C在HTML标准中规定setTimeout低于4ms的时间间隔算为4ms,那么当计时到4ms时,定时器线程就把该回调处理函数推进``任务队列中等待主线程执行`,然后JS引擎主线程继续向下执行
JS引擎主线程执行到Promise.resolve().then(function() { console.log(‘promise1’); }).then(function() { console.log(‘promise2’); });,JS引擎主线程认为Promise是一个微任务,这把该任务划分为微任务,等待执行
JS引擎主线程执行到console.log(‘script end’);,JS引擎主线程认为该任务是同步任务,所以立刻执行输出script end
主线程上的宏任务执行完毕,则开始检测是否存在可执行的微任务,检测到一个Promise.resolve().then微任务,那么立刻执行,输出promise1和promise2
微任务执行完毕,主线程开始读取任务队列中的事件任务setTimeout,推入主线程形成新宏任务,然后在主线程中执行,输出setTimeout
输出:
script start
script end
promise1
promise2
setTimeout
复制代码
上述👆去判断代码执行顺序并没有去比较(认为)任务的优先级,而是以事件循环处理流程去判断输出顺序
小试牛刀
setTimeout设置为0
面试官会问:为什么setTimeout设置为0不立即执行❓
console.log(“script start”);
setTimeout(function(){
console.log(‘setimeout 0’)},
0);
console.log(“script end”);
复制代码
头条面试题
这是一道头条的面试题
async function async1(){
console.log(“async1 start”)
await async2()
console.log(“async1 end”)
}
async function async2(){
console.log(‘async2 start’)
return new Promise((resolve,reject)=>{
resolve()
console.log(‘async2 promise’)
})
}
console.log(‘script start’)
setTimeout(function(){
console.log(‘setTimeout’)
},0)
async1()
new Promise(function(resolve){
console.log(“promsie1”)
resolve()
}).then(function(){
console.log(“promise2”)
}).then(function(){
console.log(“promise3”)
})
console.log(“script end”)
复制代码输出:
script start
async1 start
async2 start
async2 promise
promsie1
script end
promise2
promise3
setTimeout
复制代码
我理解的答案是这样的,但实际可能会受环境的影响,目前最新的谷歌浏览器输出的顺序是promise2在script end之前
⚠️好了,这也是一道考题,简单理解下就行,这道题实际的难度并非是事件循环的知识点,难点在于题目混入了async await Promsie,如果没有很好理解这些知识点,即使明白了事件循环,也不一定能做对