两小时学习讲义:事件循环与宏任务 / 微任务
配套《学习计划.md》使用,本讲义把每个知识点打碎到颗粒级 ,并逐点给出类比 + 例子 + 真实开发场景 。 学习方法建议:先猜后跑。遇到任何异步代码,先在纸上写下"每一行的宏/微任务归属和输出顺序",再运行验证。 全程约 120 分钟,与学习计划的时间轴一一对应。
⏱️ 时间轴总览
| 时间段 | 环节 | 本讲义位置 |
|---|---|---|
| 20:00--20:10 | 🔁 回顾与预习 | 第 1 段 |
| 20:10--20:25 | 📚 知识块①:单线程 + 调用栈 + Web API | 第 2 段 |
| 20:25--20:40 | 📚 知识块②:宏任务 vs 微任务 | 第 3 段 |
| 20:40--20:50 | 📚 知识块③:一次 tick + 推演 | 第 4 段 |
| 20:50--21:00 | 📝 费曼复述 | 第 5 段 |
| 21:00--21:20 | 🛠️ 练习①:event-loop-basic | 第 6 段 |
| 21:20--21:40 | 🛠️ 练习②③:event-loop-advanced + jsv9000 | 第 7 段 |
| 21:40--21:50 | 💭 思考提高题 | 第 8 段 |
| 21:50--22:00 | ✅ 测验 | 第 9 段 |
第 1 段(10min)回顾与预习:接住昨天的线
1.1 先接昨日(原型链)的线
昨天你学了对象怎么找属性 (沿原型链上溯)。今天换一个维度:代码怎么排队执行。
把前三天的知识串成一句话,你已经有了 JS 的三张地图:
kotlin
变量 → 看定义处(闭包,周一)
this → 看调用处(四种绑定,周二)
属性 → 看原型链(上溯查找,周三)
代码执行顺序 → 看事件循环(今天:谁排队、谁插队、谁先走)
1.2 先凭直觉回答 4 个小问题(不用对,先写下答案)
js
// 问题 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 | 不断检查:调用栈空了吗?空就取队列头执行 | 这是"轮转"的发动机 |
例子:四个角色怎么配合
js
console.log('开始'); // ① 进栈 → 同步执行 → 输出'开始' → 出栈
setTimeout(() => console.log('定时器回调'), 0); // ② 调用 Web API → 立即返回,主线程不等
console.log('结束'); // ③ 同步执行 → 输出'结束'
// ④ 0ms 后,浏览器把回调投进任务队列
// ⑤ 事件循环发现栈空了 → 取出回调 → 执行 → 输出'定时器回调'
// 输出:开始 → 结束 → 定时器回调
类比(厨房流水线):主厨(主线程)只有一个灶台(调用栈)。需要"烤 10 分钟"的菜(异步任务),交给烤箱(Web API),主厨不守在旁边,继续做下一道。烤箱"叮"(完成)后,把菜放进"候菜台"(任务队列)。领班(事件循环)看主厨手头空了,就从候菜台叫下一个。
真实场景(为什么页面会"无响应") :如果有一段同步 代码占用主线程太久(比如巨大循环、卡住的递归、死循环),调用栈一直不为空,事件循环就无法取任务------setTimeout 到点了也排不进、fetch 回来了也没人处理、用户点击没反应。这就是"页面卡死"的本质:不是异步任务太多,而是同步代码把主线程堵死了。
2.3 调用栈:再近一点看
知识点(细化)
- 每调用一个函数,压入一个"帧"(记录参数、局部变量、返回位置);函数返回时弹出。
- 栈是 LIFO:后进先出,符合"函数里调函数,先执行内层再返回外层"。
- 栈溢出(stack overflow):无限递归时帧无限增长,栈爆了。
例子
js
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 两张清单(背下来)
知识点(细化)
| 类型 | 包括(浏览器) | 特点 |
|---|---|---|
| 宏任务 macrotask | setTimeout、setInterval、I/O、UI 事件(click 等)、requestAnimationFrame、整体 script;Node 另有 setImmediate |
每轮取一个 |
| 微任务 microtask | Promise.then/catch/finally、await 之后的代码、queueMicrotask、MutationObserver;Node 另有 process.nextTick(优先级更高) |
每轮清空到空 |
类比(两种排队) :宏任务队列是"普通叫号",一次叫一位;微任务队列是"VIP 通道",并且规矩是------普通客户办完业务离开窗口后,下一号普通客户进来之前,所有 VIP 必须全部办完。清空了,普通客户才叫号。
3.2 铁律:每个宏任务执行完,清空整个微任务队列
知识点(细化)
- 执行顺序是:一个宏任务 → 清空所有微任务 → 下一个宏任务 → 清空所有微任务 → ...
- 关键细节:"清空"是清到空为止 ------清微任务的过程中新产生的微任务也要继续执行,直到队列真的空了。
- 因此任何微任务都必然先于下一个宏任务。
例子:先猜后跑
js
setTimeout(() => console.log('宏任务A'), 0);
Promise.resolve().then(() => console.log('微任务1'));
Promise.resolve().then(() => console.log('微任务2'));
// 输出:微任务1 → 微任务2 → 宏任务A
例子:微任务里再生微任务(清空式)
js
setTimeout(() => console.log('宏任务'), 0);
Promise.resolve()
.then(() => { console.log('微1'); return Promise.resolve(); })
.then(() => console.log('微2'));
// 输出:微1 → 微2 → 宏任务(微2 是清空过程中新产生的,也要执行完)
3.3 为什么微任务必须先进?(这是设计,不是巧合)
知识点(细化)
- 规范规定:一个宏任务执行完,必须清空微任务队列,才能继续下一个宏任务。
- 目的:保证 Promise 的时序一致性 。
.then的回调应该尽快、稳定地执行,不能被其他宏任务"插队"打断。否则:
js
// 如果 .then 是宏任务,会发生什么?
fetch('/data')
.then(() => updateUI()); // 假设是宏任务
setTimeout(() => clearUI(), 0); // 可能插在中间,把刚更新的 UI 清了------顺序就乱了
await之后的代码本质就是.then的语法糖,所以也属于微任务。
类比(餐厅出菜):后厨规定"这道菜出锅后,必须先把配菜/酱料(微任务)摆好,再出下一道主菜(宏任务)"------为了保证一桌菜的完整性,不允许别的桌插队打乱摆盘。
真实场景 :Vue3 的响应式更新用微任务做批处理 (nextTick 底层就是 Promise.then)------连续改 100 个状态,只在微任务里渲染一次;如果混进宏任务,可能被渲染或用户事件插入,产生闪烁。
3.4 微任务风暴:清空式带来的"副作用"
知识点(细化)
- 既然清空到空,那如果微任务里不断追加微任务,队列就永远空不了------宏任务和渲染会被活活"饿死"。
js
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 后立即执行"。- 三个让它变慢的因素:
- 最小延迟钳制 :Chrome 对嵌套层级 ≥ 5 层的定时器,最短延迟提升到约 4ms(防抖/节流递归写多层的坑)。
- 主线程繁忙:前面排队的同步代码和任务没跑完,回调就得等着。
- 后台标签页节流 :页面不可见时,浏览器会把定时器最小延迟提到 1 秒左右以省电。
例子
js
let n = 0;
function nested() { if (n < 6) { n++; setTimeout(nested, 0); } }
nested(); // Chrome 里第 5 层起,每次实际等待 ≥ 4ms
类比(叫号) :setTimeout(0) 是"排到 0 号优先位",但窗口前还有人在办业务、前面还有号没叫完,你还是要等。
4.3 手把手推演经典题(把"推演表格"练会)
知识点(细化) :掌握一张"推演表"------四列:栈 / 微任务队列 / 宏任务队列 / 输出。一行行扫代码,把每一步写进去。
例题
js
console.log('1'); // 同步 → 输出 1
setTimeout(() => 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()本身不阻塞后续同步代码。
例子:先猜后跑
js
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 练习内容与分步提示
主任务:推演下面代码的输出顺序,并在文件注释里画出推演过程:
js
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运行验证。
变式(练手,写出预测再运行)
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)练习②③:改造加深 + jsv9000 可视化(code\event-loop-advanced.js)
目标:把
async/await、queueMicrotask、requestAnimationFrame混进代码,预测顺序并运行验证。 📄 参考实现已放好:code\event-loop-advanced.js(Node 可跑的核心部分 + 浏览器专属的 rAF 部分)。
7.1 练习②:进阶混合推演
分步提示(先自己写预测,再运行)
js
// ① 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只在浏览器存在,在浏览器控制台运行:
js
setTimeout(() => console.log('timer'), 0);
requestAnimationFrame(() => console.log('rAF'));
Promise.resolve().then(() => console.log('micro'));
推演 :micro 是微任务,必然最先(清空阶段);rAF 在渲染阶段 执行(微任务清空后、下一个宏任务之前);timer 是下一个宏任务,通常最后。但 rAF 与 timer 的先后受浏览器帧调度影响,以实际运行为准------这正是第 3 步要用 jsv9000 / 实际运行验证的原因。
7.3 练习③:jsv9000.app 可视化验证
- 打开 www.jsv9000.app/ ,把主任务和变式代码粘进去。
- 点 Run ,逐步观察三块动画:Call Stack(栈)、Macro Task Queue(宏任务队列)、Micro Task Queue(微任务队列) 的进出。
- 验证你的推演表,并把关键观察写进
code\README.md的笔记区(比如:"看到 .then 进的是 Micro 队列,setTimeout 进的是 Macro 队列")。
观察清单(完成标准)
- 能指出"每次宏任务执行后,Micro 队列被清空,然后 Macro 队列才取下一个"
- 能指出
await之后那行代码出现在 Micro 队列里 - 能复述一次 tick 中栈/队列的变化过程
第 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 < 3; i++) { await sleep(i); }耗时约等于三个延迟之和;而Promise.all约等于最大延迟。这是并发优化的核心。
思考题③:渲染在"微任务清空后、下一宏任务前",对"改 DOM + 读布局"意味着什么?
提示
- 浏览器通常把多次 DOM 修改合并到一次渲染 。但如果代码在"改完 DOM"后立刻同步读布局 (如
el.offsetHeight、getBoundingClientRect()),浏览器被逼着强制同步重排(layout thrashing),合并优化就失效了。 - 反例:循环里交替"写样式 + 读 offsetHeight"→ 每次读写都触发一次重排,性能崩塌。
- 正确姿势:先批量改,再一次读 ;或把读操作放到
requestAnimationFrame里,让它和渲染同一帧。
第 9 段(10min)测验与收尾
9.1 测验
- 打开
test\测验.md,限时 15 分钟完成 10 题(巩固 5 + 提升 3 + 横向 2)。 - 做完再翻答案,把错题按"概念错误 / 推演失误 / 表达不清"分类标记,回到讲义对应章节重读。
9.2 今日能力自检(打分 1--5,低于 3 分明天补)
| 能力 | 自评 |
|---|---|
| 能说出四个角色(栈/Web API/队列/事件循环)如何协同 | ☐ |
| 能默写宏任务与微任务清单 + 核心铁律 | ☐ |
| 能独立画出任意异步代码的推演表并推对 | ☐ |
能解释 setTimeout(0)、await、微任务风暴三个细节 |
☐ |
| 能用 jsv9000 验证并解释 tick 中的栈/队列变化 | ☐ |
📌 今日核心速记卡(可截图/摘抄带走)
javascript
单线程 = 同一时刻只做一件事;异步 = 等待外包给 Web API,主线程不阻塞
调用栈 Call Stack:正在执行的函数(后进先出);栈空,才取任务
宏任务:setTimeout / setInterval / I/O / UI事件 / rAF
微任务:Promise.then / await 之后 / queueMicrotask / MutationObserver
铁律:每执行完一个宏任务 → 清空【整个】微任务队列 → 才轮到下一个宏任务
一次 tick:执行宏任务 → 清空微任务 →(必要时)渲染 → 取下一个宏任务
setTimeout(0) ≠ 0ms:嵌套≥5层约4ms / 主线程忙要排队 / 后台标签页节流1s
await 之后的代码 = 微任务(.then 语法糖);async 函数体在 await 前是同步的
微任务风暴会饿死宏任务与渲染;渲染在微任务清空后、下一宏任务前
❌ 常见误区清单(学完自查,哪个说过/想过就划掉)
- "setTimeout(0) 立即执行" ------ ❌ 排队 + 最小延迟钳制,只是"尽快"
- "Promise 的回调是同步的" ------ ❌
.then是微任务,同步代码之后才跑 - "宏任务和微任务按注册顺序混排执行" ------ ❌ 宏任务之间夹一整轮微任务清空
- "async 函数一调用就全异步" ------ ❌ 到第一个 await 前是同步执行的
- "微任务队列每轮只执行一个" ------ ❌ 是清空式,新产生的也要执行完
- "requestAnimationFrame 和 setTimeout 排同一队" ------ ❌ rAF 在渲染阶段,天然对齐帧
- "await 会阻塞主线程" ------ ❌ 是让出控制权,主线程继续跑其他代码
- "页面卡死是因为异步任务太多" ------ ❌ 往往是同步阻塞或微任务风暴