K 的一隅

JavaScript JavaScript 核心机制

定时器明明到点了,为什么还要等:任务与事件循环

从预约取餐却仍要排队说起,先弄清同步跑完与定时器回调的关系,再引入宏任务、微任务与事件循环。

8 分钟阅读 更新于 2026-07-28
本文目录

下午六点,你打电话给餐馆:“十分钟后到,帮我留一份炒饭。”店员答应了。六点十分进门时,灶台正被一桌十人餐占着,你还是得等。

预约时间表示“从这时起可以处理”,不是“这一秒必定交到你手上”。setTimeout 的延迟也更接近这个意思。

前三章已经把同步调用栈、词法环境与 this 绑定铺好。从本章起,讨论暂时拿不到执行权的代码。你现在只需要先抓住一件事:当前这段同步脚本跑完之前,定时器回调插不进来。 微任务、渲染时机是下一层;建议先扫一眼动图,再对照下面的例子。

先看结果:零毫秒为什么还是最后

js
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,仍要排队等当前任务处理完,这就是页面“点了没反应”时最该怀疑的一层。

教学模型:一条宏队列 + 一条微队列

下面这张动图是本章的核心模型:同步代码在执行栈里依次压入、弹出;异步回调进入队列,等栈空后由事件循环取出执行

事件循环动图:执行栈处理同步代码,宏任务队列与微任务队列交替被清空
图 1:一轮事件循环中,执行栈、宏任务队列(MacroTask)与微任务队列(MicroTask)如何配合。先按「一条宏 + 一条微」记住顺序即可。

读图时按这个顺序理解一次 tick

  1. 执行栈:当前任务里的同步代码从上到下执行,函数调用压栈,返回出栈。
  2. 栈空后 · 微任务:清空微任务队列(Promise.thenqueueMicrotask 等),一直清到没有新微任务为止。
  3. 可能渲染:浏览器在合适时机更新视图(第五章细讲 requestAnimationFrame)。
  4. 下一项宏任务:从宏任务队列取一个回调(setTimeout、点击等),当作新的任务,回到步骤 1。

社区常说的「宏任务 / 微任务」,对应动图里的 MacroTask Queue 与 MicroTask Queue。规范里更常写 taskmicrotask checkpoint;面试沟通用宏/微即可。

脚注:真实浏览器可按任务源分多列宏队列(计时器、用户交互、网络完成等),同一源内保序,跨源由宿主挑选。动图画成一条宏队列,是为了先把「同步 → 微任务 → 下一宏任务」讲清楚;排复杂题时再想任务源即可。

对照表(不必背全):

常见宏任务(task)常见微任务
整体 <script> 脚本Promise.then / catch / finally
setTimeout / setInterval 回调queueMicrotask
DOM 事件(click、input 等)MutationObserver 回调
postMessageMessageChannel部分环境下的 await 续体

下一层:为什么 promise.then 比 setTimeout 先

上面已经够解释「零毫秒定时器仍排在同步之后」。若代码里同时出现 Promise.then,还要多记一层:微任务插在当前宏任务尾部,排在下一轮宏任务之前。

js
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同步 3promise.thensetTimeout

阶段发生什么
当前宏任务 · 同步打印 1;登记定时器;执行 Promise executor 打印 2;登记 .then;打印 3
微任务检查点执行 promise.then
下一轮宏任务执行 setTimeout 回调

要点:new Promise(fn) 里的 fn同步的;.then 才是微任务。setTimeout 由宿主计时,到期后回调进入宏任务队列,要等当前轮的同步和微任务都结束。

记顺序可以先用这句:微任务像「写在当前事件循环尾部」、宏任务像「写在下一轮开头」。再深一层:setTimeout 到点后只是把回调放进宏任务队列,真正执行仍要等栈空、微任务清完;若微任务里不断 queueMicrotask 递归,宏任务可能长期轮不到(第五章叫微任务饥饿)。

定时器到点,只是获得排队资格

js
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 = 都会立刻闪一帧。

js
status.textContent = '正在处理'
heavyWork()
status.textContent = '处理完成'

heavyWork() 始终不把执行权还给事件循环,用户可能只看到“处理完成”。requestAnimationFrame 挂在渲染管线里,既不算普通宏任务,也不算 Promise 微任务——第五章单独画它在哪一格。

一项任务会一口气跑完(run-to-completion)

事件处理器里的循环、长计算不会中途被另一个点击切开。好处是同步代码不必防每行之间状态被改掉;代价是长任务占住主线程时,点击、滚动、绘制都会拖后。Performance 面板里标出的长任务,就是这个信号。

把大活切成 setTimeout(processBatch, 0) 不是让计算变少,而是批次之间把执行权还给浏览器,让输入和渲染有机会插入。批次仍占 300ms 一样会卡;块越小调度开销越大,要在 Performance 里量。纯 CPU 重活更适合 Web Worker(第十三章相关)。

js
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。

下一步:微任务与渲染时机

js
setTimeout(() => console.log('timeout'), 0)
Promise.resolve().then(() => console.log('promise'))
console.log('sync')

上一节已能解释 sync → promise → timeout。下一章专门拆微任务检查点为何可能“饿死”渲染,以及 requestAnimationFrame 站在哪一步。读第五章时可以把本章动图放在旁边:先弄清宏、微两队列,再往里加“绘制”那一格,层次会更清楚。