JavaScript重难点突破:事件循环
想了解事件循环,首先要了解js中线程的概念。
宿主环境
在浏览器环境中,js实际上包含了三个部分ECMAScript、DOM(文档对象模型)、BOM(浏览器对象模型),我们最熟悉的js代码指的是ECMAScript这一语言标准。
有了语言就要有运行环境,Java中完成这一点的是Java虚拟机,而js中,完成这一点的则是浏览器,因此我们将浏览器称为js的宿主环境,同样的,在Nodejs中,Nodejs是js的宿主环境。
宿主环境的线程
有了这样的概念,我们再回来看js的线程。
总所周知,js是单线程的,这句话的意思是js只有同时处理一件事的能力,在实际体现中,就是js是按顺序一行行执行代码的。
那么setTimeout()
和Promise.prototype.then()
这些方法是怎么起效的呢?他们为什么能在主线程运行的时候独立执行完成并返回结果呢?
这是因为宿主环境是多线程的,在宿主环境中,js的主线程只是它众多线程之一,宿主环境还有许多其他线程,setTimeOut
就是由浏览器的其他线程完成的。
浏览器是一个多进程的环境,每一个tab页都是一个独立的进程,每一个进程中含有多个线程:GUI 渲染线程(负责渲染页面,解析 HTML,CSS 构成 DOM 树)、JS 引擎线程、事件触发线程、定时器触发线程、http 请求线程等主要线程。
宏任务与微任务
js中将不同的任务队列分为宏任务和微任务两种,其中宏任务是交由宿主环境执行的,微任务是交由js主线程执行的。
常见的宏任务有
-
setTimeout()
-
setInterval()
-
setImmediate()
常见的微任务有
-
Promise.then()
-
async/await
-
process.nextTick()(nodejs)
js中的执行栈
JS 在解析一段代码时,会将同步代码按顺序排在某个地方,即执行栈,然后依次执行里面的函数。当遇到异步任务时就交给其他线程处理,待当前执行栈所有同步代码执行完成后,会从一个队列中去取出已完成的异步任务的回调加入执行栈继续执行,遇到异步任务时又交给其他线程,…,如此循环往复。而其他异步任务完成后,将回调放入任务队列中待执行栈来取出执行。
这里选择一张别人的解释图,没有找到原作者。
JS 按顺序执行执行栈中的方法,每次执行一个方法时,会为这个方法生成独有的执行环境(上下文 context),待这个方法执行完成后,销毁当前的执行环境,并从栈中弹出此方法(即消费完成),然后继续下一个方法。
事件循环的过程中,执行栈在同步代码执行完成后,优先检查微任务队列是否有任务需要执行,如果没有,再去宏任务队列检查是否有任务执行,如此往复。微任务一般在当前循环就会优先执行,而宏任务会等到下一次循环,因此,微任务一般比宏任务先执行,并且微任务队列只有一个,宏任务队列可能有多个。另外我们常见的点击和键盘等事件也属于宏任务。
执行顺序
在script标签中的代码也属于宏任务,但是由于它最先被浏览器的js主线程执行,我们将其称为同步代码块。
这很好理解,宏任务的定义就是交由宿主环境执行的任务,但是必须有一个js代码被浏览器执行了才会有后续的情况发生,因此这个第一个被执行的js代码自然有其特殊性,它是一切js的开端,其他的宏任务或者微任务都是由它启动的。
在js中,同步代码块,宏任务,微任务的执行顺序是这样的:
-
执行同步代码块
-
遇到宏任务或者微任务 => 放进宏任务或者微任务队列
-
执行后续的同步代码块
-
同步代码块执行完毕
-
将微任务队列中的任务按照先进先出的顺序依次处理
-
将宏任务队列中的任务按照先进先出的顺序依次处理
尝试做一下以下的题目(注意new Promise中的代码也属于同步代码块,只有.then()中的代码才属于微任务)
const p = new Promise((resolve,reject) => {
console.log('第一步')
setTimeout(() => {
console.log('第六步')
})
resolve('第四步');
console.log('第二步')
})
p.then(()=> {
console.log(p)
})
p.then(()=> {
console.log('第五步')
})
p.then(()=> {
setTimeout(()=> {console.log('第七步')})
})
console.log('第三步');
结果是按照顺序输出。
事件循环
之所以称之为事件循环,是因为它经常按照类似如下的方式被实现:
while (queue.waitForMessage()) {
queue.processNextMessage();
}
queue.waitForMessage()
会同步地等待消息到达 (如果当前没有任何消息等待被处理)。
定时器误差
事件循环中,总是先执行同步代码后,才会去任务队列中取出异步回调来执行。当执行 setTimeout 时,浏览器启动新的线程去计时,计时结束后触发定时器事件将回调存入宏任务队列,等待 JS 主线程来取出执行。如果这时主线程还在执行同步任务的过程中,那么此时的宏任务就只有先挂起,这就造成了计时器不准确的问题。同步代码耗时越长,计时器的误差就越大。不仅同步代码,由于微任务会优先执行,所以微任务也会影响计时,假设同步代码中有一个死循环或者微任务中递归不断在启动其他微任务,那么宏任务里面的代码可能永远得不到执行。所以主线程代码的执行效率提升是一件很重要的事情。