Skip to content

浏览器渲染与事件循环 ​

看在前面 ​

这篇回答两个面试必问题:**浏览器怎么把一串 HTML 变成屏幕上的像素?JS 的异步回调到底什么时候执行?**两个问题是同一个问题的两面。渲染管线和事件循环共用同一条主线程,谁卡住了,两边一起死。

我的对照锚点还是 Java:浏览器的渲染主线程 ≈ 一个单线程的线程池。事件循环 ≈ 它的任务调度器,requestAnimationFrame ≈ 渲染前的一次"帧钩子"。差异在哲学:Java 并发靠多线程分身,浏览器靠单线程 + 异步排队保住 UI 不卡。文末与本轨道其他篇互链,风格对齐 typescript/从Java视角看TypeScript-开篇。

一张总图:Java 心智到浏览器运行时的映射 ​

Java 概念浏览器对应关键差异
单线程 Executor + 任务队列事件循环(主线程)队列分宏/微两级,微任务可插队自增殖
多线程并行计算Web Worker只有它是真线程;不能碰 DOM,postMessage 通信
@Scheduled(fixedDelay)requestAnimationFrame间隔由"上一次渲染完成"决定,跟随刷新率
批量 flush / 攒批提交重排合并、读写分离强制同步布局 = 每次 get 都 flush
双缓冲合成帧图层 + GPU 合成transform/opacity 免布局免绘制

渲染管线:从字节到像素的六步流水线 ​

面试官:输入 URL 到页面渲染,中间渲染部分发生了什么?

        HTML              CSS             JS(可能改 DOM/CSS)
         │                 │                  │
         ▼                 ▼                  ▼
   ┌──────────┐      ┌──────────┐      ┌──────────┐
   │   解析    │─────▶│ 样式计算  │─────▶│   布局    │
   │ DOM 树    │      │ Computed │      │ 几何信息  │
   │(token→节点)│      │  Style  │      │(位置/大小)│
   └──────────┘      └──────────┘      └────┬─────┘
                                            ▼
   ┌──────────┐      ┌──────────┐      ┌──────────┐
   │   合成    │◀─────│  栅格化   │◀─────│   绘制    │
   │ 多图层拼帧 │      │ 图层→位图 │      │ 绘制指令  │
   └────┬─────┘      └──────────┘      └──────────┘
        ▼
     屏幕像素(paint records → display list)

六个阶段一句话各归其位。解析生成 DOM 树与 CSSOM 树。样式计算把选择器匹配成每个节点的最终样式。

**布局(Layout/Reflow)**算出每个盒子的几何信息。**绘制(Paint)**生成绘制指令填色画字。栅格化把图层切成图块交给 GPU 画成位图。**合成(Composite)**把多个图层按 z 序拼成一帧——transform/opacity 只走合成,不回头。

管线不是每次全跑。JS 改了几何属性(宽高/位置)触发重排。改外观属性(颜色/背景)只触发重绘。改 transform/opacity 只触发合成。性能排序:合成 < 重绘 < 重排(成本从低到高),这就是"动画优先用 transform 而不是改 top/left"的原理依据。

重排 vs 重绘:为什么改一个 width 会这么贵 ​

追问:既然重排这么贵,浏览器没做优化吗?

做了两层。批量处理:连续多次 DOM 修改会被合并成一次重排(脏标记 + 帧末统一 flush);布局缓存:没改几何就不重算。但有个 Java 工程师特别容易踩的坑——强制同步布局(forced synchronous layout):

js
// 经典反模式:读写在循环里交替,每次读都强制立刻重排
for (const item of list) {
  item.el.style.width = item.el.offsetWidth + 10 + 'px'  // 写→读→写→读
}
// 正确:先全部读,再全部写(fast dom / 双 pass 思维)
const widths = list.map((i) => i.el.offsetWidth)
list.forEach((i, idx) => (i.el.style.width = widths[idx] + 10 + 'px'))

offsetWidth 读取布局信息时,若前面有脏写操作,浏览器被迫同步执行重排才能返回准确值。相当于在 Java 里每次 get 都强制 flush 缓存。优化纪律一句话:读写分离,批量提交,和后端"攒一批再 commit"是同一种直觉。

合成层:GPU 加速的边界 ​

追问:will-change: transform 是干嘛的?为什么能优化?

浏览器把页面拆成若干图层(layer),各自栅格化成位图后由 GPU 合成。默认大多数内容在一个图层里,重绘时牵连甚广。满足条件的元素会被提升为独立合成层——3D transform、will-change、video/canvas、固定定位。独立层的重绘只重画自己,transform 动画完全不碰布局和绘制,GPU 直接改矩阵。

但要警惕层爆炸:几百个 will-change 会让内存和合成成本飙升。它不是免费加速开关,而是"用内存换重绘"的权衡,用完记得移除。这和 Java 里无脑加缓存一个道理:优化手段本身也有成本。

事件循环:单线程线程池的调度器 ​

面试官:macrotask 和 microtask 的执行顺序,讲准确一点。

Java 的直觉。 我最初把事件循环想象成 ExecutorService 的队列。但有个致命差异:浏览器只有一个主线程,JS 执行、样式计算、布局、绘制全在它身上。所以事件循环不是"多个 worker 抢队列",而是一个线程在"执行任务"和"渲染页面"两种身份间来回切换。

