我不知道的浏览器:解密 JS 异步与事件循环

262 字 12 min read
JavaScript 浏览器原理 异步编程 事件循环 EventLoop 性能优化

1. JS 异步的由来:单线程的"无奈"与智慧

JavaScript 从诞生起就被设计为一门单线程语言。这主要是因为它最初的应用场景——浏览器网页交互。想象一下,如果 JS 是多线程的,两个线程同时修改同一个 DOM 元素(比如一个删除,一个修改内容),那页面该听谁的?为了避免这种复杂性,JS 选择在主线程(也就是上一篇提到的渲染线程)上运行。

但单线程意味着任务必须排队执行。如果遇到耗时操作(比如等待网络响应或执行大量计算),整个线程都会被阻塞,导致页面卡顿甚至无响应。为了解决这个问题,JS 引入了异步机制。对于那些不需要立即得到结果的操作(如 setTimeout、网络请求),JS 不会傻等,而是启动它们,然后继续执行后续代码。当这些异步操作完成后,它们的回调函数会被安排在未来的某个时刻执行。


2. 事件循环:JS 异步的"调度中心"

事件循环 (Event Loop) 是 JavaScript 实现异步的核心机制,它负责协调和调度各种任务的执行。我们可以把它想象成一个持续运行的调度程序,其主要组成部分包括:

  • 调用栈 (Call Stack):一个后进先出 (LIFO) 的栈,用于追踪当前正在执行的函数。同步代码会直接进入调用栈执行。
  • Web APIs (或 Node.js 中的 C++ APIs):浏览器或 Node.js 环境提供的异步功能接口,如 setTimeout, DOM 事件监听, AJAX 请求 等。这些 API 通常由浏览器/Node.js 的其他线程处理,独立于 JS 主线程。
  • 任务队列 (Task Queue / Callback Queue):一个先进先出 (FIFO) 的队列,用于存放已经完成的异步操作的回调函数(或称为"任务")。当 Web API 完成某个异步操作后(比如计时器到期、网络响应返回),它不会立即执行回调,而是将回调函数放入任务队列中排队。

事件循环的工作流程大致如下:

  1. 执行调用栈中的同步代码,直到栈为空。
  2. 检查微任务队列 (Microtask Queue),执行其中所有的微任务。如果在执行微任务的过程中又产生了新的微任务,会继续执行,直到微任务队列清空。
  3. 宏任务队列 (Macrotask Queue) 中取出一个宏任务,放入调用栈执行。
  4. 执行完这个宏任务后,返回步骤 2,再次检查并清空微任务队列。
  5. 重复步骤 3 和 4,不断循环。

这种机制确保了同步代码优先执行,微任务紧随其后,宏任务则在每一轮循环中执行一个,并在执行前后处理微任务。

更进一步的任务优先级: 现代浏览器实现中,任务队列并非简单的"宏任务"和"微任务"二分法。不同的任务来源(如用户交互、计时器、网络请求)可能被放入不同的队列,并赋予不同的优先级(例如,用户交互相关的任务通常优先级更高,以保证页面的响应性)。事件循环会根据这些优先级和内部策略,智能地选择下一个要执行的宏任务。但无论如何,"执行一个宏任务 -> 清空所有微任务"的基本模式是不变的。


3. 宏任务与微任务:执行时机的差异

理解宏任务和微任务的区别对于掌握 JS 异步行为至关重要:

  • 宏任务 (Macrotask):代表一个独立的、离散的工作单元。常见的宏任务包括:
    • setTimeout, setInterval 的回调
    • setImmediate (Node.js 环境)
    • I/O 操作(如文件读写、网络请求)的回调
    • UI 渲染(浏览器环境,可以看作一个特殊的宏任务)
    • script 标签中的整体代码本身
  • 微任务 (Microtask):通常是需要在当前任务执行完毕后、但在进行任何其他操作(如下一个宏任务或 UI 渲染)之前立即执行的小任务。常见的微任务包括:
    • Promise.prototype.then(), .catch(), .finally() 的回调
    • MutationObserver 的回调
    • queueMicrotask()
    • process.nextTick (Node.js 环境,优先级甚至高于其他微任务)

