如果你写过 JavaScript,一定听过"事件循环"这个词。它几乎是前端面试的必考题,但很多人的理解停留在"宏任务、微任务、执行顺序"这几个关键词上,背完答案就忘。
这篇文章换一个角度:从"浏览器为什么要这样设计"开始,把事件循环的来龙去脉彻底讲透。
这是两个经常被混用的词:
打个比方:进程像一栋办公楼,线程是里面的工位。一栋楼可以有很多工位,但所有工位共享这栋楼的水电网。
Chrome 这类现代浏览器,不是只有一个进程。主要分三种:
渲染进程启动后,会开一个渲染主线程,负责下面这些事情:
问题来了:这么多事情,全交给一条主线程,怎么调度才能不打架?
浏览器的答案是:事件循环(Event Loop)。可以理解成一条不停转动的流水线:
关键的是第 2 步:任务不一定是主线程自己生成的。浏览器进程可以往队列里塞任务(比如用户点了按钮),网络进程可以往队列里塞任务(比如请求回来了),定时器线程也可以往队列里塞任务(计时到了)。
所有线程都可以往消息队列里丢任务,主线程只负责"一个一个取出来执行"。这就是"事件驱动"的由来。
btn.onClick = () => {
h1.innerText = 'new text';
sleep(3000); // 一个同步阻塞函数,要跑 3 秒
};
完整过程:
sleep(3000) 时,主线程在这卡 3 秒——这期间页面滚不动、点不动,因为主线程被同步代码占了sleep 执行完、整个回调函数跑完,主线程才能继续渲染页面这也是为什么"同步阻塞很可怕"——它会堵住整条流水线,用户的操作全卡在队列里排队。
如果所有任务都放一条队列,那就会出现"后面排着十万个任务,新来的用户点击等半天才响应"的情况。
所以浏览器实际上不只一条消息队列。每种任务类型有自己专属的队列,优先级不同。Chrome 目前的实现至少包含:
至于"宏队列"这个词——W3C 标准里已经不再使用这种粗粒度的分类。只记住一点就行:微队列的优先级高于其他所有队列。每次主线程执行完一个"普通任务",都会先把微队列里的任务全部清空,才去看下一个队列。
rAF 有自己独立的队列。简单的理解:在微任务执行完毕后、渲染更新开始前执行。它是专门为"在下一次重绘前更新动画"设计的,所以如果你的动画逻辑放在 rAF 里,浏览器会在绘制前统一处理,避免不必要的重复绘制。
console.log('A (同步)');
setTimeout(() => {
console.log('B (宏任务1)');
Promise.resolve().then(() => {
console.log('C (微任务1 - 来自宏任务1)');
});
}, 0);
Promise.resolve().then(() => {
console.log('D (微任务1)');
setTimeout(() => {
console.log('E (宏任务2)');
Promise.resolve().then(() => {
console.log('F (微任务2 - 来自宏任务2)');
});
}, 0);
});
(async () => {
console.log('G (async 同步部分)');
await Promise.resolve();
console.log('H (async await 之后 - 微任务)');
setTimeout(() => {
console.log('I (宏任务3)');
Promise.resolve().then(() => {
console.log('J (微任务3 - 来自宏任务3)');
});
}, 0);
})();
Promise.resolve().then(() => {
console.log('K (微任务2)');
});
答案:A → G → D → H → K → B → C → E → F → I → J
逐帧拆解:
D、H(await 后面的部分本质是 Promise.then)、K 都在微任务队列里,按注册顺序执行如果你理解了这个顺序,事件循环的调度逻辑你就真的懂了。