浏览器里的 JavaScript 是单线程的,却能同时处理用户点击、网络请求、动画与页面渲染——这背后全靠事件循环(Event Loop)。理解事件循环、宏任务与微任务的调度顺序,是写出不卡顿、行为可预测的异步代码的基础。本文用代码示例拆解它的运行机制,并给出长任务优化的实战建议。
一、单线程与调用栈
JS 引擎(如 V8)只有一个调用栈(Call Stack),同一时刻只执行一个任务。函数被调用就入栈,执行完出栈。浏览器之所以选择单线程执行 JS,是为了避免多线程同时操作 DOM 带来的竞态与加锁复杂度——代价就是:如果栈里有一个耗时 3 秒的同步循环,后面所有代码——包括点击事件、网络回调、页面渲染——都会被阻塞,页面直接”假死”。
function syncTask() {
const start = Date.now();
while (Date.now() - start < 3000) {} // 同步阻塞 3 秒
}
syncTask();
console.log('这行要等 3 秒后才打印');
二、宏任务与微任务:两条队列
事件循环并不是简单的”排队执行”。它维护两类队列:宏任务队列(Macrotask)与微任务队列(Microtask)。每执行完一个宏任务,引擎会先把微任务队列清空,再去取下一个宏任务,并在合适时机渲染页面。
2.1 宏任务(Macrotask)
常见来源包括 setTimeout、setInterval、I/O 回调、UI 事件、MessageChannel,以及动画相关的 requestAnimationFrame(在不同规范里归属略有差异)。
2.2 微任务(Microtask)
常见来源包括 Promise.then / catch / finally、queueMicrotask、MutationObserver。微任务的优先级高于”下一个宏任务”——当前宏任务一结束,微任务会立刻被全部执行。
三、执行顺序:一次循环干了什么
一次事件循环 tick 的流程是:取一个宏任务 → 执行 → 清空所有微任务 → 择机渲染 → 取下一个宏任务。关键规则只有一句:微任务会在当前宏任务结束后、下一个宏任务开始前全部跑完。
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出顺序:1 → 4 → 3 → 2
// 解析:1、4 是同步代码(同属首个宏任务);
// 4 执行完后,微任务 3 先清空,再轮到宏任务 2
3.1 async/await 的真实身份
async 函数里 await 之后的代码,本质上等价于包在 Promise.then 里的微任务。所以它不会立即执行,而是在微任务队列中排队。
async function demo() {
console.log('a');
await null;
console.log('b'); // 微任务,进入微任务队列
}
demo();
console.log('c');
// 输出顺序:a → c → b
四、渲染时机与 requestAnimationFrame
浏览器并非每次宏任务后都渲染,而是按屏幕刷新率(通常 60Hz,约 16.7ms 一帧)合并渲染。如果一段 JS 执行超过 50ms,就会跳过多次渲染机会,造成掉帧与卡顿。requestAnimationFrame(rAF)回调会在渲染前执行,最适合做动画;而耗时逻辑应尽量切成小任务,主动让出主线程。
经验法则:单帧预算约 16.7ms,其中 JS 执行最好控制在 10ms 以内,把剩余时间留给样式计算与绘制。一旦某次宏任务或微任务执行超过 50ms,Performance 面板就会把它标记为 “Long Task(长任务)”,这正是页面卡顿的直接信号。所以动画相关的计算优先交给 rAF,而大数组排序、海量数据解析等重活,应当切片处理或移出主线程。
| 维度 | 宏任务 Macrotask | 微任务 Microtask |
| 常见来源 | setTimeout/setInterval、I/O、UI 事件、MessageChannel | Promise.then、queueMicrotask、MutationObserver |
| 执行时机 | 每个 tick 取一个 | 当前宏任务结束后立即清空 |
| 优先级 | 较低(会被微任务插队) | 较高(先于下一个宏任务) |
| 典型用途 | 延迟、定时、网络回调 | 状态更新、批量 DOM 合并 |
五、实战三大坑
5.1 微任务饿死宏任务
若在微任务里不断产生新微任务(如 Promise 递归循环),宏任务和渲染永远得不到执行,页面直接卡死。务必在循环中穿插 await 或 setTimeout 让出主线程。
// 危险:微任务永动机,渲染被饿死
function loop() {
Promise.resolve().then(loop);
}
loop();
5.2 setTimeout(fn, 0) 并不”立即”
即使延迟写 0,它也只是宏任务,必须等当前微任务清空且轮到它才会触发;同时受浏览器最小延迟(约 4ms)节流限制,千万别把它当”立刻执行”。
5.3 长任务切分:用 scheduler 让出主线程
大数据处理可用 setTimeout(0) 或 requestIdleCallback 切片。现代方案是 scheduler.yield()(正在标准化),能在循环中主动让出主线程,避免制造长任务。
async function chunked(items, process) {
for (const item of items) {
process(item);
if (navigator.scheduling && navigator.scheduling.yield) {
await scheduler.yield(); // 主动让出,避免阻塞渲染
}
}
}
六、性能优化启示
借助 Performance 面板查看 “Long Task”(执行 > 50ms)定位阻塞点:把重计算迁移到 Web Worker 或切片处理,用虚拟列表减少 DOM 数量,配合 rAF 做动画——这些优化都建立在”别占着调用栈不放”这一认知上。关于指标与监控落地,可参考站内《前端性能优化:Lighthouse 90+ 到首屏 1s》与《前端性能监控:Web Vitals 与 Sentry 实战》。
七、进阶实战:定时器与微任务的混合排序
真实业务里经常同时出现多个 setTimeout 与 Promise,光记规则不够,还要会逐步推演。下面这段代码常被拿来考察,我们逐行拆解它的输出顺序:
console.log('start');
setTimeout(() => console.log('A'), 0);
Promise.resolve().then(() => console.log('B'));
setTimeout(() => console.log('C'), 10);
Promise.resolve().then(() => console.log('D'));
console.log('end');
// 输出:start → end → B → D → A → C
// 推演:start、end 是同步代码,先打印;
// 同步结束后清空微任务 B、D;
// 再取宏任务,延迟 0ms 的 A 先于延迟 10ms 的 C
关键认知:微任务队列在每次宏任务之间都会被整体清空,而 setTimeout 的延迟只决定它”最早”何时进入宏任务队列,并不代表精确时刻。把有顺序依赖的逻辑放进同一个微任务(Promise)里,比用 setTimeout(fn, 0) 更可靠、也更早执行。
另一个易错点是 Promise 链式调用的微任务切分:.then 的回调永远是异步(微任务),即使前一个 Promise 已经 resolved。所以连续的 await 会在每次让出后重新排队——这也解释了为什么”看似同步的 async 函数”永远不会阻塞事件循环:它本质上是一串微任务的接力。
八、总结
事件循环可以浓缩成一句话:一个宏任务 + 清空微任务 + 择机渲染。记住”微任务插队、宏任务排队、渲染合并”十二字,就能预判绝大多数异步时序问题;而把耗时逻辑切片、主动让出主线程,则是流畅体验的真正关键。




