花拾录
← 返回知识库

浏览器事件循环与任务队列:为什么 setTimeout 不按时执行

前端开发AI2026/09/240 阅读0 评论

从一次“延迟失效”说起

很多前端开发者都遇到过:明明写了 setTimeout(fn, 0),fn 却迟迟不执行;或者 setTimeout(fn, 100) 实际延迟远超 100ms。这不是浏览器“不守时”,而是事件循环(Event Loop)的调度机制在起作用。

事件循环的基本模型

浏览器的主线程同一时刻只能执行一段 JavaScript。事件循环不断重复以下过程:

  1. 从任务队列中取出一个任务执行;
  2. 执行完毕后,清空微任务队列;
  3. 视情况渲染页面;
  4. 回到第 1 步。

这里有两个关键角色:任务队列(Task Queue) 和 微任务队列(Microtask Queue)。

  • 宏任务(Task):setTimeout、setInterval、setImmediate(Node.js)、I/O、UI 事件回调等。
  • 微任务(Microtask):Promise.then、queueMicrotask、MutationObserver 等。

事件循环每执行完一个宏任务,都会把当前微任务队列全部清空,然后才进入下一个宏任务。

为什么 setTimeout 不“按时”

1. 最小延迟限制

HTML 规范规定,嵌套层级超过 5 层的 setTimeout,最小延迟为 4ms。也就是说,setTimeout(fn, 0) 在多次嵌套后实际至少延迟 4ms。这不是 bug,而是规范行为。

2. 主线程被占用

如果当前正在执行一个耗时很长的同步任务,事件循环无法取出下一个宏任务,定时器回调只能排队等待。例如:

setTimeout(() => console.log('timer'), 0);
const start = Date.now();
while (Date.now() - start < 1000) {}
console.log('done');

输出顺序是 done 先于 timer,因为 1 秒的同步循环阻塞了事件循环。

3. 微任务的“插队”

微任务优先级高于下一个宏任务。如果微任务不断产生新的微任务,宏任务就会被一直推迟:

setTimeout(() => console.log('timer'), 0);
Promise.resolve().then(() => {
  console.log('microtask');
});
console.log('sync');
// 输出:sync -> microtask -> timer

4. 页面不可见时的节流

浏览器对后台标签页的定时器有节流策略。页面不可见时,setTimeout 的最小间隔可能被提升到 1000ms 甚至更高,以节省资源。

可验证的结论

  • 宏任务之间会清空微任务队列,微任务先于下一个宏任务执行。
  • setTimeout 的延迟是“至少等待”,不是“精确等待”。
  • 主线程阻塞会推迟所有定时器回调。
  • 后台标签页的定时器会被浏览器节流。

实践建议

  1. 需要精确计时时,不要依赖 setTimeout 的延迟参数,可以用 performance.now() 做补偿。
  2. 避免长同步任务,把大计算拆成小块或用 requestIdleCallback。
  3. 需要“尽快但可让出主线程”的场景,优先用 queueMicrotask 或 Promise.resolve().then。
  4. 动画相关逻辑用 requestAnimationFrame,它跟随渲染节奏,比 setTimeout 更合适。

理解事件循环,才能写出可预测的异步代码。

评论(0)

  • 还没有评论,来抢沙发~

相关文章