while (true) {
  ① 取一个 macrotask 执行(script / setTimeout / 事件回调 / IO)
  ② 清空整个 microtask 队列(Promise.then / queueMicrotask / MutationObserver)
     └─ microtask 里再产生 microtask?继续清,永不让位
  ③ 需要渲染则:rAF 回调 → 样式/布局/绘制 → requestIdleCallback(有空才调)
}

三条铁律。一是microtask 在当前 macrotask 结束后、下一个 macrotask 之前全部清空。二是 microtask 队列可以"插队自增殖"(then 里再 then 同轮执行)。三是每个宏任务之间隔着一次可能的渲染机会。经典输出题:

js
console.log('1')                          // 同步
setTimeout(() => console.log('2'))        // 宏任务
Promise.resolve().then(() => console.log('3'))  // 微任务
console.log('4')
// 输出 1 4 3 2 —— 同步代码本身是第一个宏任务,微任务永远先于下一个宏任务

追问:为什么 Vue 的 nextTick 要用 microtask?

因为微任务在渲染之前执行:数据变了,nextTick 的回调在同一帧渲染前完成 DOM 更新。用户看到的是合并后的最终结果,不会闪中间态。若用 setTimeout(宏任务),回调排在渲染之后,DOM 更新被推到下一帧——表现为"短暂卡顿后再变"。Vue2 用 MutationObserver、Vue3 直接 Promise.resolve().then(),原理一致。

requestAnimationFrame:卡在渲染之前的帧钩子 ​

追问:rAF 和 setTimeout(fn, 16) 做动画,差在哪?

rAF 回调在③的样式计算之前、浏览器准备渲染新一帧时统一触发。三个好处。一是与渲染同步(改的样式当帧生效,不会"改完了这帧已经画完")。二是跟随刷新率(120Hz 屏每秒调 120 次,setTimeout 做不到)。三是后台标签页暂停(省电,setTimeout 照跑)。

对照 Java:rAF 像 Spring 的 @Scheduled(fixedDelay)——上一次渲染完到下一次触发,间隔由运行时决定,而不是写死在代码里。与它相对的 requestIdleCallback 是"帧内有富余时间才执行"的低优先级钩子,React Fiber 的可中断渲染灵感即源于此。

把两个主题拼起来:一帧的完整时间线 ​

渲染管线和事件循环不是两套系统,一帧之内它们交替执行。以 60Hz(每帧约 16.7 ms)为例:

│←──────────────── 一帧 16.7ms ────────────────→│
│ 宏任务 │ 微任务清空 │ rAF │ 样式→布局→绘制→合成 │ idle │
│  3ms   │   1ms      │ 1ms │        9ms         │ 2.7ms│
                                                 ▲
                            requestIdleCallback 只在这段富余里跑
JS 占用 + 渲染占用 > 16.7ms → 掉帧(jank)

这张图能同时回答三类面试题。一是为什么长任务导致掉帧(一帧的预算被 JS 吃光)。二是为什么微任务里改 DOM 也能当帧生效(渲染在微任务之后)。三是为什么 rAF 里适合读布局再写样式(读写发生在同一帧的渲染前,天然读写分离)。所有渲染优化的目标可以统一成一句话。让每一帧的"JS 占用 + 渲染占用"稳定压在刷新率预算之内。

长任务与卡顿:把主线程当稀缺资源 ​

主线程一次执行超过 50 ms 的任务算"长任务",期间输入无响应、掉帧。优化手段和后端治理慢 SQL 同构。拆:大任务切片,让出主线程。挪:Web Worker 真后台线程,纯计算挪过去,postMessage 通信——这才是浏览器里唯一接近 Java 多线程的东西。省:虚拟列表减少 DOM 规模,从源头减重排成本。拆的写法:

js
// 10 万条数据排序/过滤别一口气干完,切片让出主线程
async function chunkedProcess(items, fn) {
  const CHUNK = 1000
  for (let i = 0; i < items.length; i += CHUNK) {
    fn(items.slice(i, i + CHUNK))
    await new Promise((r) => setTimeout(r))   // 切到下一个宏任务,渲染得以插队
  }
}

诊断工具也是面试加分项。Performance 面板看长任务红条与掉帧位置。performance.mark/measure 埋点。Long Task API(PerformanceObserver 监听 longtask)做线上监控——思路与后端 APM 慢事务打点完全一致。

面试官视角小结:30 秒版本 ​

  1. 管线:解析→样式→布局→绘制→栅格化→合成。改几何触发重排。改外观只重绘。transform/opacity 只合成。强制同步布局是读写交替的坑,纪律是读写分离。
  2. 事件循环:一个宏任务 → 清空全部微任务 → 可能渲染。微任务插队自增殖。单主线程,渲染与 JS 互斥。
  3. rAF:渲染前触发的帧钩子,跟随刷新率、后台暂停。nextTick 用微任务是为了赶在渲染前改 DOM。
  4. 长任务:>50 ms 卡输入,拆/挪(Worker)/省三板斧;Worker 是浏览器里唯一真多线程。

开场模板:

"浏览器渲染和事件循环共用一条主线程,所以我把它当一个单线程线程池治理。渲染管线是它的批处理作业,微任务是它的插队通道,rAF 是每帧渲染前的钩子。优化本质是别让任何任务占住这条线程。"

2026-10 新增(F 轨前端 · 首批)。