本文目录
下午六点,你打电话给餐馆:“十分钟后到,帮我留一份炒饭。”店员答应了。六点十分进门时,灶台正被一桌十人餐占着,你还是得等。
预约时间表示“从这时起可以处理”,不是“这一秒必定交到你手上”。setTimeout 的延迟也更接近这个意思。
前三章已经把同步调用栈、词法环境与 this 绑定铺好。从本章起,讨论暂时拿不到执行权的代码。你现在只需要先抓住一件事:当前这段同步脚本跑完之前,定时器回调插不进来。 微任务、渲染时机是下一层;建议先扫一眼动图,再对照下面的例子。
先看结果:零毫秒为什么还是最后
console.log('开始点单')
setTimeout(() => {
console.log('定时器回调')
}, 0)
console.log('点单结束')结果固定是:开始点单 → 点单结束 → 定时器回调。
延迟写成 0,回调仍没有插进两行同步代码之间——当前脚本本身就是一项任务,必须整段跑完,宿主才能从队列里取下一项。可以把这段代码和动图叠在一起看:前两行 console.log 在执行栈里直接跑完;setTimeout 那一行只是把回调丢进宏任务队列,函数体要等下一轮才开始。
浏览器里的异步:不是 JS 自己在多线程
“JavaScript 是单线程的”指的是:执行 JS 代码的通常只有一条主线程(JS 引擎线程)。浏览器还会有计时、网络等后台能力(实现细节因引擎而异,不必死记成固定编制表),它们不负责跑你的业务脚本,而是帮异步任务“干活”,干完后把回调登记进队列,等主线程来取。
| 角色 | 做什么 |
|---|---|
| JS 引擎主线程 | 跑同步代码、事件回调、微任务 |
| 计时能力 | setTimeout / setInterval 到时后,把回调放进宏任务队列 |
| 网络能力 | 发请求、收响应,完成后把处理逻辑交回主线程 |
打个比方:主线程像柜台唯一的收银员,计时和网络像后厨——它们可以并行准备,但结账(执行 JS)永远只有收银员一个人,准备好的单子只能排队等叫号。
所以在浏览器页里,异步通常不是 V8 单独“开线程跑你的业务”,而是宿主提供后台能力 + 事件循环调度回调。Node 另有自己的模型,本章先盯浏览器。
事件驱动:谁在叫号
浏览器里的点击、setTimeout 到点、请求返回,都是事件。事件触发后,对应的处理函数不会立刻插进正在跑的同步代码,而是先进入队列;等当前执行栈清空,事件循环再取出一项来执行。每一次「取任务 → 执行 → (后面会讲)清微任务 → 可能渲染」就是一轮 tick。
这也是为什么说:理解事件循环,等于理解「异步回调究竟在等什么」——等的不是神秘魔法,而是主线程何时空下来。点击按钮时,浏览器可以先在别的线程里记录坐标,但你的 onclick 里写的 JS,仍要排队等当前任务处理完,这就是页面“点了没反应”时最该怀疑的一层。
教学模型:一条宏队列 + 一条微队列
下面这张动图是本章的核心模型:同步代码在执行栈里依次压入、弹出;异步回调进入队列,等栈空后由事件循环取出执行。

