浏览器渲染原理

面试回答: 浏览器解析 HTML 和 CSS,分别构建 DOM 与 CSSOM,再结合两者生成用于渲染的结构。随后经过样式计算、布局、绘制、光栅化和合成,最终把像素显示到屏幕上。页面更新时不一定重走全部流程:尺寸或位置变化需要重新布局,颜色和阴影等视觉变化通常从绘制开始;独立合成层上的 transformopacity 动画则可能只需要重新合成。性能问题通常来自长任务、频繁布局、复杂绘制和图层过多,其中“写样式后立即读取几何信息”会触发强制同步布局,循环发生时就会形成布局抖动。

1. 从 HTML 到屏幕像素

浏览器把页面显示出来,可以用下面这条主线理解:

HTML → DOM ┐
           ├→ 样式计算 → 渲染结构 → Layout → Paint → Raster → Composite
CSS  → CSSOM┘

(1) 构建 DOM

浏览器解析 HTML,将标签、属性和文本转换为 DOM 节点并构建 DOM 树。HTML 解析通常是渐进的,并不是等整个文件下载完成后才开始。

普通同步脚本可能暂停 HTML 解析,因为脚本可能通过 document.write 或 DOM API 改变当前文档结构。deferasync 和模块脚本的加载与执行时机不同,详见 deferasync 的区别

(2) 构建 CSSOM

浏览器解析样式表,形成 CSSOM,并计算每个元素最终命中的样式规则。

CSS 通常不会阻止 HTML 继续解析,但会阻塞页面渲染;某些同步脚本还需要等待前面的样式表加载完成,因为脚本可能读取元素的计算样式。

(3) 生成用于渲染的结构

浏览器结合 DOM 和计算后的样式,确定哪些元素需要生成布局盒以及它们之间的关系。

  • display: none 的元素不生成布局盒,也不占据空间;
  • visibility: hidden 的元素仍然参与布局,只是不绘制可见内容;
  • 伪元素、匿名盒等渲染结构不一定与 DOM 节点一一对应。

因此,“渲染树就是 DOM 和 CSSOM 简单合并”只是便于入门的简化说法。

(4) 布局(Layout)

布局阶段根据视口、盒模型和布局规则,计算元素的尺寸与位置。历史资料常把重新执行布局称为 Reflow 或回流。

布局可能只影响局部子树,也可能沿父子关系扩散。影响范围取决于布局模型、元素依赖关系和浏览器实现,并非每次都重新计算整张页面。

(5) 绘制与光栅化(Paint & Raster)

绘制阶段把背景、文字、边框、阴影等视觉内容转换为绘制指令。光栅化再把这些指令转换为可以显示的像素块。

复杂阴影、滤镜、大面积渐变和频繁变化的绘制区域,都可能增加这一阶段的成本。

(6) 合成(Composite)

页面可能被拆分为多个合成层。合成线程将已经光栅化的图块按正确顺序组合,并提交为最终画面。

如果元素已经位于合适的独立合成层,修改 transformopacity 往往不需要重新布局和绘制,只需要更新图层的变换或透明度。但是否能够只走合成阶段取决于分层状态和浏览器实现,不能把它理解为无条件规则。

2. 页面变化会从哪个阶段重新开始?

不同属性变化影响的渲染阶段不同:

变化类型常见例子通常需要执行的阶段
结构或几何变化增删可见节点、修改宽高、边距、字体大小Layout → Paint → Composite
纯视觉变化colorbackgroundbox-shadowPaint → Composite
合成属性变化独立层上的 transformopacityComposite

这张表表达的是常见路径,不是所有浏览器、所有属性组合下的绝对保证。例如修改文字颜色不需要重新布局,但仍可能扩大需要重绘的区域;修改 transform 时,如果元素没有合适的分层,也可能产生额外绘制工作。

3. 回流与重绘

回流(Reflow / Layout)

当元素的结构、尺寸或位置发生变化时,浏览器需要重新计算受影响元素的几何信息。

常见触发来源包括:

  • 添加或删除可见 DOM 节点;
  • 修改 widthheightmarginpaddingborder 等尺寸属性;
  • 修改影响布局的位置、字体或行高;
  • 文本变化、图片尺寸确定等内容变化;
  • 视口尺寸变化;
  • 切换 display 等改变布局参与状态的属性。

重绘(Repaint)

元素几何信息不变,但视觉外观发生变化时,浏览器通常只需要重新生成或更新绘制内容。

常见例子包括:

  • colorbackground-color
  • box-shadowborder-radius
  • visibility
  • 不影响布局的轮廓和装饰变化。

常见面试结论是:回流通常会带来后续的绘制与合成,重绘则不一定需要重新布局,布局成本通常高于单纯绘制。

这个结论比“回流一定重绘整个页面”更准确,因为现代浏览器会尽量限制无效区域与绘制范围。

4. 强制同步布局为什么危险?

浏览器会尽量把连续的样式修改批量处理,等 JavaScript 任务结束或进入合适的渲染时机后再统一更新布局。

但是,如果代码刚修改了布局相关样式,紧接着又读取依赖最新布局结果的几何信息,浏览器为了返回正确数值,只能先同步完成样式计算和布局:

element.style.width = '200px'; // 写:使布局失效
const width = element.offsetWidth; // 读:要求立即得到最新布局

