首页/文章/八股文

事件循环与宏任务 / 微任务

2026-08-17
513318 分钟
...

第 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浏览器提供的异步能力:setTimeoutfetchaddEventListenerrequestAnimationFrame…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 两张清单(背下来)

知识点(细化)

类型包括(浏览器)特点

宏任务 macrotasksetTimeoutsetInterval、I/O、UI 事件(click 等)、requestAnimationFrame、整体 script;Node 另有 setImmediate每轮取一个 微任务 microtaskPromise.then/catch/finallyawait 之后的代码、queueMicrotaskMutationObserver;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 复述提纲(出声讲一遍,卡壳处回去重看)

  1. JS 单线程怎么还不卡? —— 等待外包给 Web API,自己只做"取任务 → 执行"。
  2. 两条队伍怎么排? —— 宏任务一次叫一个;但每叫完一个,微任务必须全部清空才叫下一个。
  3. 为什么微任务先进? —— 这是设计:保证 Promise 的 .then 不被其他宏任务插队,时序才稳定。
  4. 一次 tick 四步? —— 执行宏任务 → 清空微任务 → (必要时)渲染 → 下一个宏任务。
  5. setTimeout(0) 为什么不是 0ms? —— 最小延迟钳制 + 主线程排队 + 后台节流。
  6. 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/awaitqueueMicrotaskrequestAnimationFrame 混进代码,预测顺序并运行验证。 📄 参考实现已放好: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

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
八股文2026-08-27
定时器按顺序播放多个音频,切后台音频会乱
本身不会补触发,但 它会被系统级冻结 ——iOS Safari 后台完全停止计时,切回前台后 只补执行一次 (不是不补,而是"缺失的中间状态补不上")。间隔短的不会出大问题, 间隔长的会出现"音频流被压缩" ——切回来后本该播 5 分钟的间隙,实际只过了 3 分钟,结果整段对不上。 核心原则 :定时器只能用来"提醒一次", 真正决定"该不该播"的是绝对时间戳 。 setTimeout 在后台会发生什么(按平台) | 平台 | 后台行为...
面试
八股文2026-08-27
线上项目白屏的原因
白屏的本质是 渲染管线某一环断了 ——可能是资源、JS、接口、路由、样式、兼容性任一环节出问题。排查按"控制台 → 网络 → DOM → 环境"四步定位。 本质(前端类比) 把网页想成一栋楼: 白屏 ≠ 一定是同一种原因——这是面试想听的层次。 六大类原因(按出现频率) | 类别 | 典型表现 | 真实案例 | | : | : | : | | ① 资源加载失败 | DOM 是空的 | 入口 JS 404、CDN 挂了、CSS 阻塞 |...
面试
八股文2026-08-27
背景图就是 1MB 大图,怎么优化
1MB 大背景图优化分 三步 :压缩体积(10 30x)、按需加载(按设备/视口/网速)、渲染期优化(GPU 合成)。背景图跟 不同——它是 CSS,不走浏览器的原生懒加载机制,得手动处理。 背景图 vs 的关键区别(先讲清楚这个) 这是面试官想听的"针对性认知"——背景图不是普通图片,不能套通用方案。 三步优化(按优先级) 第 1 步:压缩体积(最重要,立竿见影) 1MB 的来源一般是这几种 ,对应解决方案: | 原始问题 | 体积来...
面试
八股文2026-08-27
项目里很多图片和视频,怎么优化
图片视频优化分 四层 :网络层(CDN/格式)、加载层(懒加载/预加载)、渲染层(解码/缓存)、业务层(按需/降级)。面试要把这四层都讲到位才算有体系。 四层优化模型 第 1 层:网络层(省钱、省时间) 核心目标:让资源体积小、让用户拿到资源快 | 手段 | 作用 | 关键点 | | : | : | : | | 图片格式 | WebP/AVIF 比 JPEG 小 25 50% | 兼容 fallback | | 视频格式 | H.265...
面试
八股文2026-08-20
Embedding 向量模型:从语义表示到相似度计算
前言 大模型「读懂」文字靠的是 token,但 token 之间只有离散的编号关系,模型并不知道「苹果」和「李子」在语义上很近。要让程序能「理解」两段文字的相似程度,必须先把文本映射成一个 高维向量 ,再用几何方法比较。这一步就是 Embedding。 本篇基于我本地 今天的真实代码,从语义表示讲到余弦相似度,并复盘几个踩过的真实坑。 一、为什么需要 Embedding 传统关键词检索是「字面匹配」: Embedding 做的是「语义匹...
面试
八股文2026-08-19
前端面试100题
前端面试 100 题 适用方向:中高级前端 / React / Vue / Next.js / Nuxt / TypeScript / 工程化 / 实时通信 / Electron / Node.js / AI 应用前端 使用方式:优先掌握“标准回答”,再练“面试官追问”,最后把“结合你的简历怎么答”组织成自己的项目故事。 说明 “结合你的简历怎么答”只使用你简历中已经出现的项目与技术事实。 “标准回答 / 追问”属于通用前端知识总结,用...
面试

评论

请登录后发表评论

去登录
加载评论中...

目录