一、前言
JS
作为单线程脚本语言,在面对主线程阻塞时,需要借助事件循环,即 Event Loop
来同时执行其他任务,本文将基于此为大家梳理一下相关的知识点,后面还有一系列的面试题供大家食用~
二、详解事件循环
2.1 JS为何为单线程?
JS
起初是为了在浏览器中运行脚本而设计的,保持简单和易用,并避免多线程带来的复杂性和安全问题。如:两个线程同时对一个DOM对象进行处理。
2.2 什么是执行栈、主线程、任务队列
让我们先初步了解一些概念:
- JS 分为同步任务、异步任务
- JS的任务都在
执行栈
顺序执行 - 执行至同步任务,进入主线程;执行至异步任务,将被加入
任务队列
(Event Queue)中 执行栈
中所有任务执行完毕,系统读取任务队列
,将异步任务的回调事件添加到执行栈
中,如此反复循环
我们借用流程图来进一步理解一下:
接下来咱们趁热打铁,来到题目尝尝:
js
setTimeout(() => {
console.log(0)
}, 0)
const p = new Promise(r => console.log(3))
Promise.resolve(1).then((res) => {
console.log(res)
})
console.log(2)
我们来解析一下流程:
- 第一步:
setTimeout
为异步任务,存入任务队列;p的resolve
执行,输出3;Promise
对象执行then
,为异步任务,存入队列;最后同步任务直接打印2。 - 第二步:此时执行栈为空,检查任务队列,存在
setTimeout
和Promise.then
,此处考查到宏任务和微任务的概念,后面会详细解释,此时Promise.then
作为微任务,回调事件优先于宏任务setTimeout
进入执行栈,顺序执行后分别输出1、0。最后得到输出顺序为:3、2、1、0。
是不是对此有比较明确的认知了?那接下来填一下刚刚题目里埋的坑吧!
2.3 宏任务、微任务
2.3.1 宏任务
其实每一个 script
脚本都是一个宏任务(即执行栈),每一个宏任务都会从头到尾执行完成,不会执行别的任务。因此,JS脚本线程
和 GUI渲染线程
是一个互斥的关系,为此浏览器在每一次宏任务执行完成后,执行GUI渲染,渲染完成后再进行下一轮宏任务,如此反复......
以下是常见的宏任务:
- script
- setTimeout、setInterval
- I/O 操作(例如读取文件、发送请求)
- UI 渲染(绘制、重布局等)
举个🌰,开发者讲某dom元素的宽度初始值为50px,通过点击事件将宽度修改为100px,同时写一个 setTimeout
将宽度修改为150px,该dom元素会在点击后宽度变为100px,并在规定时间后宽度变为150px。
2.3.2 微任务
事实上任务队列分为:宏任务队列、微任务队列 微任务队列用于处理 Promis
的回调函数、async/await
、MutationObserver
等产生的微任务。当执行栈中的任务为空时,会优先检查微任务队列,并依次执行队列中的所有微任务。 在每个宏任务执行结束后,会检查是否存在微任务队列,并在宏任务下一个周期执行之前执行微任务队列中的所有微任务。这样的机制保证了微任务的优先级更高,可以在 UI 渲染之前执行,使页面得到更新。
我们再回到上一个🌰,如果我们把 setTimeout
改为 Promise.then
,那么dom元素在点击后会直接变为150px(因为微任务执行在GUI渲染之前)
与此同时,前面 setTimeout
和 Promise.then
的输出顺序在这里也得到了很好的解释。
以防读者还有不清晰的地方,我总结了一个流程图给大家过目:
三、面试题实战
既然都清楚了Event Loop的机制,接下来就来点题目尝尝吧~ (持续更新中) 在此之前,如果大家对Promise理解还不够深入,可以看看这篇文章详解Promise ------ 手撕源码
3.1 基础
js
setTimeout(()=>{
console.log(0)
},0)
new Promise((resolve)=>{
console.log(1)
resolve()
}).then(()=>{
console.log(2)
}).then(()=>{
console.log(3)
})
console.log(4)
这里我分析一下执行过程:
- 第一步:主线程输出1、4,任务队列存入sT、Promise.then
- 第二步:存在微任务,输出2、3
- 第三步:执行宏任务,输出0
- 输出:1、4、2、3、0
是不是很easy,那我们再来点难度吧👊
3.2 深入微任务
js
new Promise((resolve,reject)=>{
console.log("p1-0")
resolve()
}).then(()=>{
console.log("p1-1")
new Promise((resolve,reject)=>{
console.log("p2-0")
resolve()
}).then(()=>{
console.log("p2-1")
}).then(()=>{
console.log("p2-2")
})
}).then(()=>{
console.log("p1-2")
})
老样子:
- 第一步:Promise1输出p1-0,Promise1.then进入微任务队列
- 第二步:执行Promise1.then输出p1-1,读取到Promise2,输出p2-0,Promise2.then进入微任务队列;Promise1.then读取完毕,Promise1.then.then进入微任务队列,此时微任务队列的顺序为:[Promise2.then,Promise1.then.then]
- 第三步:执行栈为空,执行微任务队列,输出p2-1,Promise2.then.then进入微任务队列,输出p1-2,此时微任务队列的顺序为:[Promise2.then.then]
- 第四步:执行栈为空,执行微任务队列,输出p2-2
- 输出:p1-0 p1-1 p2-0 p2-1 p1-2 p2-2
这一题清晰的体现了微任务的执行方式,接下来我们结合一下宏任务做一道题~
3.3 结合宏任务
js
new Promise((resolve, reject) => {
console.log("p1-0")
resolve()
}).then(() => {
setTimeout(() => {
console.log('macrotask-1')
new Promise((resolve, reject) => {
console.log("p2-0")
resolve()
}).then(() => {
setTimeout(() => {
console.log('macrotask-2')
}, 0)
console.log("p2-1")
}).then(() => {
console.log("p2-2")
})
}, 0)
console.log("p1-1")
}).then(() => {
console.log("p1-2")
})
不要慌,按照老样子一步一步分析即可:
- 第一步:Promise1输出p1-0,Promise1.then进入微任务队列
- 第二步:sT1进入宏任务队列,输出p1-1,Promise1.then.then进入微任务队列
- 第三步:执行栈为空,执行微任务队列,输出p1-2;执行宏任务队列,输出macrotask-1,p2-0,Promise2.then进入微任务队列
- 第四步:sT2进入宏任务队列,输出p2-1,Promise2.then.then进入微任务队列
- 第五步:执行栈为空,执行微任务队列,输出p2-2;执行宏任务队列,输出macrotask-2
- 输出:p1-0、p1-1、p1-2、macrotask-1、p2-0、p2-1、p2-2、macrotask-2
接下来咱们再玩点好玩的
3.4 结合async/await
js
async function async1() {
console.log("a1-0");
await async2();
console.log("a1-1");
setTimeout(() => {
console.log('macro-2')
}, 0)
}
async function async2() {
console.log("a2-0");
setTimeout(() => {
console.log('macro-3')
}, 0)
}
setTimeout(() => {
console.log('macro-1')
}, 0)
async1();
看到async/await不要慌,转换为Promise观察即可 (ps:还有一个土方子:await后面都作为等待内容)
- 第一步:sT1进入宏任务队列,执行async1(),输出a1-0,后续内容存入微任务队列,执行async2(),输出a2-0,st3进入宏任务队列
- 第二步:执行栈为空,执行微任务队列,输出a1-1,sT2进入宏任务队列;执行宏任务队列,输出macro-1,macro-2,macro-3
- 输出:a1-0、a2-0、a1-1、macro-1、macro-2、macro-3
接下来的题目是我在看一位大佬的文章后打算补充的,也算是进一步提高一下大家对Promise的熟练度 原文在这里
3.5 Promise.all
js
function runAsync(x) {
return new Promise((resolve, _) => {
setTimeout(() => {
console.log(x)
resolve(x)
}, 1000)
})
}
Promise.all([runAsync(1), runAsync(2), runAsync(3)]).then((res) => {
console.log('res', res)
})
已知Promise.all会在所有的异步任务完成后再执行回调。
- 第一步:all遍历传入的数组,执行每一个Promise对象,分别将sT1、sT2、sT3存入宏任务队列
- 第二步:执行栈为空,执行宏任务队列,输出1、2、3;all的所有异步任务完成,执行回调,输出res [1,2,3]
- 输出:1、2、3、res[1,2,3]
3.6 Promise.race
js
function runAsync(x) {
return new Promise((resolve, _) => {
setTimeout(() => {
console.log(x)
resolve(x)
}, 1000 * x)
})
}
Promise.race([runAsync(1), runAsync(2), runAsync(3)]).then((res) => {
console.log('res', res)
})
已知Promise.race会在第一个异步任务完成后马上执行回调,不返回其他的异步任务结果(但是会执行)
- 第一步:race遍历传入的数组,执行每一个Promise对象,分别将sT1、sT2、sT3存入宏任务队列
- 第二步:sT1优先完成,输出1,race执行回调,输出res 1,后续逐个输出2、3
- 输出:1、res 1、2、3
四、结语
本文帮大家梳理了 JS的事件循环
,但对于更大的 浏览器进程
没有什么介绍。
这边还是建议货比三家,多看看大佬们的文章,尝试独立去描述一整个事物,形成自己的认知,本文也是在我学习了很多大佬的见解后总结出来的。后续我也会基于浏览器进程写一篇文章~
最后,希望本文对大家有帮助,如果有误欢迎指出!