事件循环与宏任务 / 微任务
第 1 段(10min)回顾与预习:接住昨天的线
1.1 先接昨日(原型链)的线
昨天你学了对象怎么找属性(沿原型链上溯)。今天换一个维度:代码怎么排队执行。
把前三天的知识串成一句话,你已经有了 JS 的三张地图:
变量 → 看定义处(闭包,周一)
this → 看调用处(四种绑定,周二)
属性 → 看原型链(上溯查找,周三)
代码执行顺序 → 看事件循环(今天:谁排队、谁插队、谁先走)
1.2 先凭直觉回答 4 个小问题(不用对,先写下答案)
// 问题 1
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出顺序?为什么 Promise 的 3 反而在 setTimeout 的 2 前面?
// 问题 2:一个死循环会怎样
while (true) {} // 页面上会发生什么?setTimeout 还能执行吗?
// 问题 3:async 是"一调用就全异步"吗?
async function f() { console.log('A'); await Promise.resolve(); console.log('B'); }
console.log('C');
f();
console.log('D');
// 输出顺序?
// 问题 4:下面哪个会"永远轮不到"?
function loop() { Promise.resolve().then(loop); }
loop();
setTimeout(() => console.log('轮不到我'), 0); // 会执行吗?
学完第 2、3、4 段再回来对照。第 1 段只做一件事:记住画面——JS 单线程,但靠"排队 + 轮转"假装多线程。
第 2 段(15min)知识块①:单线程模型 + 调用栈 + Web API
2.1 为什么 JS 必须是单线程
知识点(细化)
- JS 从出生起就是单线程:同一时刻只执行一段代码。
- 原因:JS 是浏览器的脚本语言,主要职责是操作 DOM。如果两个线程同时改一个 DOM 节点,渲染结果就无法确定。多线程 + 加锁的方案太复杂、易出错,所以干脆单线程——用"不可能出问题"换"性能天花板"。
- 那页面为什么看起来不卡?因为异步不阻塞:要等的活儿(定时、网络、事件)外包给浏览器,JS 自己不等。
类比(单窗口银行 / 单主厨餐厅):银行只有一个窗口,同一时刻只有一位客户办业务。其他客户"等待"时不需要占用柜员——他们在排队,柜员忙完就叫下一位。JS 的"等"也一样:等待不占主线程,占的是队列里的位置。
真实场景:你写 fetch(url).then(...) 时,主线程没有干等网络返回,而是继续执行后面的代码。等数据到了,浏览器把回调投进队列,主线程有空再执行——这就是"异步不阻塞"的直观体现。
2.2 四个角色:调用栈 / Web API / 任务队列 / 事件循环
知识点(细化)
角色是什么关键点
调用栈 Call Stack记录当前正在执行的函数,后进先出(LIFO)栈空 = 主线程空闲,才取任务 Web API浏览器提供的异步能力:setTimeout、fetch、addEventListener、requestAnimationFrame…JS 调用后立即返回,不等待 任务队列 Task Queue存放"到时间/到数据"的回调,排队等待先进先出(FIFO) 事件循环 Event Loop不断检查:调用栈空了吗?空就取队列头执行这是"轮转"的发动机
例子:四个角色怎么配合
console.log('开始'); // ① 进栈 → 同步执行 → 输出'开始' → 出栈
setTimeout(() => console.log('定时器回调'), 0); // ② 调用 Web API → 立即返回,主线程不等
console.log('结束'); // ③ 同步执行 → 输出'结束'
// ④ 0ms 后,浏览器把回调投进任务队列
// ⑤ 事件循环发现栈空了 → 取出回调 → 执行 → 输出'定时器回调'
// 输出:开始 → 结束 → 定时器回调
类比(厨房流水线):主厨(主线程)只有一个灶台(调用栈)。需要"烤 10 分钟"的菜(异步任务),交给烤箱(Web API),主厨不守在旁边,继续做下一道。烤箱"叮"(完成)后,把菜放进"候菜台"(任务队列)。领班(事件循环)看主厨手头空了,就从候菜台叫下一个。
真实场景(为什么页面会"无响应"):如果有一段同步代码占用主线程太久(比如巨大循环、卡住的递归、死循环),调用栈一直不为空,事件循环就无法取任务——setTimeout 到点了也排不进、fetch 回来了也没人处理、用户点击没反应。这就是"页面卡死"的本质:不是异步任务太多,而是同步代码把主线程堵死了。
2.3 调用栈:再近一点看
知识点(细化)
- 每调用一个函数,压入一个"帧"(记录参数、局部变量、返回位置);函数返回时弹出。
- 栈是 LIFO:后进先出,符合"函数里调函数,先执行内层再返回外层"。
- 栈溢出(stack overflow):无限递归时帧无限增长,栈爆了。
例子
function a() { b(); }
function b() { c(); }
function c() { console.log('内层执行'); }
a();
// 栈的变化:a 进栈 → b 进栈 → c 进栈 → c 出栈 → b 出栈 → a 出栈
// 栈溢出
function boom() { boom(); } // Uncaught RangeError: Maximum call stack size exceeded
真实场景:浏览器开发者工具 → Sources → Call Stack 面板在断点调试时展示的正是这张栈;"Maximum call stack size exceeded"是递归写崩时的经典报错。记牢:栈非空,队列里的任务就别想执行。
2.4 任务队列:也分优先级?
知识点(细化)
- 任务队列其实不止一条:宏任务队列 + 微任务队列(第 3 段重点)。
- 事件循环每轮只从宏任务队列取一个,但微任务要"清空"。
- 也就是说:任务不是简单 FIFO 混排,宏任务之间插着一整轮微任务。
这一段先记住结论,第 3 段马上讲"为什么"。
第 3 段(15min)知识块②:宏任务 vs 微任务
3.1 两张清单(背下来)
知识点(细化)
类型包括(浏览器)特点
宏任务 macrotasksetTimeout、setInterval、I/O、UI 事件(click 等)、requestAnimationFrame、整体 script;Node 另有 setImmediate每轮取一个 微任务 microtaskPromise.then/catch/finally、await 之后的代码、queueMicrotask、MutationObserver;Node 另有 process.nextTick(优先级更高)每轮清空到空
类比(两种排队):宏任务队列是"普通叫号",一次叫一位;微任务队列是"VIP 通道",并且规矩是——普通客户办完业务离开窗口后,下一号普通客户进来之前,所有 VIP 必须全部办完。清空了,普通客户才叫号。
3.2 铁律:每个宏任务执行完,清空整个微任务队列
知识点(细化)
- 执行顺序是:一个宏任务 → 清空所有微任务 → 下一个宏任务 → 清空所有微任务 → …
- 关键细节:"清空"是清到空为止——清微任务的过程中新产生的微任务也要继续执行,直到队列真的空了。
- 因此任何微任务都必然先于下一个宏任务。
例子:先猜后跑
setTimeout(() => console.log('宏任务A'), 0);
Promise.resolve().then(() => console.log('微任务1'));
Promise.resolve().then(() => console.log('微任务2'));
// 输出:微任务1 → 微任务2 → 宏任务A
例子:微任务里再生微任务(清空式)
setTimeout(() => console.log('宏任务'), 0);
Promise.resolve()
.then(() => { console.log('微1'); return Promise.resolve(); })
.then(() => console.log('微2'));
// 输出:微1 → 微2 → 宏任务(微2 是清空过程中新产生的,也要执行完)
3.3 为什么微任务必须先进?(这是设计,不是巧合)
知识点(细化)
- 规范规定:一个宏任务执行完,必须清空微任务队列,才能继续下一个宏任务。
- 目的:保证 Promise 的时序一致性。
.then的回调应该尽快、稳定地执行,不能被其他宏任务"插队"打断。否则:
// 如果 .then 是宏任务,会发生什么?
fetch('/data')
.then(() => updateUI()); // 假设是宏任务
setTimeout(() => clearUI(), 0); // 可能插在中间,把刚更新的 UI 清了——顺序就乱了
await之后的代码本质就是.then的语法糖,所以也属于微任务。
类比(餐厅出菜):后厨规定"这道菜出锅后,必须先把配菜/酱料(微任务)摆好,再出下一道主菜(宏任务)"——为了保证一桌菜的完整性,不允许别的桌插队打乱摆盘。
真实场景:Vue3 的响应式更新用微任务做批处理(nextTick 底层就是 Promise.then)——连续改 100 个状态,只在微任务里渲染一次;如果混进宏任务,可能被渲染或用户事件插入,产生闪烁。
3.4 微任务风暴:清空式带来的"副作用"
知识点(细化)
- 既然清空到空,那如果微任务里不断追加微任务,队列就永远空不了——宏任务和渲染会被活活"饿死"。
function loop() { Promise.resolve().then(loop); }
loop();
setTimeout(() => console.log('永远轮不到我'), 0); // 可能永远不执行
类比(插队的 VIP 没完没了):VIP 一个接一个插进来,普通客户永远轮不到——业务就瘫痪了。
真实场景:某个监听器在微任务里又触发新的状态更新、又排微任务,就可能形成风暴卡死页面。性能调优时,"微任务里不要无限再生微任务"是红线。
第 4 段(10min)知识块③:一次 tick 的完整过程 + 代码推演
4.1 一次 tick 四步(背下来)
知识点(细化)
一次 tick:
① 从宏任务队列取【一个】宏任务执行(首次加载的整体同步脚本也算一个宏任务)
② 执行期间产生的微任务全部入微任务队列
③ 宏任务执行完,清空【整个】微任务队列(新产生的也算)
④ 必要时渲染(先跑 requestAnimationFrame 回调,再更新布局与绘制)
⑤ 回到 ① 取下一个宏任务
关键点:渲染发生在"微任务清空之后、下一个宏任务之前"。这决定了"改 DOM + 读布局"类代码的时序(第 8 段思考题③)。
4.2 setTimeout(0) 真的 0ms 吗?——不是
知识点(细化)
setTimeout(fn, 0)的含义是"尽快把回调排进队列",不是"0ms 后立即执行"。- 三个让它变慢的因素: 1. 最小延迟钳制:Chrome 对嵌套层级 ≥ 5 层的定时器,最短延迟提升到约 4ms(防抖/节流递归写多层的坑)。 2. 主线程繁忙:前面排队的同步代码和任务没跑完,回调就得等着。 3. 后台标签页节流:页面不可见时,浏览器会把定时器最小延迟提到 1 秒左右以省电。
例子
let n = 0;
function nested() { if (n console.log('2'), 0); // 宏任务 → 进宏任务队列
Promise.resolve().then(() => console.log('3')); // 微任务 → 进微任务队列
console.log('4'); // 同步 → 输出 4
// 同步执行完 → 清空微任务:输出 3 → 渲染(可能) → 取下一个宏任务:输出 2
// 最终:1 4 3 2
推演表(写给自己看的样子)
| 步骤 | 栈 | 微任务队列 | 宏任务队列 | 输出 |
|---|---|---|---|---|
| console.log(‘1’) | — | — | — | 1 |
| setTimeout(…) | — | — | [2] | |
| .then(3) | — | [3] | [2] | |
| console.log(‘4’) | — | [3] | [2] | 4 |
| 清空微任务 | [→3] | [2] | 3 | |
| 取宏任务 | [→2] | 2 |
推演表是今天的核心技能。练习①会让你多画几张,画熟了,任何异步题都是填表题。
4.4 进阶推演:async/await 拆解
知识点(细化)
async function的函数体同步执行到第一个await之前,然后"让出"。await 之后的代码 → 被包成微任务(等价.then)。- 调用
f()本身不阻塞后续同步代码。
例子:先猜后跑
async function f() {
console.log('A'); // 同步(await 之前)
await Promise.resolve(); // 让出,后续变微任务
console.log('B'); // 微任务
}
console.log('C'); // 同步
f(); // 同步执行到 await → 输出 A,然后让出
console.log('D'); // 同步
setTimeout(() => console.log('E'), 0); // 宏任务
// 输出:C A D B E
坑:“async 函数不是全异步”——A 是同步打的,B 才是微任务。忘了这一点,推演必错。
第 5 段(10min)费曼复述:讲给"零基础同学"听
5.1 复述提纲(出声讲一遍,卡壳处回去重看)
- JS 单线程怎么还不卡? —— 等待外包给 Web API,自己只做"取任务 → 执行"。
- 两条队伍怎么排? —— 宏任务一次叫一个;但每叫完一个,微任务必须全部清空才叫下一个。
- 为什么微任务先进? —— 这是设计:保证 Promise 的
.then不被其他宏任务插队,时序才稳定。 - 一次 tick 四步? —— 执行宏任务 → 清空微任务 → (必要时)渲染 → 下一个宏任务。
- setTimeout(0) 为什么不是 0ms? —— 最小延迟钳制 + 主线程排队 + 后台节流。
- await 之后是啥? —— 微任务(就是
.then的语法糖)。
5.2 自测清单(每条能写出例子才算过)
- 能说出调用栈 / Web API / 任务队列 / 事件循环四个角色各自干什么
- 能默写宏任务清单和微任务清单
- 能用一句话说清"为什么微任务先于下一个宏任务"
- 会用"推演表"(栈/微/宏/输出四列)推演任意异步代码
- 能解释
setTimeout(0)不是 0ms 的三个原因 - 能解释微任务风暴为什么饿死宏任务和渲染
第 6 段(20min)练习①:推演经典输出顺序(code\event-loop-basic.js)
目标:先在纸上推演,再运行验证。卡住再看提示。 📄 参考实现已放好:
code\event-loop-basic.js(含推演表注释 + 4 个变式 + 运行结果)。
6.1 练习内容与分步提示
主任务:推演下面代码的输出顺序,并在文件注释里画出推演过程:
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
- 第 1 步:先标出每一行的"同步 / 宏任务 / 微任务"。
- 第 2 步:按"同步 → 清空微任务 → 宏任务"三段写出顺序。
- 第 3 步:画一张推演表(栈/微/宏/输出四列,参考 4.3)。
- 第 4 步:用
node event-loop-basic.js运行验证。
变式(练手,写出预测再运行)
// 变式 A:微任务里又排微任务
setTimeout(() => console.log('A'), 0);
Promise.resolve().then(() => { console.log('B'); Promise.resolve().then(() => console.log('C')); });
Promise.resolve().then(() => console.log('D'));
// 预测:B D C A (清空到空:B → D → C,然后才到宏任务 A)
// 变式 B:setTimeout 里的 Promise
setTimeout(() => { Promise.resolve().then(() => console.log('inner')); }, 0);
setTimeout(() => console.log('outer'), 0);
// 预测:inner 先于 outer(第一个宏任务产生的微任务,在第二个宏任务前清空)
// 变式 C:同步里穿插
for (const i of [1, 2, 3]) { setTimeout(() => console.log(i), 0); }
Promise.resolve().then(() => console.log('微'));
console.log('同步');
// 预测:同步 → 微 → 1 2 3
6.2 验证清单(每一条都应通过)
- 主任务输出
1 4 3 2,注释里有完整推演表 - 三个变式预测正确,且每条都能说出归属(宏/微/同步)
6.3 常见报错/错误认知排查
错误认知正确理解
“Promise 的回调是同步的”.then 是微任务,同步代码之后才执行 "微任务和宏任务按注册顺序混排"宏任务之间夹着整轮微任务清空 “setTimeout(0) 立即执行"排队 + 钳制,只是"尽快” "async 函数一调用就全异步"到第一个 await 前是同步执行的
第 7 段(20min)练习②③:改造加深
目标:把
async/await、queueMicrotask、requestAnimationFrame混进代码,预测顺序并运行验证。 📄 参考实现已放好:code\event-loop-advanced.js(Node 可跑的核心部分 + 浏览器专属的 rAF 部分)。
7.1 练习②:进阶混合推演
分步提示(先自己写预测,再运行)
// ① async/await + queueMicrotask 混排(Node 与浏览器都能跑)
console.log('1');
setTimeout(() => console.log('2'), 0);
async function a() {
console.log('3');
await Promise.resolve();
console.log('4');
}
a();
queueMicrotask(() => console.log('5'));
console.log('6');
// 推演:同步 1,3,6 → 微任务:await 后的 4、queueMicrotask 的 5(按序)→ 宏任务 2
// 预测:1 3 6 4 5 2
- 第 1 步:标出每一行的归属(同步/宏/微)。
- 第 2 步:注意
a()的3是同步输出的(await 之前)。 - 第 3 步:微任务队列里 4、5 的入队顺序。
- 第 4 步:
node event-loop-advanced.js验证前半部分。
7.2 练习②(浏览器部分):rAF 加入战局
requestAnimationFrame只在浏览器存在,在浏览器控制台运行:
setTimeout(() => console.log('timer'), 0);
requestAnimationFrame(() => console.log('rAF'));
Promise.resolve().then(() => console.log('micro'));
推演:micro 是微任务,必然最先(清空阶段);rAF 在渲染阶段执行(微任务清空后、下一个宏任务之前);timer 是下一个宏任务,通常最后。但 rAF 与 timer 的先后受浏览器帧调度影响,以实际运行为准——这正是第 3 步要用 jsv9000 / 实际运行验证的原因。
第 8 段(10min)思考提高题:先独立想,再看提示
思考题①:为什么 Promise 的 .then 被设计成微任务?如果它是宏任务会怎样?
提示
- 从"时序一致性"想:
.then回调需要紧跟在当前同步代码之后、且不被打断地执行。 - 如果是宏任务,
setTimeout和它公平排队——用户点击事件、I/O 就可能插在.then之前,导致"数据刚更新又被覆盖 / 链式状态被外部打断"。 - 具体反例:
fetch('/a').then(() => setState(A)); setState(B)如果 then 是宏任务,B 可能渲染在 A 之前。微任务保证"本轮的连续逻辑一口气跑完"。
思考题②:循环里连续 await 多个任务,是串行还是并行?
提示
- 先想清楚
await的本质:它让出控制权,后续代码进微任务。在for循环里写await task(),每次都要等上一个完成——这是串行(等价task().then(() => task())...)。 - 如果想让它们"同时推进",要用
Promise.all([...])先全部发起,再一次await结果。 - 试跑:`for (let i = 0; i
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录