本节摘要:浏览器里所有 JavaScript 代码都跑在一个线程上,事件循环负责决定「下一段跑什么」。规则只有三条:调用栈清空后立刻清空微任务队列(清空过程中新产生的微任务也要清);然后才取下一个宏任务;两轮宏任务之间浏览器视需要执行渲染。掌握这三条,任何输出顺序题、任何「为什么我的回调没跑」都能现场推导。本节还覆盖 setTimeout 的最小延迟、长任务卡死页面的机制与拆解手段。
一段浓缩了全部调度规则的代码,先自己推演答案再看实测:
console.log('1 同步'); setTimeout(() => console.log('2 宏任务 timeout'), 0); Promise.resolve().then(() => { console.log('3 微任务'); Promise.resolve().then(() => console.log('4 微任务里再排微任务')); }); queueMicrotask(() => console.log('5 queueMicrotask 排的微任务')); console.log('6 同步'); // 实测输出顺序:1 6 3 5 4 2
逐条回放引擎动作。同步语句 1、6 直接在调用栈上执行。setTimeout 的回调被定时器线程登记,0 毫秒到期后进入宏任务队列排队。两个 Promise then 回调与 queueMicrotask 回调进入微任务队列。同步代码跑完,调用栈清空——此刻是微任务检查点:引擎不取宏任务,先把微任务队列整个清空。按入队顺序执行 3(它内部又排了个 4,追加到队尾)、5、4。微任务清空后,本轮宏任务结束,浏览器视需要渲染,然后取下一个宏任务——2 终于登场。
三个推论直接从这段回放里长出来:微任务能插队宏任务,无论 setTimeout 写得多早;微任务清空是「彻底的」,执行中产生的新微任务也在本轮清完(所以 4 排在 2 前面,尽管它产生得更晚);同步代码永远优先,任何排队者都动不了正在栈上跑的代码。

