这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战
前言
想要了解事件循环,首先需要知道一个概念,JS是一门单线程语言。为什么?因为JS在设计之初就是一个专门用于编写页面交互语言,而与页面交互则会频繁的进行DOM操作,试想,如果JS是多线程的,一个线程在操作这个DOM节点,而另一个要删除这个节点,此时浏览器应该听谁的?为了避免复杂性,JS从一诞生开始,就是一门单线程语言。
基于这个原因,JS被设计成了一门单线程语言。但单线程也有它的缺点,那就是同一时间只能做一件事情,而我们都知道,发http请求或者各种IO,都可能非常耗时,这带来的问题就是,如果这个任务被卡住了,那后面的代码都得等这个任务完成后才能继续执行,此时页面看起来就像完全停滞了一样,但此时CPU却是空闲的状态,这是何等的浪费性能。并且,互联网的世界里,没有用户会花超过5秒耐心等你,所以我们必须引入一种机制,可以充分的利用资源,优化执行逻辑。
事件循环,就是这么诞生的。
模型
这是一张经典的事件循环模型,我们可以看到该模型由几个核心部分组成:
- 执行栈(execution context stack)
- JS的内存空间分为栈(stack)、堆(heap)
- 其中栈存放变量,堆存放复杂对象
- LIFO(先进后出)
- webApis(如DOM操作,setTimeout、setInterval、ajax)
- 任务队列(callback queue)
- FIFO(先进先出)
执行栈、调用栈
着两个东西在英文里分别叫做execution context stack和call stack,但我认为在该模型中可以表达同一个意思。我们通过一段简单的代码来熟悉一下。
function c(a, b) {
return a * b;
}
function b(x) {
return c(x);
}
function a(n) {
let res = b(n);
console.log('aaa');
}
a(2);
如果我们打断点执行一下,会发现,此处的调用栈是右下角这个样子的,即a在栈底(忽略anonymout),b在a上,c在b上
这个也很好理解,因为函数的执行肯定都要将内部全部执行完才能算结束,所以会一层一层往里走,如果遇到了一个新的函数,就将它压入栈中。
能解释这个原因的另个现象,就是我们的报错信息,一般我们的报错信息都是,从栈顶到栈底,一排排信息自上而下排列。
让我们把刚才的代码改一下,在c函数中抛出一个错误试试
function c(a, b) {
// return a * b;
throw new Error('xixi');
}
function b(x) {
return c(x);
}
function a(n) {
let res = b(n);
console.log('aaa');
}
a(2);
此时你就会发现,为什么平时报错信息长着样子了,因为报错是从内而外,从栈顶到栈底,不断传递的。
好的,了解了基本的call stack的概念,我们就可以继续往下了
任务队列
任务队列也可以称为消息队列、事件队列,队列中的事件包含:IO操作、Ajax请求、用户事件(点击、按键、滚动等)等,只要指定了回调函数,这些事件就会进入到任务队列中,当执行栈运行完成后,便会执行任务队列中的任务。
同步任务与异步任务
在该模型中,所有任务被分为两种,一种是同步任务,一种是异步任务。
同步任务(synchronous)
在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行下一个任务。
异步任务(asynchronous)
不进入主线程、而进入"任务队列"(callback queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
每当运行开始时,引擎会将所有同步任务放入到执行栈(execution context stack)中,把所有异步任务放入任务队列中(callback queue),JS的引擎会不断检查主线程的执行栈是否为空,一旦执行栈将所有同步任务执行完成,系统就会查看任务队列中是否有异步任务,如果有,则将它放入执行栈中执行。同步任务总是会在异步任务之前执行。
上述过程会不断重复,于是就形成了事件循环。
宏任务与微任务
任务队列中的,异步任务又被分为宏任务(macro-task)和微任务(micro-task),在最新标准中,它们被称为task与jobs,分别位于task queue和job queue中。
宏任务(task)
- script
- setTimeout
- setInterval
- requestAnimationFrame
- IO
- UI rendering
微任务(job)
- promise.then catch finally
- async await
- MutationObserver
- process.nextTick
在每一次循环中,他们的执行顺序是这样的:
- 从task queue中拿出队列第一个task执行
- 执行完这一个task后,查看job queue
- 执行job queue中所有可执行的任务
- 完成后,本轮循环结束
值得注意的是,会有以下的情况发生:
- 在执行task的时候,有可能会有新的任务产生,有可能是task,也有可能是job
- 如果是job,直接追加到job queue中
- 如果是task,直接追加到task queue中
- 当执行完毕后,如果job queue有内容,则在本轮中执行完job queue中所有job,而新追加的task,则全都放到下一轮执行
- 需要记得,script也是一个宏任务
- await下面的代码,都会被当成job
示例
console.log(1);
setTimeout(() => {
console.log(2)
}, 100);
console.log(3);
我们尝试一下将代码套入我们的模版
- 执行script
- 遇到console.log(1),为一个同步任务,直接放入执行栈运行,输出1
- 遇到setTimeout,为一个异步的宏任务,开始计时并等待放入task queue中运行
- 遇到console.log(3),同步,直接放入执行栈执行,输出3
- 同步任务执行完毕,task queue中的任务放入执行栈中并执行,输出2
最后,代码的输出顺序为 1、3、2。
刚刚那个太简单,没啥难度,再来个稍微复杂点的
console.log(1);
new Promise((resolve) => {
console.log(2);
resolve(3);
setTimeout(() => {
console.log(4);
}, 0);
}).then(data => {
console.log(data);
})
setTimeout(() => {
console.log(5);
}, 0);
console.log(6);
- 执行script
- 遇到console.log(1),放入执行栈,执行,输出1
- 遇到promise,此时要注意,new Promise的时候,产生的代码为同步的,因为此处内部的代码是会被立即执行的,所以直接输出2
- 随后遇到第一个setTimeout,虽然是在new Promise的内部,但是它是一个宏任务(task),因此开始计时,并等待放入task queue中执行
- 遇到then函数,这也是一个微任务(job),放入job queue中,等待执行
- 遇到第二个setTimeout,开始计时并等待放入task queue中执行
- 遇到console.log(6),直接输出
- 本轮task执行完毕,开始执行job queue中的所有job
- 先执行then函数,输出3
- job全部执行完毕,开启下一轮
- 开始执行task,执行第一个setTimeout,输出4
- 检查一下job queue,无,开启下一轮
- 执行第二个setTimeout(此时已经在队列的头部),输出5 所以最后的输出顺序为,1、2、6、3、4、5
来一个更有挑战的
async function async1(){
console.log('async1 start') // 2
await async2()
console.log('async1 end') // 6
}
async function async2(){
console.log('async2') // 3
}
console.log('script start') // 1
setTimeout(function(){
console.log('setTimeout') // 8
},0)
async1();
new Promise(function(resolve){
console.log('promise1') // 4
resolve();
}).then(function(){
console.log('promise2') // 7
})
console.log('script end') // 5
- 执行script
- 遇到同步的script start,输出
- 遇到setTimeout,放入task queue
- 遇到async1(),执行后,输出async1 start
- 遇到async2(),输出async2
- await之后的代码都当成job,放入job queue中
- 遇到new Promise,执行,输出同步的promise1
- then放入job queue中
- 输出script end
- 到目前为止,我们已经输出了script start、async1 start、async2、promise1、script end
- 本轮宏任务执行完毕,开始执行job queue中的所有job
- 输出async1 end
- 输出promise2
- 本轮结束
- 下一轮开始,执行第一个task,输出setTimeout
所以最后的输出顺序为:
- script start
- async1 start
- async2
- promise1
- script end
- async1 end
- promise2
- setTimeout
最后我们来看一个比较抽象的
Promise.resolve().then(()=>{
console.log('Promise1')
setTimeout(()=>{
console.log('setTimeout2')
},0)
})
setTimeout(()=>{
console.log('setTimeout1')
Promise.resolve().then(()=>{
console.log('Promise2')
})
},0)
- 执行宏任务script,没发现任何同步代码
- 遇到第一个then,把它放入job queue
- 遇到一个setTimeout,把它放入task queue
- 本轮结束,第二轮开始,先 job queue 再 task queue
- 输出promise1,遇到一个setTimeout,加入task queue
- 本轮job queue全部清空,执行task queue
- 执行task queue中第一个task,输出setTimeout1
- 遇到一个promise,加入job queue中
- 本轮结束,第三轮开始
- 执行 job queue,输出promise2
- 本轮job queue全部清空,执行task queue
- 输出setTimeout2
- 执行完毕
所以最后的输出顺序是,promise1、setTimeout1、promise2、setTimeout2
Q&A
-
setTimeout能保证时间严格执行吗?如果不,为什么? 不能,setTimeout中的事件只是它的最短执行时间(0除外)。因为setTimeout是一个异步任务,在每个循环中,异步任务是在同步任务之后执行的,如果同步任务需要执行很久很久,那setTimeout也必须一直等待同步任务执行完毕,才能将它放入执行栈中执行。
-
setTimeout设置为0,能解决问题吗? 不能,还是得按照模型来跑,并且根据官方的规定,最小执行事件为4ms。
setTimeout(fn,0)的含义是,指定某个任务在主线程最早可得的空闲时间执行,也就是说,尽可能早得执行。它在”任务队列”的尾部添加一个事件,因此要等到同步任务和”任务队列”现有的事件都处理完,才会得到执行。
小结
事件循环非常绕,很有可能尝试了很多遍还是不理解,没关系,只要坚持总结、思考,总会研究出来的。这篇学习笔记是自己看了许多博客、文档的总结,主要是个人的理解,如果有不正确的地方请及时指出,相互交流
参考文献
JavaScript 运行机制详解:再谈Event Loop
What the hack is the event loop anyway?
Difference between microtask and macrotask within an event loop context