执行顺序关键点:在一个事件循环的迭代中,执行完当前的宏任务后,会立即处理并清空所有当前存在的微任务。只有当微任务队列为空时,事件循环才会考虑执行下一个宏任务或进行 UI 渲染。

看个例子:

console.log('同步代码 1');

setTimeout(() => {
  console.log('宏任务 setTimeout');
}, 0);

Promise.resolve().then(() => {
  console.log('微任务 1');
}).then(() => {
  console.log('微任务 2');
});

console.log('同步代码 2');

输出顺序会是: 同步代码 1 同步代码 2 微任务 1 微任务 2 宏任务 setTimeout


4. 计时器 (setTimeout 等) 的工作方式与精度问题

setTimeout(callback, delay) 这样的计时器是如何工作的?

  1. 调用 setTimeout 时,浏览器(或 Node.js)的计时器模块(通常在单独的线程)会启动一个计时器,等待 delay 毫秒。
  2. delay 时间到达后,计时器模块不会立即执行 callback
  3. 而是将 callback 函数作为一个宏任务放入宏任务队列
  4. callback 最终能否执行,以及何时执行,取决于事件循环何时轮到从宏任务队列中取出并执行它。

为什么计时器不精确?

setTimeoutdelay 参数指定的只是"至少要过多长时间才将回调放入任务队列",而不是"过多长时间后精确执行回调"。实际执行时间会受到多种因素影响,导致延迟:

  • 事件循环的繁忙程度: 如果调用栈长时间被占用,或者前面排队的宏任务很多,那么计时器回调就得等着。
  • 最小延迟: 浏览器通常会对低于某个阈值(如 4ms 或根据 W3C 建议的嵌套层数增加延迟)的 delay 进行强制约束,避免过于频繁地触发。
  • 系统调度: 操作系统的任务调度、CPU 负载等也会影响计时器的精确性。
  • 微任务优先: 在执行计时器这个宏任务之前,必须先清空微任务队列。

因此,setTimeoutsetInterval 不适合用于需要高精度计时的场景。


5. 异步机制与浏览器渲染的关联

我们知道 JS 执行和浏览器渲染共享主线程。事件循环机制巧妙地协调了这两者:

  • 浏览器通常会在处理完一个宏任务之后,并且在下一个宏任务开始之前,检查是否需要进行 UI 渲染(布局、绘制等)。这是一个进行渲染更新的机会窗口。
  • 微任务会在这个渲染机会之前执行。这意味着,由 Promise 等产生的微任务会优先于页面的视觉更新完成。

这种机制保证了逻辑状态的更新(通过微任务)能够及时反映,同时也为浏览器提供了插入渲染操作的时机。但反过来,如果宏任务执行时间过长,或者微任务队列持续有新任务加入,都会推迟渲染的时机,导致用户感觉卡顿。


6. 提升 JS 异步代码效率的建议

深入理解了事件循环和异步原理后,可以采取一些策略来优化代码性能和响应性:

  • 优先使用微任务处理依赖: 对于需要在当前任务之后立即执行的逻辑,Promise 通常比 setTimeout(..., 0) 更快、更可靠。
  • 分解长任务: 将耗时的同步计算分解成多个小的异步任务(例如使用 setTimeout(..., 0)requestIdleCallback),或者将其移到 Web Workers 中,以保持主线程畅通。
  • 选择合适的计时器: 对于动画等需要与屏幕刷新同步的场景,使用 requestAnimationFramesetTimeoutsetInterval 效果更好,因为它能保证回调在浏览器下一次重绘之前执行。
  • 利用 requestIdleCallback: 当有一些低优先级的后台任务可以在浏览器空闲时执行时,可以使用 requestIdleCallback。但要注意其兼容性和触发时机的不确定性。
  • 善用开发者工具: Chrome DevTools 的 Performance 面板是分析事件循环、任务执行时长、找出性能瓶颈的利器。

掌握 JS 异步和事件循环的运作方式,是编写高效、流畅、响应迅速的 Web 应用的基础。