哪些 API 往哪条队列里塞任务,是推导题的基本功。
微任务的生产者:Promise 的 then、catch、finally 回调;queueMicrotask;MutationObserver 回调;await 之后的代码(本质是 then,4.3 节拆解)。
宏任务的生产者:setTimeout 与 setInterval 的到期回调;用户事件(点击、输入)的回调;I/O 与网络完成回调(浏览器环境里 fetch 的完成也走任务队列,Node 里另有细分);页面脚本本体算第一个宏任务。
容易误判的三个点。其一,Promise 构造函数里的同步代码不是异步:new Promise(executor) 的 executor 立即在栈上执行,只有 then 回调才排队。其二,await promise 这一行本身是同步求值,让出栈的是 await 之后的代码。其三,setInterval 的回调同刻只会有一个在队列里(上次没跑完不会叠两个),这是它防抖的天性。
new Promise((resolve) => { console.log('executor 同步跑'); resolve(); }).then(() => console.log('then 才排队')); console.log('主代码'); // 输出:executor 同步跑 → 主代码 → then 才排队
setTimeout(fn, 0) 的 0 不是承诺,是「不早于」的下限。规范与实现有三层约束:嵌套超过五层后最小延迟被强制提升到 4 毫秒(防递归轰炸);后台标签页的定时器被大幅节流(分钟级);延迟到达不等于回调执行——回调仍要排队等调用栈与微任务清空。实测:
const t0 = performance.now(); setTimeout(() => { console.log('名义 0ms,实际经过', performance.now() - t0); }, 0); // 实测输出:名义 0ms,实际经过 4.x ms(嵌套层级与队列排队的综合结果)
给个反向案例——用 setTimeout 测量一段同步代码的耗时是测不准的:
const start = performance.now(); let s = 0; for (let i = 0; i < 1e7; i++) s += i; console.log('循环实际耗时', performance.now() - start); // 实测约 8 ms setTimeout(() => console.log('回调看到的时刻', performance.now() - start), 0); // 回调看到的时刻 实测约 12 ms —— 多出的 4ms 是最小延迟与排队
工程结论:动画不用 setTimeout 驱动(用 rAF,第 5 章);精确计时用 performance.now 而不是回调到达时刻;「至少 N 毫秒后」的语义里,N 只会被放大不会被缩小。
单线程的代价在调度上显形:任何一段同步代码占着调用栈,事件、渲染、定时器全部进不来。实测一段 200 毫秒的循环:
console.log('开始阻塞'); const until = performance.now() + 200; while (performance.now() < until) { /* 空转 */ } setTimeout(() => console.log('阻塞结束后才轮到我'), 0); console.log('阻塞结束'); // 输出顺序:开始阻塞 →(200ms 无响应,点击/滚动全部无感)→ 阻塞结束 → 阻塞结束后才轮到我
阻塞期间用户点击会排队(排队过久浏览器直接丢弃合成事件),渲染跳帧,页面呈现「死」的观感。性能领域把超过 50 毫秒的任务定义为长任务,正是以「人能感知的响应上限」划线。
拆解手段有三层。分片:把大循环切块,用 setTimeout(或更现代的 scheduler.yield)在块间让出:
async function chunked(items, worker) { for (let i = 0; i < items.length; i++) { worker(items[i]); if (i % 500 === 499) { await new Promise(r => setTimeout(r, 0)); // 让出一轮,事件与渲染得以插队 } } }
搬走:纯计算搬到 Web Worker 线程(第 5 章的排队现场会再次点名它);改造:算法换复杂度更低的实现,或把一次大任务摊到多次用户交互里。三种手段的共同思想:别让任何一段同步代码占用栈超过 50 毫秒。
⚠️ 常见坑:微任务里死循环比宏任务死循环更狠——微任务队列清不完,循环永远走不到渲染与下一个宏任务,页面直接冻住,连报错都未必来得及显示。宏任务里的死循环至少每轮之间理论上还有渲染机会(虽然实际上也没有)。写递归排微任务的逻辑(then 链递归)务必确认有终止条件。
💡 关键直觉:把事件循环想成一家只有一个窗口的营业厅——微任务是排在你身后的一米线内的加急客户,宏任务是取号机,渲染是营业厅每小时一次的盘点。窗口正在办理的业务(同步代码)不被打断,加急客户清完才叫下一个号。
五道渐进难度的顺序题,先推演再运行。它们覆盖了调度规则的常见组合形态。
第一题(微任务嵌套):
Promise.resolve() .then(() => console.log('A')) .then(() => console.log('B')); Promise.resolve().then(() => console.log('C')); // 实测:A C B —— 第二个 then 排队要等第一个 then 执行完才发生
第二题(await 与 then 混排):
async function f() { console.log('1'); await null; // 等价于 await Promise.resolve(null) console.log('3'); } f(); Promise.resolve().then(() => console.log('2')); console.log('4'); // 实测:1 4 3 2 —— await 之后的延续与 then 同级排队,按入队序
第三题(setTimeout 对顺序的影响):
setTimeout(() => console.log('t1'), 0); setTimeout(() => console.log('t2'), 0); Promise.resolve().then(() => console.log('p')); // 实测:p t1 t2 —— 两个定时器保序(同轮登记按序入队),但都排在微任务后
第四题(同步阻塞插在中间):
Promise.resolve().then(() => { console.log('p1'); const until = performance.now() + 50; while (performance.now() < until) {} // 微任务里阻塞 50ms console.log('p2'); }); setTimeout(() => console.log('t'), 0); // 实测:p1 p2 t —— 微任务队列必须清完才轮到宏任务,阻塞期间 t 干等
第五题(综合):
console.log('s1'); setTimeout(() => console.log('t1')); queueMicrotask(() => console.log('m1')); (async () => { console.log('s2'); await Promise.resolve(); console.log('m2'); })(); console.log('s3'); // 实测:s1 s2 s3 m1 m2 t1
五题的共同解法只有一条:给每个输出标「队列与位次」——同步直接跑,微任务记入队顺序,宏任务垫底。练到不看运行就能全对,任何面试变体题都是同一套动作的换皮。
为什么我的 setTimeout(fn, 1000) 有时明显超过一秒才跑?
三段延迟叠加:最小延迟与节流(嵌套层级、后台标签页);队列排队(前方有长任务或一堆微任务);回调本身的执行时机(等调用栈清空)。需要「至少一秒后」的场合这个误差无妨;需要精确节奏(动画、倒计时)就换 rAF 加时间戳增量,或用 performance.now 自己算。
requestIdleCallback 和微任务是什么关系?
不同层级的东西:微任务在「每个任务之后、下一个任务之前」执行,是调度规则内的插队者;requestIdleCallback 在「一帧的渲染完成后还有富余时间」时执行,是渲染节拍上的顺路工。两者用途互补——状态同步类的小事进微任务,非关键的大杂务进 idle。
把大任务拆成很多微任务分片,可行吗?
不可行且危险。微任务清不完,渲染与宏任务永远轮不到——页面冻结(本节常见坑已警告)。拆片必须回到宏任务粒度:setTimeout 交还控制权,或使用 scheduler.yield 这类语义更明确的让出接口。微任务只做「一小步且必然有限步」的事。
await 后面的代码和 await 前面的代码,还在同一个调用栈上吗?
不在。await 是挂起点:前半段在当前栈帧执行,后半段作为延续进入微任务队列,恢复时从一个新的空栈起步(原栈帧早就弹了)。这就是异步错误的栈快照总是断在回调边界的原因(第 7 章展开),也是「异步递归不爆栈」的原理——每次延续都是新栈。想跟踪跨挂起点的调用关系,错误对象要自己携带上下文(trace ID 这类字段)。
Node 的事件循环和浏览器完全一样吗?
骨架一致、细节不同。一致的骨架:调用栈优先、微任务在合适检查点清空、宏任务逐个执行。不同的细节:Node 把宏任务分成定时器、轮询、检查等多个阶段各有顺序;process.nextTick 排在普通微任务之前;Node 15 起未处理 rejection 默认致命(第 7 章提过)。写跨端代码时,把「微任务先于下一个宏任务」当公理、把具体阶段顺序当平台细节查证,是最稳的姿态。
页面突然卡了,从调度角度先查什么?
按三问排查:一问「有没有长任务」——Performance 录制看主线程长条,超过五十毫秒的红色段就是嫌疑犯,点开看火焰图顶端是哪个函数;二问「有没有微任务风暴」——then 链递归或 await 循环无出口(本节警告过的坑),表现为栈空但页面仍无响应;三问「是不是定时器堆积」——setInterval 回调耗时超过间隔时的排队积压。三问走完,卡顿的「谁占着线程」必有答案,剩下的就是本节三招(分片、搬走、降复杂度)的应用题。
开发者工具里能直接看任务队列吗?
看不到队列本体(那是运行时内部结构),但有等效观测:Performance 面板的主线程轨道按时间排开每个任务,任务的排队延迟(从可执行到真正执行的间隔)会体现在长任务的「等待脚」上;应用性能监控里的「长任务观察器」能按条捕获超过五十毫秒的任务及其归因。把「队列里的等待」当成一种可测的延迟来源,是调度知识与观测工具的接口。
本节的三步舞(宏任务、微任务清空、渲染空档)是全书的枢纽规则:五道顺序题、六类 API 的队列归属、setTimeout 的三层延迟、长任务的三种拆法,全部由它派生。接下来两节看它最重要的两个应用场景:Promise 的状态机与排队,以及 async-await 这颗穿在规则外面的糖衣——两者用的是同一条规则,只是穿着不同的语法外衣。