JavaScript

浏览器事件循环:从进程模型到任务队列

梳理渲染主线程如何调度任务、异步为何必要、JS 为何会阻塞渲染,以及 Chrome 中延时/交互/微队列的优先级。

#browser #event-loop #interview

浏览器事件循环:从进程模型到任务队列

JavaScript 常被说成「单线程」,但真正决定页面是否流畅的,是浏览器里那条繁忙的 渲染主线程。它既要解析 HTML/CSS、布局绘制,又要执行 JS 与事件回调——这些任务如何排队、谁先谁后,就是 事件循环(消息循环) 要回答的问题。

下文从浏览器进程模型讲起,再到异步模型、阻塞渲染的实测,以及 W3C 与 Chrome 对任务队列的最新理解。

浏览器的进程与线程

进程是什么?

程序运行需要专属的内存空间,这块空间可以理解为 进程。每个应用至少有一个进程;进程之间相互独立,若要通信也需要双方同意。

进程与内存空间示意图

线程是什么?

有了进程,才能在其中运行代码。执行代码的单元叫 线程。进程启动后会自动创建一个线程运行代码,称为 主线程;若需并行执行多段代码,进程内还可再开更多线程。

进程内的主线程与额外线程

浏览器有哪些进程?

浏览器是多进程、多线程应用。 启动后会拉起多个进程,以降低相互影响和连环崩溃的风险。可在浏览器任务管理器中查看当前进程列表。

浏览器主要进程示意

与前端开发最相关的是 渲染进程:启动后会开启 渲染主线程,负责执行 HTML、CSS、JavaScript。默认情况下,每个标签页对应一个渲染进程,标签页之间互不影响。(Chrome 的进程模型仍在演进,详见 Chrome 官方说明。)

渲染主线程如何工作?

渲染主线程是浏览器中最繁忙的线程,典型工作包括:

  • 解析 HTML、CSS
  • 计算样式、布局、处理图层
  • 以约 60fps 重绘页面
  • 执行全局 JS、事件处理函数、计时器回调
  • ……

这么多任务如何调度?例如:JS 执行到一半时用户点了按钮,要立即处理点击吗?计时器到时了,要立即跑回调吗?点击与计时器同时到来,先处理谁?

主线程的做法是:排队

事件循环与消息队列

  1. 渲染主线程进入一个 不会结束的循环(事件循环)。
  2. 每次循环检查 消息队列:有任务则取出第一个执行,执行完再进入下一轮;没有任务则休眠。
  3. 其他线程(含其他进程的线程)可随时把任务加到队列 末尾;若主线程在休眠,会被唤醒继续取任务。

整条链路也叫 事件循环消息循环

思考题:为什么渲染进程不用多个线程并行做解析、布局、绘制?
简答:DOM 与样式状态需要一致的可预测顺序;多线程并行改同一棵 DOM 树会带来同步与竞态难题,单主线程 + 队列调度是更稳妥的工程取舍(GPU 合成等仍可由其他线程辅助)。

异步:主线程不能阻塞

有些任务 无法立刻完成,例如:

  • 计时器:setTimeoutsetInterval
  • 网络:XHRFetch
  • 用户操作:addEventListener

若主线程一直等到「时间到了」「请求返回」「用户点击」,就会长期阻塞,页面卡死:

同步等待导致主线程阻塞

渲染主线程承担页面更新与交互,绝不能被长时间阻塞。浏览器因此采用 异步

异步模型:其他线程处理,回调入队

主线程把计时、网络、监听等交给其他线程,自己继续执行后续代码;待时机成熟,将回调包装成任务加入消息队列末尾,由主线程在空闲时调度。异步模式下,主线程不会因等待 I/O 而空转阻塞。

如何理解 JS 的异步?(面试要点)

  • JS 运行在渲染主线程中,同一时刻只有一条执行栈,故常被称为单线程语言。
  • 主线程还要负责渲染;若大量任务同步等待,队列里其他任务无法执行,页面无法及时更新。
  • 异步做法:计时、网络、事件等交由其他线程;完成后把回调入队,主线程按序执行。

JS 为何会阻碍渲染?

改 DOM 与真正画到屏幕上并非同一瞬间完成。若主线程正在执行一段 长时间占用的 JS,渲染任务只能等在队列后面。

主线程阻塞导致画面延迟更新

html
<h1>Hello, Event Loop!</h1>
<button>change</button>
<script>
  const h1 = document.querySelector('h1')
  const btn = document.querySelector('button')

  function delay(duration) {
    const start = Date.now()
    while (Date.now() - start < duration) {}
  }

  btn.onclick = function () {
    h1.textContent = '页面已更新!'
    delay(3000)
  }
</script>

点击按钮后:textContent 虽已修改,但 画面要等 delay(3000) 跑完 才会更新——因为渲染步骤也要主线程空闲才能执行。

下方可亲自点击体验(约 3 秒内标题不会变,之后才刷新):

JS 阻塞渲染

Live Preview · 可编辑代码

正在加载代码预览…

任务有优先级吗?

单个任务在所属队列里通常是先进先出,没有「插队」;但 队列之间 有优先级差异。

W3C 现行表述要点(Perform a microtask checkpoint):

  • 每个任务有 任务类型;同类型任务在同一队列,不同类型可分到不同队列。
  • 一次事件循环中,浏览器可按情况从不同队列取任务。
  • 浏览器必须维护 微队列(microtask queue),其中任务优先于其他队列执行。

随着浏览器复杂度提升,简单区分「宏队列 / 微队列」已不足以描述全部行为,但 微任务优先清空 仍是硬性要求。

在 Chrome 实现中,至少包括:

队列典型内容优先级(相对)
微队列Promise.thenMutationObserver最高
交互队列用户操作产生的事件处理
延时队列setTimeout / setInterval 到期回调

立即把函数放进微队列的常见写法:

Promise 入微队列

javascript
Promise.resolve().then(() => {
  console.log('microtask')
})

常见面试题

阐述 JS 的事件循环

事件循环是渲染主线程的工作方式:开启不会结束的循环,每次从队列取一个任务执行;其他线程在合适时机把任务加到队列末尾。任务按类型分队列,浏览器在一次循环中决定取哪个队列;微队列任务必须优先调度执行。Chrome 源码层面可理解为 for 循环 + 队列取任务,而非过去教科书里单一的「宏队列」模型。

计时器能精确计时吗?

不能做到严格精确,原因包括:

  1. 硬件与 OS 计时本身存在误差,JS 计时器最终依赖系统调用。
  2. W3C 规定:嵌套超过 5 层的计时器,最小间隔为 4ms,短于 4ms 的设定会被抬高。
  3. 回调只在主线程空闲时执行,前面若有长任务,到期时间到了也会 晚跑

小结

  • 浏览器多进程隔离标签页;渲染主线程 是页面与 JS 的核心调度点。
  • 事件循环 用消息队列 + 无限循环保证任务有序执行。
  • 异步 避免主线程等待 I/O;长同步 JS 仍会阻塞渲染与交互。
  • 理解 微队列优先 与多队列模型,比死记「宏/微」更符合当前规范与 Chrome 行为。