从一次“延迟失效”说起
很多前端开发者都遇到过:明明写了 setTimeout(fn, 0),fn 却迟迟不执行;或者 setTimeout(fn, 100) 实际延迟远超 100ms。这不是浏览器“不守时”,而是事件循环(Event Loop)的调度机制在起作用。
事件循环的基本模型
浏览器的主线程同一时刻只能执行一段 JavaScript。事件循环不断重复以下过程:
- 从任务队列中取出一个任务执行;
- 执行完毕后,清空微任务队列;
- 视情况渲染页面;
- 回到第 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的延迟是“至少等待”,不是“精确等待”。- 主线程阻塞会推迟所有定时器回调。
- 后台标签页的定时器会被浏览器节流。
实践建议
- 需要精确计时时,不要依赖
setTimeout的延迟参数,可以用performance.now()做补偿。 - 避免长同步任务,把大计算拆成小块或用
requestIdleCallback。 - 需要“尽快但可让出主线程”的场景,优先用
queueMicrotask或Promise.resolve().then。 - 动画相关逻辑用
requestAnimationFrame,它跟随渲染节奏,比setTimeout更合适。
理解事件循环,才能写出可预测的异步代码。