这是我参与8月更文挑战的第7天,活动详情查看:8月更文挑战(已经更文7天)
前言
面试中遇到的高频问题当然少不了浏览器的事件循环了,什么是浏览器的事件循环?什么是微任务宏任务?为什么要区分微任务宏任务?给你一段代码让你写出执行的先后顺序等。如果你不懂或者一知半解,这篇文章带你捋清浏览器的事件循环。
为什么有事件循环?
众所周知,JavaScript是一门单线程的解释型脚本语言,而单线程也意味着JS代码在执行的时候,只有一个主线程来处理所有的任务。
那么为什么JS在设计之初不像Java一样设计成多线程呢?因为JS的定位是与浏览器做交互,它是一门脚本语言。如果它是多线程的,试想一下,如果在同一时刻,有一个线程操作一个DOM,改变它的样式,而另一个线程也在操作这个DOM,执行删除操作,那么浏览器听谁的呢?
而单线程有没有弊端呢?那肯定是有的。我们再试想一下,在主线程中有两个操作A和B,A先执行,B后执行。A如果需要耗时0.2s,那必然是没什么问题的,那如果A要耗时20s、200s、2min呢?那B操作就会一直处于一个等待的状态,那这种结果必然是无法接受的。正是为了解决单线程会造成的阻塞问题,浏览器引入了事件循环机制。
什么是事件循环?
JS的任务分为同步任务和异步任务。
- 同步任务: 立即执行的任务,在主线程上排队执行,前一个任务执行完毕,才能执行后一个任务。主要有:Promise(then/catch/finally等)、Object.obsever、MutationObsever等。
- 异步任务: 异步执行的任务,不进入主线程,而是在异步任务有了结果后,将注册的回调函数放入
任务队列中等待主线程空闲的时候读取执行。主要有:setTimeout、setInterval、I/O、UI渲染等
而同步任务都在主线程中执行,构成了一个执行栈。而除了主线程的执行栈之外,还存在一个任务队列,用来存放异步任务执行完毕后注册的回调函数。
执行栈执行同步任务,在遇到异步任务时,会将异步任务进行异步处理并且跳过,把所有同步任务执行完成后,再执行异步处理结束后推入任务队列的异步任务回调函数。而这个过程是循环不断进行的,这就被称为事件循环。
举个栗子:
console.log("script start");
setTimeout(() => {
console.log("我是异步任务");
}, 100);
console.log("script end");
上下两个console打印是同步任务,所以会先执行,而setTimeout是一个异步任务,所以首先会跳过它,等它的异步处理完后,会把回调放入任务队列中,主线程再执行这个回调。
打印结果:
那么,异步任务又都有哪些呢?具体的异步处理又是什么呢?
微任务和宏任务
异步任务也是有区别的,它分为微任务(MicroTask)和宏任务(MacroTask),他们执行的优先级也是有区别的。
当执行一段代码时,会先执行同步任务,当遇到异步任务时,区分它是
宏任务还是微任务。如果是
宏任务,则会把此宏任务放入一个消息队列中挂起,继续往下执行。如果是
微任务,则会把此微任务放入全局执行上下文中的一个微任务队列中,继续往下执行。而等到同步任务都执行完毕后,会先去
微任务队列中读取微任务执行,等清空了微任务队列后,再去执行消息队列里的宏任务,进入下一次事件循环。
流程图:
每个事件循环,当执行栈中的同步任务都已经执行完毕后,会去检查微任务队列,如果其中有待执行的微任务,则会按照先进先出的方式依次执行微任务。等到微任务队列被清空后,才会去消息队列中读取宏任务,进入下一次事件循环。(微任务的优先级是高于宏任务的)
为什么要区分微任务和宏任务?
试想一下,假如不区分微任务或者宏任务,只有一个广义上的异步任务会怎么样?
假如你有一个异步任务A,其内部还有一个异步任务B。当同步任务都执行完后,开始调用异步任务A,发现它其中还有一个异步任务B。这时候因为不区分异步任务,所以又把异步任务B给挂起了,等下一次事件循环再去调用它。这显然是不合理的,因为可能后面的状态需要用到异步任务B,你的目的就是在这一次事件循环中都执行完这两个异步任务。
举个具体的生活中的例子,你去食堂排队打饭,把你自己当作一个异步任务。好不容易排队到你了,你打完了菜,需要刷卡付钱。而刷卡付钱这个操作也是一个异步任务,这时候如果不区分微任务或者宏任务,你就需要再回过头去排队,等到你再次排到了才能付钱(当然不能白嫖了,所以点个赞再走吧~)。这显然是不合理的。
所以,我们需要区分微任务或者宏任务,形成一个具有优先级的关系,就是为了方便插队。在一次事件循环中,先执行优先级较高的异步任务(插队),再去执行优先级较低的异步任务。
来个栗子
现在我们了解了 浏览器的事件循环机制 ,我们就动手来看个面试题试试。
我们来看看它执行完后输出什么:
console.log("script start");
new Promise((res) => {
console.log(1);
res();
}).then(() => {
console.log("微任务1");
});
const p = new Promise((res) => {
console.log(9);
res();
}).then(() => {
console.log("微任务2");
});
async function foo() {
console.log("async start");
await p;
console.log("async end");
}
foo();
setTimeout(() => {
console.log("宏任务1");
new Promise((res) => {
console.log(3);
res();
}).then(() => {
console.log("微任务3");
});
}, 101);
setTimeout(() => {
console.log("宏任务2");
new Promise((res) => {
console.log(2);
res();
}).then(() => {
console.log("微任务4");
});
}, 100);
console.log("script end");
- 首先将整体代码作为一个
宏任务执行,同步任务console.log进入执行栈,执行打印操作后弹出执行栈,打印了script start。
- 走到第一个
Promise中,Promise是一个微任务,但是它的构造函数入参是一个同步任务,console.log(1)进入执行栈,输出一个1后弹出执行栈。紧接着调用了resolve函数,而Promise.then是一个微任务,所以它被放入了微任务队列当中,暂时挂起,而后resolve函数弹出执行栈。
// new Promise中是一个同步任务
new Promise((res) => {
console.log(1);
res();
})
- 走到第二个
Promise中,跟前一个Promise一样先打印一个9,再调用resolve函数,挂起Promise.then到微任务队列中。
- 执行
foo()函数,它是一个async函数(点这里了解async),先执行console.log打印,走到了await p处之后,就相当于new Promise(()=>{p}),异步转同步,但是并不会发生什么,因为p已经执行过了,但是它会把下面的代码放入new Promise(()=>{p}).then中,导致console.log("async end")也被推入微任务队列中。
- 接着执行
setTimeout,它是一个宏任务,所以将它放入消息队列中挂起。
- 同上,挂起第二个
setTimeout。
- 执行
console.log("script end")。
- 到现在,这段代码的同步任务都已经执行完了,现在
微任务队列中有三个微任务挂起,消息队列中有两个宏任务挂起。之前提到过,微任务的优先级高于宏任务,所以会先执行微任务队列中的任务,顺序是先进先出(队列),所以依次打印并且清空微任务队列,到这里,第一轮事件循环结束,开启第二轮。
- 在
微任务队列清空后,会去消息队列执行宏任务,而现在有的宏任务是两个setTimeout,该执行哪个呢?是按照队列先进先出的规则先执行101ms的那个吗?不是的,setTimeout会根据时间来执行,谁先到时间先执行谁,比如现在已经到了100ms,它就会先执行100ms的那个,再执行101ms的那个。所以,先进入100ms的setTimeout中,先后打印宏任务2、2,后挂起里面的微任务。
- 接着执行微任务,等清空
微任务队列后,再跟上面一样执行101ms的setTimeout,最终打印结果:
- 我们去浏览器打印看看具体是什么。
结语
浏览器的事件循环机制在面试中是高频的问题,相信这篇文章或多或少会给予您一些帮助,如果看完这篇文章,对您有所帮助的话,还烦请您点个赞,点点关注,祝您生活愉快。