本文是事件循环学习笔记的整理版。作为一个刚学 JS 不久的小白,我尽量用大白话把自己踩过的坑和验证过的例子讲清楚。如果有理解不到位的地方,欢迎评论区指正~
前言
第一次看到 setTimeout 和 Promise 一起出现,并且输出顺序"莫名其妙"时,我整个人是懵的:
js
console.log("script start");
setTimeout(() => {
console.log("setTimeout");
}, 0);
Promise.resolve().then(() => {
console.log("promise");
});
console.log("script end");
猜猜输出顺序?答案是:
arduino
script start
script end
promise
setTimeout
为什么 setTimeout(0) 里的代码反而最后 执行?要搞懂这个,就得先弄明白三件事:JS 为什么是单线程 、任务分几类 、事件循环怎么转。
一、JS 为什么是单线程?
- 默认情况下,JS 引擎只启用一个线程来执行代码。
- 我们的代码里一定有耗时任务(定时器、HTTP 请求、文件 I/O)和不耗时任务(普通计算、赋值)。
那为什么不设计成多线程呢?主要有两个原因:
- JS 能操作 DOM 结构。 如果多个线程同时去改同一个 DOM,页面渲染会变得不安全、结果不可预测。
- 多线程需要加锁。 加锁会显著增加开发难度,还会带来额外的设备性能开销。
一句话:为了简单和安全,JS 选择单线程 + 异步协作,而不是多线程 + 锁。
二、任务分类:同步任务 & 异步任务
既然是单线程,那遇到耗时任务怎么办?答案是:把耗时(异步)任务先存进队列,先把不耗时的(同步)任务执行完。
异步任务又分成两类:
| 类型 | 常见成员 |
|---|---|
| 宏任务 (macrotask) | script、setTimeout()、setInterval()、ajax 请求、I/O、UI-rendering |
| 微任务 (microtask) | promise.then()、process.nextTick()、MutationObserver |
记住这个分类,后面的事件循环全靠它。
三、事件循环到底怎么转的?
这是全文的核心。事件循环一轮的完整流程是:
用文字画个图(ASCII 版,纯文本保证任何地方都能显示):
markdown
① 开始执行宏任务(整个 script 就是第一个宏任务)
│
▼
② 遇到异步任务?──是──► 存入对应的宏任务/微任务队列
│否
▼
③ 继续同步执行
│
▼
④ 同步代码执行完?──否──► 回到 ②
│是
▼
⑤ 执行微任务队列中的所有微任务(新加的也要本轮清完)
│
▼
⑥ 需要渲染页面?──需要──► 渲染页面
│不需要
▼
⑦ 取出一个宏任务执行(这也是下一轮循环的开始)
│
└────────────► 回到 ①,继续循环
拆成 4 步就是:
- 先执行同步任务 (整个
script就属于一个宏任务)。执行过程中遇到异步任务,就存入对应的队列。 - 去微任务队列里取微任务,全部取出来执行。
- 有需要的话,就渲染页面。
- 去宏任务队列里取宏任务执行------而这一步,也是下一轮循环的开始。
几个容易忽略的细节:
- 微任务队列要清空到空为止:执行一个微任务时如果又产生了新的微任务,这些新微任务也要在本轮里继续执行完,然后才轮到渲染和宏任务。
- 每一轮循环只取出一个宏任务执行(执行完它,再回到步骤 2 清一遍微任务),而不是一次性把所有宏任务全执行掉。
promise的构造函数是同步执行 的,只有.then()里的回调才是微任务------很多小白在这里栽过跟头。
四、setTimeout 的坑:先进队列的不一定先执行
有一条很重要的规律需要记住:所有的 setTimeout 共用同一份时间,先进宏任务队列的不一定先执行,而是根据计时时间长短来判断是否先执行。
什么意思?看一个真实跑过的例子:
js
console.log(1);
new Promise((resolve) => {
console.log(2);
resolve();
}).then(() => {
console.log(3);
setTimeout(() => {
console.log(4); // 0ms
}, 0);
});
setTimeout(() => {
console.log(5); // 1000ms
}, 1000);
console.log(6);
输出顺序:
1
2
6
3
4
5
关键在第 4 和第 5 步:setTimeout(5) 是先进宏任务队列的 (在 console.log(6) 之前就注册了),而 setTimeout(4) 是在微任务里才注册的。按"先进先出"的直觉,应该先输出 5 再输出 4------但结果是先输出 4。
原因就是:setTimeout 不是"入队即执行",而是按计时时间 来决定。4 只要 0ms,5 要 1000ms,所以哪怕 5 先进队,也要等到 1 秒后才轮得到。定时器到点了,任务才会被"投递"进宏任务队列,队列里谁先被投递谁先执行。
五、JS 引擎线程 和 渲染线程 互斥
JS 引擎线程 和 渲染线程 是互斥的:同一时刻只能有一个在工作。
- JS 在跑的时候,页面渲染被阻塞------这就是为什么遇到死循环、超大计算量时页面会"卡死",按钮点了没反应。
- 反过来,渲染的时候 JS 也不会执行。
用一个简单的 HTML 页面可以直观感受渲染时机:
html
<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>
过程是这样:
- 同步代码跑完:
a = 1→a = 2,控制台输出1、2。 - 清微任务:输出
3(a = 3)。 - 此时微任务清完了,事件循环"有需要就渲染页面" ,
hello才真正被画出来。 - 1 秒后宏任务触发:输出
4。
这正好对应事件循环里的第 3 步"有需要的情况下,就渲染页面"------渲染发生在微任务之后、下一个宏任务之前。
六、async/await 到底干了啥?
我最初对 async/await 的理解就是"把异步代码写得像同步一样",但它的底层机制是:
- 函数前面加
async,等同于函数内部return了一个 Promise 对象。 await xxx会把xxx当成同步来执行;await之后的代码,会被挤进微任务队列。
用一道经典面试题来验证:
js
console.log("script start"); // 1
async function async1() {
await async2();
console.log("async1 end"); // 5
}
async function async2() {
console.log("async2 end"); // 2
}
async1();
setTimeout(() => {
console.log("setTimeout"); // 8
}, 0);
new Promise((resolve) => {
console.log("promise"); // 3
resolve();
})
.then(() => console.log("then1")) // 6
.then(() => console.log("then2")); // 7
console.log("script end"); // 4
输出:
arduino
script start
async2 end
promise
script end
async1 end
then1
then2
setTimeout
逐步拆解:
- 第 1 步:
script start(同步)。 - 第 2 步:调用
async1(),执行到await async2()------async2()是同步执行 的,所以立刻输出async2 end。注意:await之后的console.log("async1 end")现在还没执行,它已经被挤进微任务队列了。 - 第 3 步:
new Promise的构造器同步执行,输出promise,.then()的回调(then1)进微任务队列。 - 第 4 步:
script end(同步)------到这里,宏任务(整个 script)执行完毕。 - 第 5~7 步:开始清微任务队列,按注册顺序执行:
async1 end→then1→then2(then1执行后又产生了then2,也在本轮清完)。 - 第 8 步:微任务清空后,渲染时机过了,最后执行宏任务
setTimeout,输出setTimeout。
一个很容易踩的坑(看下面这个例子):
js
function A() {
setTimeout(() => {
console.log("a");
}, 1000);
}
function B() {
console.log("b");
}
async function fn() {
await A();
await B();
C();
}
fn();
A() 没有返回 Promise (它返回的是 undefined)。所以 await A() 并不会"等待"那个 1 秒的定时器,而是把 undefined 当成已经 resolved 的 Promise,立刻继续往下走:B() 马上输出 b,然后 C() 直接报错(未定义)。而 a 要等 1 秒后才输出。
结论:想让 await 真正起到"等待"作用,被 await 的函数必须返回 Promise 对象 ,比如 return new Promise((resolve) => {})。小白(比如我)最容易在这上面栽跟头。
七、总结
把今天的内容浓缩成几句话:
- JS 单线程:为了操作 DOM 的安全和简单,不用多线程 + 锁。
- 任务分两类 :宏任务(
script、setTimeout、setInterval、ajax、I/O、UI 渲染)和微任务(promise.then()、process.nextTick()、MutationObserver)。 - 事件循环顺序:同步代码(script 宏任务)→ 清空微任务 →(需要时)渲染 → 取一个宏任务 → 再回到清微任务......循环往复。
- setTimeout 不按入队顺序执行,按计时时间投递到队列后先到先执行。
- 渲染线程与 JS 引擎线程互斥,渲染插在微任务清空之后、下一个宏任务之前。
- async/await :
async函数返回 Promise;await后的代码进微任务队列;被await的函数必须返回 Promise,否则等于白等。
一句话速记事件循环的节奏:
同步 → 微任务 →(渲染)→ 宏任务 → 循环
把这六句话记住,再遇到"这道题输出顺序是什么"的面试题,就能一步步推出来了。我也是这么练出来的,共勉 💪