可能触发布局刷新或样式计算的读取包括:

  • offsetWidthoffsetHeightoffsetTopoffsetLeft
  • clientWidthclientHeight
  • scrollWidthscrollHeightscrollTop
  • getBoundingClientRect()
  • 特定情况下的 getComputedStyle()

读取本身不一定每次都造成布局。如果此前没有让样式或布局失效,浏览器可以直接返回已有结果。真正危险的是写入与读取交替发生

布局抖动(Layout Thrashing)

在循环中反复“写 → 读”,可能让浏览器每轮都被迫同步布局:

// 不推荐:每次写入后立即读取下一项布局
for (const item of items) {
  item.style.width = `${item.offsetWidth + 1}px`;
}

改为先集中读取,再集中写入:

const widths = Array.from(items, (item) => item.offsetWidth);

items.forEach((item, index) => {
  item.style.width = `${widths[index] + 1}px`;
});

读写分离减少的是强制同步布局次数,并不意味着整个批量更新永远只产生一次局部计算。浏览器最终仍然需要为真实的几何变化执行布局。

更多触发属性和调度示例见布局抖动与强制同步布局

5. 绘制、合成与图层

为什么transformopacity 更适合动画?

使用 topleft 或宽高制作动画通常会改变几何信息,需要反复布局。transformopacity 不改变普通文档流中的布局结果,在元素被合适地分层后,可以交给合成阶段处理。

/* 通常比反复修改 left 更适合位移动画 */
.card {
  transform: translateX(100px);
}

这不代表所有 transform 动画都没有成本:图层首次创建、纹理上传、超大图层光栅化和图层之间的叠加,都可能影响性能。

will-change 为什么不能滥用?

will-change 是对浏览器的优化提示,可能让浏览器提前为变化做准备:

.moving-element {
  will-change: transform;
}

独立图层需要显存和管理成本。给大量列表项长期设置 will-change,可能造成内存增长和合成压力。更合理的做法是在元素即将发生高频变化前使用,并在变化结束后移除。

6. 如何排查渲染性能问题?

先测量,再决定优化位置。

使用 Performance 面板

录制卡顿操作,重点观察:

  • 是否存在持续时间过长的 JavaScript 任务;
  • Recalculate StyleLayout 是否频繁出现;
  • 是否存在大面积或高频 Paint
  • 哪段调用栈触发了布局或绘制;
  • 帧耗时是否持续超出当前设备的刷新预算。

使用渲染辅助工具

Chrome DevTools 的 Rendering 面板可以辅助观察绘制闪烁、帧率和图层边界。发现问题后,再回到调用栈和具体 DOM 更新定位原因。

常见优化顺序

  1. 减少不必要的 DOM 更新:避免重复设置相同样式,批量插入节点时使用离线容器或框架批处理能力。
  2. 分离布局读取与写入:先获取所需几何信息,再统一修改样式。
  3. 控制单次渲染规模:长列表使用虚拟列表、分页或分批渲染。
  4. 选择合适的动画属性:位移和透明度动画优先考虑 transformopacity
  5. 在合适时机更新视觉状态:使用 requestAnimationFrame 对齐下一次绘制,但不要在同一个回调中继续反复读写布局。
  6. 谨慎创建图层:只为确有高频变化的元素使用 will-change

requestAnimationFrame 只能帮助代码与渲染时机对齐,本身不会自动消除长任务或布局抖动。

7. 高频面试题

CSS 为什么通常放在文档头部?

CSSOM 是确定页面样式和布局的必要输入。尽早加载关键 CSS,可以避免页面先以无样式状态出现,再发生明显跳变。

JavaScript 为什么不建议使用阻塞脚本放在头部?

同步脚本会暂停 HTML 解析。现代项目通常使用 defer、模块脚本、代码分割或合理的资源优先级,而不是简单地把所有脚本机械移动到 body 底部。

display: nonevisibility: hiddenopacity: 0 有什么区别?

属性是否占据布局空间常见渲染影响
display: none切换时通常需要重新布局
visibility: hidden通常不需要重新布局,但需要更新绘制结果
opacity: 0适合透明度动画;元素默认仍可能接收指针事件

读取offsetWidth 一定会触发回流吗?

不一定。如果布局结果仍然有效,可以直接读取缓存结果;如果前面有尚未处理的布局变更,浏览器才需要先同步完成布局。

为什么回流通常比重绘更贵?

布局需要计算元素间的几何依赖,影响可能沿父子或兄弟关系扩散;绘制主要更新视觉指令和像素。布局完成后通常还要继续绘制和合成,因此整体工作更多。

如何回答“页面为什么卡顿”?

不要直接归因于回流。应依次检查 JavaScript 长任务、样式计算、布局、绘制、图层与资源加载,用 Performance 记录确认真正的瓶颈。

8. 总结

  • 首次渲染主线是 DOM/CSSOM、样式计算、布局、绘制、光栅化与合成。
  • 几何变化通常从布局开始,纯视觉变化通常从绘制开始,部分合成属性可以只更新图层。
  • 回流与重绘不是两个孤立概念,而是页面更新重新进入渲染流水线的不同起点。
  • 写入布局样式后立即读取几何信息,会触发强制同步布局;循环交替读写会形成布局抖动。
  • 性能优化应以 DevTools 测量结果为依据,避免把所有问题笼统归结为“DOM 太多”或“回流太多”。