把事件循环彻底讲清楚:从进程线程到一道经典面试题
如果你写过 JavaScript,一定听过"事件循环"这个词。它几乎是前端面试的必考题,但很多人的理解停留在"宏任务、微任务、执行顺序"这几个关键词上,背完答案就忘。
这篇文章换一个角度:从"浏览器为什么要这样设计"开始,把事件循环的来龙去脉彻底讲透。
先分清进程和线程
这是两个经常被混用的词:
- 进程(Process):操作系统分配资源的单位。打开 Chrome 浏览器,Chrome 就是一个进程;打开 VS Code,VS Code 是另一个进程。进程之间相互隔离,一个崩了不影响另一个。
- 线程(Thread):进程里实际干活的单位。一个进程至少有一个线程,也可以有多个线程共享同一块内存。
打个比方:进程像一栋办公楼,线程是里面的工位。一栋楼可以有很多工位,但所有工位共享这栋楼的水电网。
浏览器开了哪些进程?
Chrome 这类现代浏览器,不是只有一个进程。主要分三种:
- 浏览器进程:管界面(地址栏、前进后退、书签栏),也管子进程的创建和销毁。它自己不渲染页面。
- 网络进程:专门负责网络请求。加载 HTML、CSS、JS、图片都是它在干。
- 渲染进程:这才是前端的主战场。每个标签页默认开一个独立的渲染进程,这样 A 标签页崩了不会拖 B 标签页下水。
渲染进程启动后,会开一个渲染主线程,负责下面这些事情:
- 解析 HTML、解析 CSS
- 计算样式、布局
- 处理图层
- 每秒把页面往屏幕上画 60 次
- 执行全局 JS 代码
- 执行事件处理函数(点击、滚动、键盘)
- 执行定时器回调
- ……等等
问题来了:这么多事情,全交给一条主线程,怎么调度才能不打架?
事件循环:一条永不停歇的流水线
浏览器的答案是:事件循环(Event Loop)。可以理解成一条不停转动的流水线:
- 渲染主线程进入一个无限循环
- 每次循环,先看一眼"消息队列"里有没有任务
- 有就取一个出来执行,执行完进入下一次循环
- 没有就等着,等有新任务加入队列
关键的是第 2 步:任务不一定是主线程自己生成的。浏览器进程可以往队列里塞任务(比如用户点了按钮),网络进程可以往队列里塞任务(比如请求回来了),定时器线程也可以往队列里塞任务(计时到了)。
所有线程都可以往消息队列里丢任务,主线程只负责"一个一个取出来执行"。这就是"事件驱动"的由来。
举个例子:点按钮发生了什么?
btn.onClick = () => {
h1.innerText = 'new text';
sleep(3000); // 一个同步阻塞函数,要跑 3 秒
};
完整过程:
- 浏览器有个"交互线程",专门监听用户的点击、滚动等操作
- 用户点了按钮 → 交互线程把回调函数包装成"任务对象",丢进消息队列
- 主线程当前手头的事做完,从队列里取出这个任务
- 执行到
sleep(3000)时,主线程在这卡 3 秒——这期间页面滚不动、点不动,因为主线程被同步代码占了 sleep执行完、整个回调函数跑完,主线程才能继续渲染页面
这也是为什么"同步阻塞很可怕"——它会堵住整条流水线,用户的操作全卡在队列里排队。
任务的优先级:不只一条队列
如果所有任务都放一条队列,那就会出现"后面排着十万个任务,新来的用户点击等半天才响应"的情况。
所以浏览器实际上不只一条消息队列。每种任务类型有自己专属的队列,优先级不同。Chrome 目前的实现至少包含:
- 微队列(Microtask Queue):优先级最高。Promise.then、MutationObserver 的回调在这里
- 交互队列:用户操作产生的事件处理任务,优先级高——浏览器知道用户等不起
- 延迟队列:定时器回调(setTimeout、setInterval),优先级中——多等几毫秒用户一般感觉不到
至于"宏队列"这个词——W3C 标准里已经不再使用这种粗粒度的分类。只记住一点就行:微队列的优先级高于其他所有队列。每次主线程执行完一个"普通任务",都会先把微队列里的任务全部清空,才去看下一个队列。
requestAnimationFrame 插在哪?
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
逐帧拆解:
- 同步代码从上往下跑:先打 A,再打 G(async 函数里 await 之前都是同步的)
- 同步代码跑完,微任务队列清空:
D、H(await 后面的部分本质是 Promise.then)、K都在微任务队列里,按注册顺序执行 - 微任务清完,取下一个宏任务。此时延迟队列里有 B(第一个 setTimeout)和 E(在 D 里注册的 setTimeout)、I(在 H 里注册的 setTimeout)。先入先出,执行 B → 打 B
- B 执行中往微任务里塞了 C → 清微任务 → 打 C
- 下一个宏任务 E → 打 E,塞 F 到微任务 → 清微任务 → 打 F
- 下一个宏任务 I → 打 I,塞 J 到微任务 → 清微任务 → 打 J
如果你理解了这个顺序,事件循环的调度逻辑你就真的懂了。