全栈手账 订阅 RSS

浏览器渲染原理

以 Chromium 为主,讲清从 HTML/CSS 到屏幕像素的主链路:解析、样式计算、布局、绘制、合成,以及回流与重绘的触发条件和可复现的验证方式。

读了 8 分钟 更新于 11 月 9 日

一、主链路:从 URL 到像素

一次导航大致经过:

  1. 网络:DNS → TCP/TLS → HTTP 响应,返回 HTML 字节流。
  2. 解析 HTML:增量解析,边下载边构建 DOM,不必等整个文档到达。
  3. 遇到 CSS 时并行下载并构建 CSSOM;遇到脚本时按 defer / async 规则处理。
  4. 样式计算(Recalculate Style):把 CSS 规则匹配到 DOM 节点,算出每个节点的最终样式。
  5. 生成布局树(Layout Tree,旧称 Render Tree):只包含会占据空间的节点。
  6. 布局(Layout / Reflow):计算每个节点的几何位置与尺寸。
  7. 绘制(Paint):生成绘制指令列表(display list),此时还没有像素。
  8. 栅格化(Raster)与合成(Composite):合成线程把图层切成图块交给 GPU 栅格化,再合成上屏。

关键认识:DOM 树 ≠ 布局树 ≠ 屏幕。display: none 的节点在 DOM 里但不在布局树里;伪元素不在 DOM 里却在布局树里。

二、解析阶段的三个阻塞点

资源 阻塞 HTML 解析 阻塞首次渲染 说明
CSS 否(并行下载) 是 要等 CSSOM 完成才敢首次绘制,避免闪一下无样式内容
<script>(无属性) 是 是 暂停解析、下载、执行,执行完才继续
<script defer> 否 否 并行下载,DOM 解析完后按文档顺序执行,DOMContentLoaded 之前
<script async> 否 否 下载完立即执行,可能打断解析,顺序不保证

两个容易记反的点:

  • CSS 不阻塞 DOM 解析,但会阻塞它后面的 JS 执行(JS 可能要读样式),从而间接卡住依赖该脚本的解析流程。
  • defer 保证顺序、async 不保证;依赖 DOM 或有先后关系的脚本用 defer。

三、Layout → Paint → Composite 的职责边界

阶段 输入 → 输出 典型触发
Layout(回流) 布局树 → 每个盒子的坐标与尺寸 改宽高、字体、增删节点、窗口 resize、读取几何属性
Paint(重绘) 布局树 → 绘制指令列表 改颜色、背景、阴影、边框样式等不影响几何的属性
Composite(合成) 图层 → 屏幕 transform、opacity 在已提升的合成层上做动画

三者是递进关系:回流通常伴随重绘,重绘不一定回流,纯合成变化可以两者都不触发。

四、回流、重绘与强制同步布局

会触发回流的常见操作:

  • 几何属性:width / height / padding / margin / top / font-size / display
  • DOM 结构变化:插入、删除、移动节点
  • 读取几何信息:offsetTop / offsetHeight / scrollTop / getBoundingClientRect() / getComputedStyle()

最后一项最容易被忽略。浏览器会把样式变更攒着批量布局,一旦在写之后立刻读几何值,就被迫立即同步布局:

js
// 反例:读写交替,n 次循环触发 n 次布局(Layout Thrashing)for (const el of items) {  el.style.width = el.offsetWidth + 10 + 'px';}// 正例:先批量读,再批量写const widths = items.map(el => el.offsetWidth);items.forEach((el, i) => { el.style.width = widths[i] + 10 + 'px'; });

五、合成层、帧与事件循环

满足条件的元素会被提升到独立合成层,常见的有:3D transform、will-change: transform 或 will-change: opacity、position: fixed、<video> / <canvas>、正在进行 transform 或 opacity 动画。

提升后,只改 transform / opacity 可以跳过 Layout 与 Paint,直接由合成线程处理 —— 这是这类动画流畅的主要原因。代价是每层都占显存,will-change 长期挂着会浪费资源,动画结束应移除。

帧预算:60Hz 屏幕约 16.67ms/帧。主线程要在这段时间里完成 JS、样式计算、布局、绘制;任何长任务超过预算就会掉帧。requestAnimationFrame 在每帧绘制前回调,适合逐帧动画;setTimeout 不与 VSync 对齐,容易抖动。

六、验证方式

不用背结论,用 DevTools 复现一遍:

  1. Performance 面板录制一次页面加载,Main 轨道能看到 Parse HTML → Recalculate Style → Layout → Paint → Composite Layers 的先后;点 Layout 事件可看到触发布局的调用栈。
  2. Rendering 面板勾选 Paint flashing(重绘区域闪绿)与 Layout Shift Regions,直观区分重绘和布局抖移。
  3. Layers 面板查看合成层与估算显存,验证 will-change 是否真的提升了层。
  4. 强制同步布局复现:在 Console 执行上一节的反例,Performance 里会看到成排的 Layout,DevTools 也会给出 Forced reflow 警告。
  5. 线上观测:
js
new PerformanceObserver(list => {  for (const e of list.getEntries()) console.log(e.name, e.startTime, e.duration);}).observe({ type: 'longtask', buffered: true });new PerformanceObserver(list => {  for (const e of list.getEntries()) {    if (!e.hadRecentInput) console.log('CLS', e.value);  }}).observe({ type: 'layout-shift', buffered: true });

七、关键渲染路径优化清单

按收益排序,只列能直接验证的:

  • 减少关键资源:首屏用不到的 CSS/JS 拆出去;脚本加 defer / async。
  • 减少关键资源字节数:压缩、tree shaking、移除未使用 CSS。
  • 缩短关键路径长度:<link rel="preload"> 提前发现字体与首屏 CSS;字体加 font-display: swap。
  • 图片 / iframe 懒加载,给 width / height 或 aspect-ratio 占位,避免布局抖移。
  • 动画优先用 transform / opacity,避免逐帧改 top / left / width。
  • 批量读写 DOM;复杂计算放到 requestIdleCallback 或 Web Worker。

判断顺序:先在 Performance 里确认瓶颈在哪个阶段,再选优化项。没有测量就加 will-change,通常只是把问题从主线程挪到显存。

前端、后端、部署、客服都是我一个人。写这里的东西都是真踩过的坑,不是抄来的教程。