本节我们来介绍JS中的事件循环!
要了解事件循环,我们从以下三个问题出发:
- 为什么需要事件循环/事件循环产生的原因?
- 事件循环是什么/事件循环是如何工作的?
- 如何更好地利用事件循环?
为什么要采用黄金圈法则来学习事件循环呢?
实际上我已经查找资料学习过两次了,但是依然觉得没有学明白,知识零散繁杂不成体系,尽管可以解决那些笔试面试问题,但是我觉得远远不够。如果你也有这样的感觉。希望这篇文章能够帮助到你。(以下所有论述均以浏览器作为执行环境)
为什么需要事件循环?
首先我们知道JavaScript是单线程的,依靠函数调用栈同步执行代码,可是浏览器并不是单线程的,需要多线程共同协作异步执行,浏览器即需要进行UI渲染,又需要发送Http请求,还有很多其他事情需要做,这些事情每一件都是一个线程负责完成的。同时浏览器为我们提供了很多API,这些浏览器为我们提供的API如何调度执行以及浏览器内部一系列处理流程如何执行就变得至关重要,他们需要一个统一的策略/方案来管理/调度,这就是为什么我们需要事件循环的原因。
浏览器中的线程包括:UI渲染线程,JS引擎(Chrome中的V8),定时器线程,http线程,I/O事件...
JavaScript是一个单线程的语言,其代码的执行依靠的是函数调用栈,试想如下示例:
js
setTimeout(()=>{
//do A
},10000)
// do B
如果setTimeout
是一个同步执行的代码,当setTimeout
入栈了,阻塞了后续代码10s,10s后处理A,紧接着再处理B,那这10s就卡在这里吗,其他事情都不做?显然这是不行的!
但是我们有了事件循环,也带了问题:异步的代码何时执行,如何执行,怎样才能让其按照我们需要的逻辑执行?在我们学习完事件循环(管理调度)是什么以及如何利用后就知道了!
事件循环是什么/如何工作?
在搞清楚事件循环是什么/如何工作之前,需要搞清楚两个概念:宏任务、微任务
宏任务和微任务都是异步任务
- 宏任务:我们前面说的浏览器中除了JS引擎外的其他线程,所提供给JS引擎的API(Web API)部分都是宏任务
script脚本
setTimeout、setInterval
I/O(交互)事件
- ...
- 微任务:起初并没有微任务这个概念,当JavaScript引入了Promise后,就有了微任务,特别注意微任务是Javascript引入的,而不是执行环境(浏览器等...)引入的
Promise.then,Promise.catch
MutationObserver IntersectionObserver
- ...
下面就来说说事件循环:
- 清空函数调用栈 :首先利用函数调用栈同步执行同步代码(在JS中,例如
setTimeout setInterval
这些函数实际上也可以理解为同步代码,他们的功能是将一个回调函数注册为一个宏任务)直到函数调用栈清空,并将遇到的所有宏任务和微任务分别推入宏任务队列和微任务队列; - 出队一个
老的
宏任务执行:在宏任务队列中出队队首宏任务(这个宏任务不应该是本次事件循环中入队的宏任务),将其放入函数调用栈中执行,将其产生的异步任务/宏任务推入对应队列中; - 清空微任务队列:将微任务队列中的所有微任务逐个出队放入函数调用栈并执行,如果在微任务执行过程中产生了新的微任务,同样入队到微任务队列,并稍后执行;
- UI渲染、循环步骤2
注意 :步骤二中所提到的出队执行一个老的宏任务如何理解:
js
setTimeout(()=>{
new Promise((resolve)=>{
resolve(2);
}).then(res=>{
console.log(res);
new Promise((resolve)=>{
console.log(3);
resolve(4)
}).then(res=>{
console.log(res)
})
})
},0)
console.log(1)
模拟:
- 第一次事件循环:
- 1:清空函数调用栈
- setTimeout执行,将其回调函数推入宏任务队列
- 输出1
- 2:出队一个老的宏任务执行
- 当前宏任务队列中存在一个宏任务,但是该宏任务不是之前事件循环中入对的,而是本次的,因此不执行
- 3:清空微任务队列
- 微任务队列为空
- 1:清空函数调用栈
- 第二次事件循环:
- 2:出队一个老的宏任务执行
- 此时setTimeout的回调是一个老的宏任务,将其放入函数调用栈中执行
- new Promise的executor是同步代码,执行了resolve后,将then()回调入队微任务队列
- 3:清空微任务队列
- 微任务队列中只存在一个then回调,将其出队放入执行栈执行,输出2
- new Promise的executor是同步代码,输出3,执行resolve后,将then回调推入微任务队列
- 此时微任务队列又存在一个新的then回调,将其出队并执行,输出4
- 2:出队一个老的宏任务执行
一次事件循环只会处理一个宏任务,而一次事件循环会清空所有产生的微任务;
如何更好地利用事件循环?
我们已经知晓了事件循环的运行流程了,那么在实际开发中,我们会遇到很多异步场景,但是大部分情况我们需要的是一个同步逻辑,那么应该如何让异步代码逻辑上同步呢?
解决方案:
- Promise
- async/await
那么问题就在于如何使用Promise、async/await?我们要掌握他们!
首先是Promise,Promise的引入极大简化了我们的心智负担,解决了回调地狱的问题。(tips:微任务和Promise都是JS引入的,并不是我们之前所说的拥有一个自己的线程)
Promise接收第一个参数是executor回调,他会在该Promise实例化时执行,是同步代码!
Promise存在then/catch方法,then/catch的回调是异步任务,只有当executor执行resolve/reject
时(Promise的状态被settle)才会将then/catch的回调推入微任务队列。
那我们应该如何利用Promise呢?如下示例:我们将resolve/reject放入宏任务中执行,这样宏任务后续的代码将在宏任务完成后执行(宏任务执行完毕后再执行微任务),这样就达到了我们的目的,包含异步任务的代码逻辑同步化了。 示例:
js
//模拟请求
function getData(){
return new Promise((resolve)=>{
//模拟数据响应
setTimeout(()=>{
resolve([1,2,3])
},100)
})
}
function calculateData(){
getData().then(res=>{
console.log(res);
})
}
calculateData()
而async/await实际上是Promise的语法糖,我们要知道async/await的概念之外,还需要明确其语义;
async/await的产生的目的是为了让代码更加易读,尽管Promise解决了回调地狱的问题,但是过多的then也会造成代码的繁琐。我们将上述代码改写为等价的async/await写法:
js
function getData() {
return new Promise((resolve) => {
setTimeout(() => {
resolve([1, 2, 3])
}, 100)
})
}
async function calculateData() {
const res = await getData();
console.log(res)
}
calculateData();
-
async语义:被标记的函数的返回值将被Promise包裹,如果返回的是Promise则不包裹
-
await语义:指等待一个函数的执行,该函数应该是一个返回Promise的函数,await后的代码将可视为then方法的回调函数
总结
- 为什么需要事件循环/事件循环产生的原因?
- 浏览器中不仅仅是执行JS代码一项工作,有很多线程需要异步处理各种任务,但是JS代码又需要一些API来处理某些任务,因此一个统一调度任务的策略十分重要,这个策略就是事件循环。
- 事件循环是什么/事件循环是如何工作的?
- 事件循环:清空函数调用栈(同步代码)--> 选择老的宏任务执行 --> 清空所有微任务 --> UI渲染、重复宏任务到微任务
- 如何更好地利用事件循环?
- 使用Promise,将宏任务在Promise的executor中(同步部分)执行,并在宏任务中调用resolve/reject;当事件循环到这个宏任务被执行时,宏任务中的resolve/reject会将then/catch推入微任务队列,紧接着就会去执行这些微任务了。利用事件循环将异步代码的逻辑同步化。