为什么 `setTimeout(fn, 0)` 并不会立即执行?一次事件循环背后的真相

299 阅读3分钟

很多 JavaScript 开发者第一次接触 setTimeout(fn, 0) 时,都会产生这样的疑问:

“我都设置 0 毫秒了,为什么 fn 不是立刻执行?”

更让人困惑的是,把它放在某些异步场景中,它执行得“似乎又慢了一拍”。如果你曾尝试用 setTimeout(fn, 0) 去“打断”同步逻辑流,可能会惊讶于它的“不配合”。

究竟,浏览器是如何调度这些任务的?setTimeout 到底归属哪一类任务队列?它为什么不立即执行?

我们要搞清楚这个问题,就得穿越到 事件循环 的核心。


JavaScript 中的任务分类:MacroTask vs MicroTask

在 JavaScript 中,任务被划分为两类:

  • 宏任务(MacroTask) :包括 setTimeoutsetIntervalsetImmediateMessageChannel、I/O 等。
  • 微任务(MicroTask) :包括 Promise.thenMutationObserverqueueMicrotask

一轮事件循环的执行顺序是:

  1. 执行一个宏任务(如 script)
  2. 执行所有在这轮中产生的微任务队列(直到清空)
  3. 渲染 UI
  4. 进入下一轮宏任务

所以当你执行:

console.log('start');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('microtask'));
console.log('end');

输出是:

start
end
microtask
timeout

0 毫秒不是“立刻执行”,而是“尽快调度”,但仍排队在下一轮宏任务的队列中。


为什么 setTimeout(fn, 0) 是“假异步”?

从浏览器的角度,setTimeout(fn, 0) 表示:

“请最少等待 0 毫秒之后,再执行 fn。”

这句话并不等价于 “立即执行 fn”。

浏览器为了调度公平性和效率,内部会对 setTimeout(fn, 0) 强制设置一个 最小延迟阈值,即 clamp 时间,通常在不同浏览器中约为 4ms 到 16ms(尤其在嵌套调用 setTimeout 的时候,Chrome 会将其强制设置为 4ms 以上)。

你可以验证:

let start = Date.now();
setTimeout(() => {
  console.log(Date.now() - start);
}, 0);

输出往往是 4ms+ ,而不是 0。


那微任务为什么那么快?

微任务,是专门设计来在当前宏任务结束后立即执行的,因此:

Promise.resolve().then(() => console.log('microtask'));

它几乎就是 当前执行上下文完成后的“尾部钩子” ,而不会被推迟到下一轮事件循环。

所以微任务更适合:

  • 精确控制异步逻辑流转
  • 在 DOM 更新前做校验
  • 替代回调中的回调(callback hell)

真实案例:setTimeout 被 Promise 抢戏

setTimeout(() => console.log('timeout 1'), 0);
Promise.resolve().then(() => console.log('microtask'));
setTimeout(() => console.log('timeout 2'), 0);

输出:

microtask
timeout 1
timeout 2

这个输出常被误解为“setTimeout 的顺序错乱了”。其实原因很简单:

  • 所有 setTimeout 都排队到“下一轮宏任务”
  • Promise.then 是“当前轮”的“微任务”

事件循环的核心隐喻:会议室 vs 小纸条

可以用一个轻松的比喻来理解事件循环:

  • 宏任务就像会议室里的正式议题,每次只开一个大会,之后才能再开。
  • 微任务像会议期间传的小纸条,只要有纸条,主持人都得处理完,才继续下个议题。

所以:

  1. 当前会议结束(宏任务执行完)
  2. 看看有没有小纸条(执行所有微任务)
  3. 开下一个会(开始新的宏任务)

setTimeout 的实际用途和局限

✅ 合适用途:

  • 打断长时间运行的同步代码,让 UI 有机会刷新
  • 节流、去抖逻辑的实现
  • 轮询机制(不过现在推荐用 requestAnimationFrame / setInterval

❌ 不合适的场景:

  • 控制精确的执行顺序(应优先用 Promise
  • 依赖精确延迟时间(不如 performance.now() + RAF)

微妙陷阱:setTimeout 嵌套 setTimeout

function loop() {
  console.log(Date.now());
  setTimeout(loop, 0);
}

loop();

你以为这个是“无限快速循环”?实际上,每一轮至少 4ms。浏览器为了避免卡死页面,会强制增加延迟

所以这不适合作为“无限轮询”逻辑(推荐 requestAnimationFrame)。


总结回顾

  • setTimeout(fn, 0) 并不立即执行,它只是放到“下一轮宏任务”。
  • Promise.then 是微任务,在当前宏任务结束后立刻执行。
  • 微任务的优先级 > 宏任务。
  • 浏览器存在最小延迟机制(clamp),让 0 毫秒实为 4 毫秒+。
  • 实际项目中,优先用微任务控制异步执行顺序,避免滥用 setTimeout