JS事件循环

5 阅读8分钟

深入理解事件循环&一些常见的疑惑解答

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)
如果主线程傻等这一秒钟,期间用户点击按钮、页面滚动、动画都无法处理,体验极差。

解决方案:异步。

当遇到一个不能立即完成的任务(比如计时、网络请求、用户事件监听),主线程不会傻等,而是:

  1. 把这个任务交给其他线程(比如计时线程、网络线程)去处理。
  2. 主线程继续执行后面的代码。
  3. 其他的线程会将回调函数包装成一个任务,扔回主线程的任务队列中排队。
  4. 主线程空闲时从队列里取出任务执行。

说明

当遇到一个不能立即完成的任务,主线程会把它交给浏览器的其他线程或进程去处理。例如,计时任务有专门的计时线程来处理;而网络请求,则会通过进程间通信(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. 同步代码:打印 1。
  2. 遇到 setTimeout,将其回调放入任务队列(计时结束后)。
  3. 遇到 Promise.resolve().then,将 () => console.log(4) 放入微任务队列。
  4. 打印 5。
  5. 当前任务结束,检查微任务队列:执行 console.log(4),打印 4。微任务队列清空。
  6. 事件循环取出下一个任务(setTimeout 回调)执行:打印 2。
  7. 在回调中遇到 Promise.resolve().then,将 () => console.log(3) 放入微任务队列。
  8. 当前任务(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 能插队、为什么点击事件有时比定时器快。希望这篇文章能帮你把之前模糊的概念彻底理清。