前言
相信很多第一次接触 JS 异步的小伙伴很容易被搞懵,明明写在前面的定时器 setTimeout 为什么总是比后面的 Promise 回调执行晚?这与事件循环有着莫大的关系,我第一次也踩了不少坑,这篇文字带你五分钟学懂 JS 事件循环。
一、JS 线程
让我们先来简单回顾一下 JS 线程的知识点
1.1 JS 默认单线程执行
1.2 代码中一定存在耗时任务和不耗时任务
1.3 为什么不设计成多线程模式?
- 因为 JS 可以操作 DOM 结构,多线程执行可能会造成不安全的渲染
如果代码中存在耗时任务,将一个盒子设置为100×100,另一个设置盒子为 200×200,那就有可能碰上两条线程同时进行这一操作,那浏览器就不知道该执行哪个操作
- 多线程需要锁,增加了难度,也增加了性能开销
为了避免这种情况,就需要锁,当两个线程撞上了,需要锁住其中一个,让另一个先执行,再进行解锁,而这无疑要增加不少难度
二、任务
2.1 任务分为耗时任务和不耗时任务
2.2 当代码碰上耗时任务(异步任务)时,会将耗时任务存放到队列中,先执行不耗时任务(同步任务)
2.3 耗时任务(异步任务)也被分为微任务和宏任务
-
微任务:promise.then() [最常见] ,process.nextTick() , MutationObserver
-
宏任务: setTimeout() , script , setInterval() , ajax【接口请求】, I/O【输入输出】, UI-rendering【页面渲染】
2.4 setTimeout() 补充
- 所有的 setTimeout 共用同一份时间,先进宏任务队列的不一定先执行,而是根据计时 时间长短 来判断是否先执行
除了 setTimeout() ,其他的异步任务都满足队列先进先出
三、事件循环机制
- 1. 先执行同步任务(script属于宏任务),这个过程中,遇到异步就存入对应的队列中
微任务存入到微任务的队列中, 宏任务存入对应的宏任务队列中
- 2. 去微任务队列中查找微任务,并将微任务取出执行
满足队列先入先出,先存入的微任务先执行
-
3. 有需要的情况下,就渲染页面
-
4. 去宏任务队列中查找宏任务,并将宏任务取出执行(也是下一次循环的开始)
接下来我们来看一段代码,理解运用一下上述规则
console.log(1); //1
new Promise((resolve) => {
console.log(2); //2
resolve()
}).then(() => { //4
console.log(3);
setTimeout1(() => { //5
console.log(4);
}, 0)
})
setTimeout2(() => { //6
console.log(5);
}, 1000)
console.log(6); //3
// 输出顺序为 1 2 6 3 4 5
c代码从上往下,根据步骤来
-
碰到第一行
console.log(1)同步代码执行输出1,第三行new Promise(() =>{xxx})也是同步任务 执行内容 输出 2 ,碰到了.then(()=>{xxx})微任务(异步任务) 存入微任务队列中,第十三行setTimeout2(()=>{xxx})宏任务(异步任务) 存入宏任务队列中,第十七行console.log(6);同步任务 执行输出6 -
去微任务队列中查找微任务,发现有个 .then() 任务取出执行,
console.log(3)同步任务 执行输出3,紧接着发现setTimeout1(()=>{xxx})宏任务(异步任务) ,存入宏任务队列中,微任务执行完毕,且没有其他微任务 -
无页面需要渲染
-
去宏任务队列中查找宏任务,发现有两个定时器,而又因为所有的 setTimeout 共用同一份时间,且定时器1耗时更短,所以先输出4,紧接着输出5,所以代码执行完毕
可以将宏任务先后执行理解为沙漏,当开始执行宏任务时,所有沙漏开始同时计时,耗时短的任务先漏完,开始执行,耗时长的后漏完再执行
画一个流程图更方便理解
graph LR
A[主线程开始执行] --> B[执行同步代码]
B --> C{异步代码?}
C -->|微任务| D[存入微任务队列]
C -->|宏任务| E[存入宏任务队列]
D --> F{后续同步代码是否执行完?}
E --> F
F -->|否| B
F -->|是| G[清空并执行所有微任务]
G --> H[执行一个宏任务]
这是一个完整的事件流程,但既然我们说的是事件循环,那循环体现在哪呢?
console.log(1); //1
setTimeout1(() => { //3
console.log(2);
new Promise((resolve) => { //4
console.log(3);
resolve()
}).then(() => { //5
console.log(4);
})
setTimeout2(() => { //6
console.log(5);
}, 0)
}, 1000)
console.log(6); //2
// 1 6 2 3 4 5
- 我们依旧按照事件循环的流程来,第一步 先执行同步任务,这个过程中,遇到异步就存入对应的队列中
console.log(1)同步 输出 1 ,setTimeout(()=>{xxx})异步存入宏队列console.log(6)同步 输出6
- 去微任务队列中查找微任务,并将微任务取出执行
无微任务
- 有需要的情况下,就渲染页面
无页面渲染
- 去宏任务队列中查找宏任务,并将宏任务取出执行
setTimeout1(()=>{xxx})找到setTimeout1执行,又开始按照先同步执行,再异步存入,再微任务宏任务的流程执行
这是不是就相当于开启了第二世,依旧按照事件逻辑开始同步执行,同步执行,再异步存入,再微任务宏任务的流程执行,而下一个宏任务也依旧按照这套逻辑执行,这就是事件循环,而第一个宏任务的执行也代表第二世循环的开始,而第一世循环的开始则是 script 标签
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script> //第一世开始
let a = 1
console.log(a);
new Promise((resolve) => {
a = 2
console.log(a);
resolve()
}).then(() => {
a = 3
console.log(a);
})
setTimeout(() => { //第二世开始
a = 4
console.log(a);
},1000)
</script>
<h2>hello</h2> <!-- 先打印 1 2 3 再渲染 hello 最后 输出4 -->
</body>
</html>
所以整体逻辑为
graph LR
A[主线程开始执行] --> B[执行同步代码]
B --> C{异步代码?}
C -->|微任务| D[存入微任务队列]
C -->|宏任务| E[存入宏任务队列]
D --> F{后续同步代码是否执行完?}
E --> F
F -->|否| B
F -->|是| G[清空并执行所有微任务]
G --> H[渲染页面]
H --> I[执行宏任务]
I --> B
补充 js引擎线程和渲染线程是互斥的,同一时间只执行其中一个线程,其他线程可同时进行
四、async/await
- 1. 函数前面加 async 等同于函数内部 return 了一个 promise 对象
- 2. await xxx (相当于.then() 在它后面的代码一定要等xxx执行完才能执行)
- xxx 看成同步来执行
- 将它的后续代码挤入微任务序列
console.log('script start'); //1 同步
async function async1() {
await async2()
console.log('async1 end'); //5 挤入微任务队列中 1号
}
async function async2() {
console.log('async2 end'); //2 同步
}
async1()
setTimeout(() => {
console.log('setTimeout'); //8 宏任务队列
}, 0)
new Promise((resolve, reject) => {
console.log('promise'); //3 同步
resolve()
})
.then(() => {
console.log('then1'); //6 微任务队列 2号
})
.then(() => {
console.log('then2'); //7 微任务队列 3号
});
console.log('script end'); //4 同步