【第2114期】浏览器环境下的JavaScript Event Loop
前言
老话题了。今日早读文章由作者@周雄投稿分享。
正文从这开始~~
什么是JavaScript的Event Loop?
在JavaScript中有一个很重要的Event Loop概念,从字面意思理解为事件循环,为什么会有Event Loop这个概念呢?我们得先从JavaScript语言的特征说起。
细心的读者可能发现了,文章的标题中我们着重强调了是在浏览器环境,这是为什么呢?
因为JavaScript的Event Loop不仅存在于浏览器环境下,还存在于Node环境下,而且两者还存在一些差别。
本篇文章是基于浏览器环境下对于JavaScript Event Loop的讲解。
JavaScript 的单线程
我们都知道JavaScript语言的一大特点就是单线程,这就意味着在同一个时间,只能做一件事情。
为什么JavaScript不做多线程处理呢?还能更加充分利用CPU呢。
这要从JavaScript的使用场景说起,JavaScript作为浏览器语言,主要用途是通过操作DOM,与用户进行互动。
我们设想一下,如果一个线程去更新某个DOM元素,而另一个线程去删除这个DOM元素,那么浏览器该执行哪个操作呢?
为了避免复杂的多线程机制,JavaScript从设计之初就选择了单线程标准,而且未来也不会发生变化。
你可能会问,Web Worker不是允许JavaScript来创建多线程吗?
这个是没错的,但是要注意的是,Web Worker创建的子线程是不能操作DOM的,操作DOM的任务需要交给主线程来执行,而且子线程完全受到主线程的控制,因此Web Worker的提出也并没有改变JavaScript单线程的本质。
既然JavaScript是单线程执行,那么这种运行机制的优点是什么吗?
最大的优点就是,在单线程中运行代码会相当轻松,因为你不用处理多线程环境中出现的一些复杂情况,比如死锁。
但是单线程同样会带来一些问题。当“执行栈”中需要运行耗费大量时间的函数时,例如DOM,AJAX,setTimeout等,由于单线程的机制,浏览器只能等待耗时代码的完成,而不能去做其它的事情,因为它被阻塞了,站在用户的角度,就是浏览器停止渲染,卡住了。
那么,聪明的JS是如何来解决这个问题的呢?
没错,采用异步回调函数的机制,Event Loop事件循环机制也就应运而生。
Event Loop的作用
在JavaScript引擎的主线程上,会包含一个执行栈,按照栈后进先出的顺序处理栈中的任务。
而在执行栈之外,会独立使用一个容器来专门管理异步状态,这个容器就是task queue (任务队列)。
所有异步操作的回调,都会暂时被塞入这个队列。Event Loop 处在两者之间,扮演一个调度者的角色,它会以一个固定的时间间隔不断轮询,当它发现执行栈空闲,就会去到 Task 队列里拿一个异步回调,把它塞入执行栈中执行,一段时间后,主线程执行完成,弹出上下文环境,再次空闲,Event Loop 又会执行同样的操作依次循环,于是构成了一套完整的事件循环运行机制。
microtask 和 macrotask
在任务队列中维护的任务虽然都会因为Event Loop而得到执行,但是不同类型的任务执行的时机是不一样的。
任务队列中的任务会被分为两种:微任务(microtask)和宏任务(macrotask)。
在这里我们可以简单概括下两种任务包含的基本操作。
· 宏任务包括 script脚本, setTimeout ,setInterval ,setImmediate ,I/O ,UI rendering。
· 微任务包括 process.nextTick,Promise,MutationObserver,注意process.nextTick是在Node环境中具有的
执行顺序
通过下面这张图,我们来看看宏任务和微任务执行的顺序。
配合这张图,我们看看JavaScript代码的执行过程:
首先执行同步代码,即script脚本,这属于宏任务
当执行完所有同步代码后,执行栈清空
从微任务队列中逐个取出回调任务,放入执行栈中执行,直至所有微任务执行完成。注意:如果在执行微任务的过程中,产生了新的微任务,那么这个微任务会加入到队列的末尾,同样会在这个周期内被执行。
当执行完所有微任务后,如果有必要会开始渲染页面
开始下一轮 Event Loop,执行宏任务中的异步代码,也就是 setTimeout 中的回调函数。
实例验证
在学习完相关的理论知识后,我们通过一些代码实例来检验下大家对event loop的理解。
console.log(1);
setTimeout(() => {
console.log(2);
Promise.resolve().then(() => {
console.log(3);
});
});
new Promise((resolve, reject) => {
console.log(4);
resolve(5)
}).then((data) => {
console.log(data);
});
setTimeout(() => {
console.log(6);
});
console.log(7);
首先我们来看看这段代码的输出结果。
1->4->7->5->2->3->6
看看你答对了吗?
然后我们来看看代码的执行流程,包括执行栈Stack Queue,宏任务队列Macrotask Queue和微任务队列Microtask Queue的变化。
1、执行全局的script代码,第1行代码
此时Stack Queue:[console]
Macrotask Queue:[],
Microtask Queue:[]
此阶段输出1。
2、执行到2-7行setTimeout代码,此时会将setTimeout的回调推入Macrotask Queue
此时Stack Queue:[setTimeout本身]
Macrotask Queue:[setTimeout callback],
Microtask Queue:[]
此阶段没有输出。
3、执行8-13行Promise代码,在Promise构造函数内部的代码实际是同步代码,直到遇到resolve或者reject,才会触发异步的回调。
而Promise的异步任务回调会被推入到Microtask Queue。
此时Stack Queue:[Promise内的console],
Macrotask Queue:[setTimeout callback1],
Microtask Queue:[Promise callback]
此阶段输出4。
4、执行14-16行setTimeout代码,与上一个setTimeout一样,会被推入到Macrotask Queue
此时Stack Queue:[setTimeout本身],
Macrotask Queue:[setTimeout callback1,setTimeout callback2],
Microtask Queue:[Promise callback]
此阶段没有输出。
5、执行第17行代码,实际是同步代码,会进入到执行栈中。
此时Stack Queue:[console],
Macrotask Queue:[setTimeout callback1,setTimeout callback2],
Microtask Queue:[Promise callback1]
此阶段输出7。
6、至此第一轮的宏任务执行完成,开始从微任务队列中取出任务执行,直至微任务队列为空。
第10行代码resolve(5)执行后,会进入到then()方法内部,执行第12行代码。
此时Stack Queue:[Promise callback1],
Macrotask Queue:[setTimeout callback1,setTimeout callback2],
Microtask Queue:[]
此阶段输出5。
7、至此微任务队列清空,第一轮微任务也执行完成,开始下一轮event loop
8、执行setTimeout宏任务的回调,并将其推入到执行栈中。
此时Stack Queue:[setTimeout callback1],
Macrotask Queue:[setTimeout callback2],
Microtask Queue:[]
此阶段输出2。
但是在执行到第4行代码时,又生成了一个新的Promise,会将其推入到Microtask Queue中。
Microtask Queue:[promise callback2]。
9、宏任务执行完成后,开始从微任务队列中取出任务执行,即第5行代码
此时Stack Queue:[promise callback2],
Macrotask Queue:[setTimeout callback2],
Microtask Queue:[]
此阶段输出3。
10、第二轮任务轮询结束,开始第三轮event loop
11、执行第二个setTimeout任务回调,即第15行代码。
此时Stack Queue:[setTimeout callback2],
Macrotask Queue:[],
Microtask Queue:[]
此阶段输出6。
12、至此全部执行完成,Stack Queue,Macrotask Queue,Microtask Queue均为空。
关于宏任务和微任务中的setTimeout和Promise,我们再增加一些更加容易让人理解的解释。
setTimeout的作用是等待给定的时间后为它的回调产生一个新的宏任务,这个宏任务将在下一轮循环中执行,而Promise的回调是在当前轮循环中产生,并推入到当前循环的微任务队列中。
这就是为什么打印'2'在'5'之后。因为打印'5'是第一轮微任务里做的事情,而打印'2'是在第二轮的宏任务里做的事情。
通过上面的整体讲述,相信大家对event loop的过程已经有了深入的了解。
下面再给大家留一道题目,看看大家能不能答对,这其中增加了async-await关键字。
console.log('script start');
async function async1() {
await async2();
console.log('async1 end')
}
async function async2() {
console.log('async2 end');
}
async1();
setTimeout(function() {
console.log('setTimeout');
}, 0);
new Promise(resolve => {
console.log('Promise');
resolve();
})
.then(function() {
console.log('promise1');
})
.then(function() {
console.log('promise2');
});
console.log('script end');
在这里也给出参考答案吧,看看你答对了没?
script start -> async2 end -> Promise -> script end -> async1 end-> promise1 -> promise2 -> setTimeout
这里给大家一个提示,await在执行async函数时是会有阻塞性的,所以'async2 end'会出现在'Promise'之前。
为你推荐
欢迎自荐投稿,前端早读课等你来
在《javascript重难点实例精讲》这本书里,都是形如这种方式的内容讲解,不仅包含理论知识的描述,还会通过丰富的实例来做验证,详细讲解各种代码的执行过程,还会适当的留一些题目给你当练习,让你真正从原理上去学会它。