深入理解事件循环&一些常见的疑惑解答
1. 先从浏览器开始:进程与线程
在聊事件循环之前,得先弄明白浏览器是怎么组织工作的。
- 进程:操作系统分配资源(内存、文件句柄等)的基本单位。每个进程有自己独立的内存空间。
- 线程:进程内的执行单元,一个进程至少有一个线程,负责实际运行代码。
打个比方:
进程就像一个工厂车间,有自己的厂房和设备;线程就是车间里的工人,干活的。
浏览器启动时,会创建多个进程,如下表:
| 进程名 | 职责 |
|---|---|
| 浏览器进程 | 控制地址栏、书签、前进/后退,以及管理其他进程 |
| 网络进程 | 处理网络请求 |
| 渲染进程 | 核心:解析 HTML、CSS,执行 JavaScript,计算布局,绘制页面 |
| GPU 进程 | 处理 3D 加速、合成图层等 |
其中渲染进程和我们前端开发者关系最大。
2. 渲染主线程:所有 JS 代码的执行者
渲染进程启动后,会开启一个主线程(还有一些辅助线程,但负责跑 JS、操作 DOM、解析页面、布局绘制的,只有主线程)。
这个主线程非常繁忙,它要负责:
- 解析 HTML,构建 DOM 树
- 解析 CSS,构建 CSSOM 树
- 计算样式、布局(Layout)、分层(Layer)
- 将内容绘制到屏幕上(每秒 60 次,即 16.6ms 一帧)
- 执行全局 JavaScript 代码
- 执行事件处理函数(点击、键盘等)
- 执行定时器(setTimeout、setInterval)的回调
- 等等……
关键点:所有这些工作都在同一个线程上按顺序执行。
如果某件事耗时太久(比如一个死循环),它就会阻塞后面所有的任务,页面就会卡死。
这时候,异步出现了。
补充:渲染进程中不止主线程(真实浏览器视角)
上面我们聚焦于“主线程”,因为它负责 JS、DOM、样式、布局、绘制——也就是事件循环的核心。
但在真实的 Chromium 浏览器中,一个渲染进程会包含多个线程,各司其职。下面快速列举,理解它们对学习事件循环不是必须的,但可以避免你以后看到“合成线程”时感到困惑。
| 线程名称 | 职责 |
|---|---|
| 主线程 | 就是我们一直在讲的那个线程,执行 JS、解析、样式、布局、绘制指令生成 |
| 合成线程 | 负责将页面分层、分块,并执行合成(把绘制指令变成屏幕上的最终图像)。处理滚动和 transform 动画,无需主线程参与 |
| 光栅线程(池) | 把绘制指令(矢量)转换成像素点(位图)。通常多个线程并行工作 |
| 工作线程 | 运行 Web Worker / Service Worker 的 JS 代码,独立于主线程 |
| IO 线程 | 处理进程间通信(IPC),接收来自浏览器进程、网络进程的消息 |
| V8 辅助线程 | V8 引擎内部用于并发编译、并发标记(GC)的线程 |
3. 为什么需要异步?——避免主线程阻塞
考虑一个场景:
主线程执行到 setTimeout(() => console.log('hello'), 1000)。
如果主线程傻等这一秒钟,期间用户点击按钮、页面滚动、动画都无法处理,体验极差。
解决方案:异步。
当遇到一个不能立即完成的任务(比如计时、网络请求、用户事件监听),主线程不会傻等,而是:
- 把这个任务交给其他线程(比如计时线程、网络线程)去处理。
- 主线程继续执行后面的代码。
- 其他的线程会将回调函数包装成一个任务,扔回主线程的任务队列中排队。
- 主线程空闲时从队列里取出任务执行。
说明
当遇到一个不能立即完成的任务,主线程会把它交给浏览器的其他线程或进程去处理。例如,计时任务有专门的计时线程来处理;而网络请求,则会通过进程间通信(IPC)交给专门的网络进程来执行。
- 计时线程:通常位于渲染进程内部(每个渲染进程有自己的计时线程),但它仍然属于“浏览器提供的能力”,不是 JS 语言自带的。
- 网络进程:是一个独立的进程,不属于任何渲染进程,专门负责所有网络通信。
这就是异步的本质:不阻塞主线程,延迟执行回调。
所以,异步不是 JavaScript 语言本身的特性,而是浏览器提供的运行时机制。
4. 事件循环(Event Loop)的工作流程
主线程维护着一个任务队列(Task Queue)。
事件循环就是一个永不结束的循环,如下:
while(true) {
// 1. 执行1个同步/宏任务
// 2. 清空整个微任务队列
// 3. 渲染页面(渲染阶段不一定每轮都执行,取决于浏览器调度)
// 4. 取下一个宏任务,重复循环
}
- 任务(Task):也叫“宏任务”(非规范用语),包括:setTimeout、setInterval、I/O 事件、用户交互事件等。
- 微任务(Microtask):Promise.then、queueMicrotask、MutationObserver 等。
重要规则
- 每执行完一个任务,就会立即清空整个微任务队列(包括清空过程中新加入的微任务)。
- 然后才会进入下一轮循环(取出下一个任务)或执行渲染。
这个规则解释了为什么微任务可以“插队”:微任务的执行时机总是在当前任务之后、下一个任务之前。
5. 任务的优先级:不止一个队列
早期的简单理解:只有一个任务队列,先进先出。
但现代浏览器为了提高用户体验,引入了多个任务队列,且不同队列有不同的优先级。
例如 Chrome 内部至少有以下几种队列:
| 队列类型 | 典型任务 | 优先级 |
|---|---|---|
| 交互队列 | 用户点击、键盘事件 | 最高 |
| 微任务队列(独立机制) | Promise.then 回调 | 特殊机制 |
| 定时器队列 | setTimeout、setInterval | 中 |
| 网络队列 | fetch/XHR 回调 | 中低 |
| 空闲队列 | requestIdleCallback | 最低 |
事件循环每次会从优先级最高的非空队列中取出一个任务执行,而不是机械地先进先出。
这也解释了为什么用户点击事件通常能比 setTimeout(fn, 0) 更快响应——交互队列优先级更高。
很多人以为 setTimeout(fn, 0) 是「0 毫秒后马上执行」,但浏览器的真实逻辑是:0 毫秒 = 「尽快执行,但必须等主线程空闲」。
它的本质是:把回调任务放到定时器队列的队尾,等主线程把当前所有高优先级任务都干完了,才会轮到它。
6. 微任务的递归清空与渲染阻塞
由于微任务会在当前任务结束后一次性清空,如果你在微任务里又添加了新的微任务,新任务会在同一轮清空中继续执行。
Promise.resolve().then(function loop() {
console.log('micro');
Promise.resolve().then(loop); // 无限递归添加微任务
});
// 主线程将永远卡在清空微任务阶段,无法进入渲染,页面会假死
因此,不要用微任务做大量计算或递归,否则会阻塞渲染。
7. 经典面试题:setTimeout 与 Promise 的执行顺序
代码示例
console.log(1);
setTimeout(() => {
console.log(2);
Promise.resolve().then(() => console.log(3));
}, 0);
Promise.resolve().then(() => console.log(4));
console.log(5);
执行步骤分解
- 同步代码:打印 1。
- 遇到 setTimeout,将其回调放入任务队列(计时结束后)。
- 遇到 Promise.resolve().then,将
() => console.log(4)放入微任务队列。 - 打印 5。
- 当前任务结束,检查微任务队列:执行
console.log(4),打印 4。微任务队列清空。 - 事件循环取出下一个任务(setTimeout 回调)执行:打印 2。
- 在回调中遇到 Promise.resolve().then,将
() => console.log(3)放入微任务队列。 - 当前任务(setTimeout 回调)执行完毕,清空微任务队列:打印 3。
最终输出:1 5 4 2 3
注意:并不是简单的“微任务总是在宏任务之前”,而是每个宏任务执行后都会清空微任务。
8. 为什么 setTimeout 无法精确计时?
常见说法:setTimeout(fn, 1000) 表示至少延迟 1000ms,而不是精确的 1000ms。原因包括:
- 操作系统时钟精度有限,某些系统只能提供 10ms 左右的精度。
- 浏览器对嵌套的 setTimeout 有“最小延迟 4ms”的限制(当调用层级超过 5 层时)。
- 最重要的是:如果主线程正在执行其他任务(比如一个长循环),setTimeout 的回调只能排队等待,计时器属于宏任务,优先级低于【微任务】和【交互队列】,必须等它们执行完才会执行。所以实际执行时间会延后。
所以,不要用 setTimeout 做精确计时,比如动画应该用 requestAnimationFrame。
9. 异步与单线程的关系
经常有面试题问:“JavaScript 是单线程语言,为什么能异步?”
更准确的说法是:
JavaScript 在浏览器中执行时,通常只有一个主线程来运行代码。
这个主线程不能阻塞,因此浏览器提供了异步 API(setTimeout、Promise、事件监听等),配合事件循环机制,让单线程也能高效处理 I/O、用户交互等耗时操作。
异步是单线程下实现非阻塞的解决方案。
10. 总结一句话
事件循环是浏览器渲染主线程的工作方式:它循环地从不同优先级的任务队列中取任务执行,并在每个任务后清空微任务队列,保证页面响应及时、不卡顿。
掌握事件循环,你就能理解为什么 setTimeout 不精确、为什么 Promise 能插队、为什么点击事件有时比定时器快。希望这篇文章能帮你把之前模糊的概念彻底理清。