读图时按这个顺序理解一次 tick:
- 执行栈:当前任务里的同步代码从上到下执行,函数调用压栈,返回出栈。
- 栈空后 · 微任务:清空微任务队列(
Promise.then、queueMicrotask等),一直清到没有新微任务为止。 - 可能渲染:浏览器在合适时机更新视图(第五章细讲
requestAnimationFrame)。 - 下一项宏任务:从宏任务队列取一个回调(
setTimeout、点击等),当作新的任务,回到步骤 1。
社区常说的「宏任务 / 微任务」,对应动图里的 MacroTask Queue 与 MicroTask Queue。规范里更常写 task 与 microtask checkpoint;面试沟通用宏/微即可。
脚注:真实浏览器可按任务源分多列宏队列(计时器、用户交互、网络完成等),同一源内保序,跨源由宿主挑选。动图画成一条宏队列,是为了先把「同步 → 微任务 → 下一宏任务」讲清楚;排复杂题时再想任务源即可。
对照表(不必背全):
| 常见宏任务(task) | 常见微任务 |
|---|---|
整体 <script> 脚本 | Promise.then / catch / finally |
setTimeout / setInterval 回调 | queueMicrotask |
| DOM 事件(click、input 等) | MutationObserver 回调 |
postMessage、MessageChannel | 部分环境下的 await 续体 |
下一层:为什么 promise.then 比 setTimeout 先
上面已经够解释「零毫秒定时器仍排在同步之后」。若代码里同时出现 Promise.then,还要多记一层:微任务插在当前宏任务尾部,排在下一轮宏任务之前。
console.log('同步 1')
setTimeout(() => console.log('setTimeout'), 0)
new Promise((resolve) => {
console.log('同步 2')
resolve()
}).then(() => console.log('promise.then'))
console.log('同步 3')输出:同步 1 → 同步 2 → 同步 3 → promise.then → setTimeout。
| 阶段 | 发生什么 |
|---|---|
| 当前宏任务 · 同步 | 打印 1;登记定时器;执行 Promise executor 打印 2;登记 .then;打印 3 |
| 微任务检查点 | 执行 promise.then |
| 下一轮宏任务 | 执行 setTimeout 回调 |
要点:new Promise(fn) 里的 fn 是同步的;.then 才是微任务。setTimeout 由宿主计时,到期后回调进入宏任务队列,要等当前轮的同步和微任务都结束。
记顺序可以先用这句:微任务像「写在当前事件循环尾部」、宏任务像「写在下一轮开头」。再深一层:setTimeout 到点后只是把回调放进宏任务队列,真正执行仍要等栈空、微任务清完;若微任务里不断 queueMicrotask 递归,宏任务可能长期轮不到(第五章叫微任务饥饿)。
定时器到点,只是获得排队资格
const startedAt = performance.now()
setTimeout(() => {
console.log('实际等待:', performance.now() - startedAt)
}, 20)
while (performance.now() - startedAt < 100) {}20ms 时宿主可以认为计时“到点”,但主线程仍卡在 while 里,回调只能挂在宏任务队列里等。延迟参数是最早可执行的下界,不是精确闹钟。同步代码越长、微任务越多,定时器误差越大——界面上“每秒跳字”偶尔跳过一秒,常见原因就在这里。
补充两个细节:
- 浏览器里
setTimeout(fn, 0)并不保证 0ms,嵌套层级深了还可能被钳到至少 4ms。 - 页面在后台时,定时器会被明显节流,DevTools 里看到的延迟和前台不可直接类比。
视图更新:为什么在微任务之后
动图里若看到 DOM 已改、屏幕还没变,也是同一道理:改的是内存里的文档树,绘制要等事件循环把机会让给渲染。微任务队列清空后,浏览器才可能合并多次 DOM 变更,走样式、布局、绘制;不是每次 textContent = 都会立刻闪一帧。
status.textContent = '正在处理'
heavyWork()
status.textContent = '处理完成'若 heavyWork() 始终不把执行权还给事件循环,用户可能只看到“处理完成”。requestAnimationFrame 挂在渲染管线里,既不算普通宏任务,也不算 Promise 微任务——第五章单独画它在哪一格。
一项任务会一口气跑完(run-to-completion)
事件处理器里的循环、长计算不会中途被另一个点击切开。好处是同步代码不必防每行之间状态被改掉;代价是长任务占住主线程时,点击、滚动、绘制都会拖后。Performance 面板里标出的长任务,就是这个信号。
把大活切成 setTimeout(processBatch, 0) 不是让计算变少,而是批次之间把执行权还给浏览器,让输入和渲染有机会插入。批次仍占 300ms 一样会卡;块越小调度开销越大,要在 Performance 里量。纯 CPU 重活更适合 Web Worker(第十三章相关)。
let index = 0
const items = Array.from({ length: 20_000 }, (_, i) => i)
function processBatch() {
const end = Math.min(index + 400, items.length)
while (index < end) {
consume(items[index++])
}
if (index < items.length) setTimeout(processBatch, 0)
}
processBatch()切片时要记得取消:组件卸载时 clearTimeout、移除监听、取消 fetch,避免队列里留着已不需要的宏任务。这和 run-to-completion 不矛盾——取消的是还没开始执行的回调,不是从函数中间硬掐断。
容易踩的坑
误解一:延迟到了会抢占当前代码。 不会,宏任务不能切开正在跑的同步代码。
误解二:零毫秒等于下一行。 下一行仍属当前任务,必先执行。
误解三:有单独的「渲染队列」和定时器轮流出队。 渲染是微任务之后、下一宏任务之前的阶段;requestAnimationFrame 挂在渲染管线里,不是第三条与 setTimeout 并列的 JS 队列。
误解四:事件循环就是轮询一个数组。 还要执行任务源规则、微任务检查点、文档可见性等,示意图不是源码。
误解五:看懂动图就等于会排所有题。 题目若混了 async/await、多个 Promise 链或不同事件源,仍要回到「当前是哪一轮宏任务、微任务检查点有没有结束」逐行推;动图是地图,不是替你代算的 GPS。
下一步:微任务与渲染时机
setTimeout(() => console.log('timeout'), 0)
Promise.resolve().then(() => console.log('promise'))
console.log('sync')上一节已能解释 sync → promise → timeout。下一章专门拆微任务检查点为何可能“饿死”渲染,以及 requestAnimationFrame 站在哪一步。读第五章时可以把本章动图放在旁边:先弄清宏、微两队列,再往里加“绘制”那一格,层次会更清楚。