首页 / 文章

重排、重绘、合成:每项CSS更改会给浏览器带来多少负担

跟随HTML与CSS的流程,了解DOM、CSSOM、布局、绘制及合成过程,掌握为何宽度变化比颜色变化更耗资源,以及如何避免布局抖动。

发布者:REACTAPP.TOP阅读约 16 分钟3720 词

来源: Browser Rendering Pipeline Explained: From HTML to Pixels + Reflow vs Repaint

像标题这样的单行标记看似简单,但浏览器在将其转化为屏幕上的像素之前要执行一系列复杂的操作。了解这一系列流程有助于回答诸多实际问题:为何调整元素大小会降低页面加载速度、为何动画通常使用transform来实现、为何一次更改会影响到其他数十个元素,以及为何频繁操作DOM会导致界面卡顿。本指南将逐一介绍渲染流程的各个阶段,并借助该模型解释重排与重绘的区别,以及保持界面流畅的操作习惯。

<h1>Hello World</h1>

渲染流程概览

从根本上说,渲染流程就是将三种输入——标记、样式和脚本——转化为显示器能够显示的图像的过程。

HTML + CSS + JavaScript
          ↓
       Pixels
          ↓
       Screen

将其拆分为各个阶段后,整个流程大致如下:

Parse HTML
   ↓
Build DOM
   ↓
Parse CSS
   ↓
Build CSSOM
   ↓
Create Render Tree
   ↓
Calculate Styles
   ↓
Layout / Reflow
   ↓
Paint / Repaint
   ↓
Composite
   ↓
Pixels on Screen

当人们讨论页面何时能快速显示首个可见内容时,通常会将这一流程称为关键渲染路径。需记住,这只是一个模型,并非针对某一种浏览器的具体规范。Chrome、Firefox和Safari对这些阶段的实现方式各不相同,而且在后续更新中,只要能证明某些步骤没有必要,它们就会跳过这些阶段。不过这个模型依然足够准确,可用于分析性能问题,而这正是日常前端开发中所需要关注的。

阶段1:将HTML解析为DOM

一切始于服务器返回的HTML文档,例如一个包含一个标题和一个段落的简单页面:

<!DOCTYPE html>
<html>
  <body>
    <h1>Hello World</h1>
    <p>Welcome to my website.</p>
  </body>
</html>

解析器读取这些标记后会产生文档对象模型,即一个节点树结构,其中的每个元素和每段文本都有固定的位置。上述文档的树结构示意图如下:

HTML

<html>
  │
  └── body
       │
       ├── h1
       │    └── "Hello World"
       │
       └── p
            └── "Welcome..."

DOM用于记录文档的内容及其嵌套关系。它同时也是脚本操作的接口,因此像下面的调用会沿着这棵树来查找特定的节点:

document.querySelector("h1");

解析是逐步进行的。浏览器不会等到整个文件加载完毕才开始构建节点,正因如此,即使页面的其余部分仍在传输中,长页面也可以开始渲染。

第二阶段:将CSS解析为CSSOM

仅凭结构无法体现外观效果,为此浏览器需要样式表,比如下面这两条简单的规则:

h1 {
  color: blue;
  font-size: 32px;
}

p {
  color: gray;
}

CSS会被解析为另一种结构,即CSS对象模型。区分这两种结构的一个有效方法是根据它们各自所回答的问题:

DOM
 ↓
"What exists on the page?"

CSSOM
 ↓
"How should those things look?"

渲染过程需要这两个答案。这也正是样式表会阻塞渲染的原因。

第三阶段:将两种结构合并为渲染树

在获得DOM和CSSOM之后,浏览器会将它们合并为渲染树。

        DOM
         +
       CSSOM
         ↓
    Render Tree

渲染树仅存储实际要绘制的内容以及适用于这些内容的样式。以这段代码为例:

<div>
  <h1>Hello</h1>
  <p>World</p>
</div>

合并之后,它可以被想象成一棵包含样式信息的可见框构成的树:

Render Tree

div
│
├── h1
│   ├── font-size: 32px
│   └── color: blue
│
└── p
    └── color: gray

任何不参与渲染的元素都会被忽略。应用了 display: none 的元素根本不会出现在列表中,这与 visibility: hidden 不同:隐藏的元素仍然占据空间,因此会保留在布局树中并参与布局。

第4阶段:计算最终样式

接下来,浏览器会为每个将要被渲染的元素计算出最终的计算样式。真实的样式表中往往存在类似这样的重叠规则:

.card {
  width: 300px;
}

.card p {
  font-size: 16px;
}

对于每个元素,渲染引擎需要按顺序解决三个问题:

Which rules apply?
        ↓
Which rule wins?
        ↓
What is the final computed style?

回答这些问题意味着要应用CSS中的所有规则解析机制:

  • 层叠规则
  • 从父元素继承的样式
  • 选择器特异性
  • 转换为计算值
  • 依赖于媒体查询的样式

最终结果是每个元素都有一组确定的属性:

DOM + CSSOM
     ↓
Style Calculation
     ↓
Computed Styles

第5阶段:布局,确定几何形状

此时浏览器已经知道要显示什么以及其外观应该如何。但它还不知道每个元素的位置和大小。解决这一问题的就是布局,它将视口转换为一组定位好的盒子:

Viewport
┌────────────────────────────┐
│                            │
│   Header                   │
│                            │
├──────────────┬─────────────┤
│ Sidebar      │ Content     │
│              │             │
│              │             │
└──────────────┴─────────────┘

在布局过程中,渲染引擎会为渲染树中的每个盒子计算:

  • 宽度和高度
  • 位置
  • 外边距和内边距
  • 每个盒子与相邻元素及父元素的关系
  • 页面的整体几何结构

MDN将这一阶段概括为确定渲染树中每个节点的大小和位置。由于各个盒子之间存在相互依赖关系,因此布局是一个涉及整个树结构的问题,而非针对单个元素的问题。

重排:再次执行布局计算

第一次的几何图形计算简单称为“布局”。之后因变化而触发的计算通常被称为重排,有些团队也会用非正式术语“重新布局”来指代同一概念:

Layout
  ↓
Initial calculation

Reflow / ReLayout
  ↓
Subsequent layout calculation

本指南其余部分均使用“重排”这一术语,因为这是在大多数性能讨论和工具中出现的用词。

什么会触发重排

从一个固定宽度的盒子开始:

.box {
  width: 200px;
}

现在有一个脚本在运行时改变了该宽度:

box.style.width = "500px";

这一次赋值会引发一系列问题,浏览器必须在回答这些问题后才能绘制下一帧:

The box changed width.

What else is affected?
Does its parent change?
Do neighboring elements move?
Does the text wrap differently?
Does the height change?
Does the entire layout need recalculation?

因此,页面的部分或全部内容可能需要重新进行一次布局计算,之后后续步骤才会继续执行:

Change width
    ↓
Style recalculation
    ↓
Layout / Reflow
    ↓
  Paint
    ↓
Composite

影响几何形状的属性

任何会影响尺寸或位置的属性都可能引发布局调整。常见的因素包括:

width
height
margin
padding
top
left
right
bottom
font-size

更改其中任何一个属性都可能移动或调整该元素本身的大小,进而影响其周围的元素。例如,以下两种更新都可能迫使浏览器重新计算布局:

element.style.width = "500px";
element.style.marginLeft = "100px";

font-size 也应列入此列表,因为文本大小的变化会改变行高和换行方式,进而影响包含该文本的块的高度,最终影响其后所有元素的位置。

为何布局重排会耗费大量资源

以一个包含多个区域和嵌套内容的典型页面结构为例:

Page
│
├── Header
├── Navigation
├── Sidebar
├── Main Content
│    ├── Card
│    ├── Card
│    ├── Card
│    └── Table
└── Footer

如果位于顶部的某个元素变高,其下方的所有卡片、表格以及页脚都可能需要新的坐标。一次几何属性的更改可能会波及整个页面结构的很大一部分:

One Geometry Change
        ↓
Layout Recalculation
        ↓
Many Elements Recalculated
        ↓
      Paint
        ↓
    Composite

这种扩散会导致不必要的布局处理耗费大量资源,尤其是在动画或拖动操作中反复出现时。

第6阶段:绘制

当这三项条件都满足后,浏览器就可以开始生成像素:

What to display
       +
Where to display it
       +
How it should look

绘制功能会将定位好的元素转换为绘图操作,最终形成像素。所绘制的内容包括:

  • 文本
  • 颜色和背景
  • 边框
  • 阴影
  • 图像
  • 其他任何可见的装饰元素
Layout
  ↓
Paint
  ↓
Pixels

MDN将绘制描述为将布局中的元素转换为屏幕上实际像素的步骤。

重新绘制:新的像素,相同的几何结构

当某个元素的外观发生变化但占据的空间完全相同的情况下,就需要进行重新绘制:更改文本颜色就是典型的例子:

color: red;

新的颜色对以下任何方面都不会产生影响:

Width
Height
Position
Layout relationships

因此浏览器可以完全跳过布局处理,走更短的路径:

Style Recalculation
       ↓
Paint / Repaint
       ↓
   Composite

重排与重绘的对比

这一区别是大多数渲染性能建议的核心,因此有必要准确阐述这两者的差异。

重排会重新计算几何信息

当至少有一个元素的尺寸或位置需要重新确定时,就会发生重排。设置宽度就是典型的例子:

element.style.width = "500px";

随后会依次执行完整的处理流程:

Change
  ↓
Style
  ↓
Layout / Reflow
  ↓
Paint
  ↓
Composite

重绘仅改变外观

重绘意味着元素的外观发生变化,但不需要重新计算几何信息。设置颜色就足以触发重绘:

element.style.color = "red";

布局处理会从该流程中省略:

Change
  ↓
Style
  ↓
Paint / Repaint
  ↓
Composite

每项都简化为一行:

RELayout / Reflow
→ Geometry changes
Repaint
→ Visual appearance changes

可改变宽度或颜色的按钮

想象一个简单的操作按钮:

┌─────────────────────┐
│       Buy Now       │
└─────────────────────┘

假设它的宽度从第一个值变为第二个值:

width: 200px;
width: 400px;

此时按钮占据的水平空间变为原来的两倍,这可能会把相邻元素挤到一边或导致行换行。这是一种几何变化,因此浏览器会重新布局并重新绘制:

Width changes
     ↓
Geometry changes
     ↓
  Reflow
     ↓
  Repaint

现在假设只有背景颜色发生了变化:

background-color: blue;

按钮的尺寸和位置保持不变,只有像素内容有所不同:

Color changes
     ↓
Geometry unchanged
     ↓
  Repaint

这种关系是单向的

当布局发生变化时,元素会出现在新的位置,而新位置需要新的像素内容:

Layout changes
     ↓
Browser needs new pixels
     ↓
   Paint

简而言之,重新布局之后通常会进行重新绘制:

Reflow
   ↓
Usually followed by
   ↓
Repaint

反之则不成立。无需进行任何布局操作,画面也可能会自行重新绘制:

Repaint
   ↓
Does NOT necessarily require
   ↓
Reflow

第7阶段:将图层合成到帧中

现代渲染引擎并不总是将整个页面绘制为单一的平面图像。它们可以将内容拆分成独立的图层,并在最后的合成步骤中将其组合起来:

Layer 1
   +
Layer 2
   +
Layer 3
   ↓
Composite
   ↓
Final Frame

例如,一个页面可能包含背景图层、位于独立图层上的卡片,以及位于第三个图层上的动画元素:

┌──────────────────────┐
│ Background Layer     │
│                      │
│   ┌──────────────┐   │
│   │ Card Layer   │   │
│   └──────────────┘   │
│                      │
│   ┌──────────────┐   │
│   │ Animation    │   │
│   └──────────────┘   │
└──────────────────────┘

合成器通过堆叠这些图层来生成每一帧。某些元素可以被提升为独立的图层,在合适的情况下,浏览器会将合成任务交给GPU处理。此时,仅对现有图层进行移动或混合的操作即可实现,而无需重新绘制其内容。

为何transform是处理动画的默认方式

这是一个悬停效果,通过动画改变元素的 left 值来实现盒子移动,起始值为零:

.box {
  left: 0;
}

最终移动到右侧100像素处:

.box:hover {
  left: 100px;
}

由于 left 是元素布局位置的一部分,每次过渡都会触发一次布局计算和重绘。同样的视觉移动效果可以通过变换来实现:

.box {
  transform: translateX(0);
}

.box:hover {
  transform: translateX(100px);
}

变换可以在不改变元素布局框的情况下移动已绘制好的元素,因此浏览器通常可以在合成器中处理整个动画过程,无需每帧进行布局计算或重绘。opacity 具有类似的特性,是实现淡入淡出效果的常用选择。

应将此视为一种强默认规则而非绝对准则。变换是否真的能在合成器中处理,取决于具体元素、应用在其上的其他样式、图层分配方式以及所使用的浏览器引擎。

实际会触发哪些阶段的变化

对于大多数样式更新,在重新计算样式后,工作会沿着两个分支之一展开:

                    Change
                      ↓
              Recalculate Style
                      ↓
              ┌───────┴───────┐
              ↓               ↓
           Layout           No Layout
              ↓               │
            Paint             │
              ↓               ↓
              └───────┬───────┘
                      ↓
                  Composite
                     ↓
                  Screen

频繁的更新涉及的昂贵处理阶段越少,保持流畅渲染就越容易。MDN也描述了相同的流程:先重新计算样式,然后是布局,接着是绘制,最后在适用时进行合成。如果对已合成的图层中的transform或opacity进行修改,甚至可以跳过绘制阶段,因为这是所有步骤中最省资源的。

帧时间预算:约16.67毫秒

大多数显示设备每秒刷新约60次,因此每帧有固定的时间分配:

1 second
   ÷
60 frames
   ≈
16.67ms per frame

换句话说,要维持60 FPS的帧率,浏览器每帧大约有这么多时间可用:

16.67ms

该帧预算被所有在页面显示前需要执行的操作所共享:JavaScript代码、样式计算、布局处理、绘制操作以及浏览器自身的维护工作。一旦总耗时超过限制,该帧就会被丢弃,从而导致用户看到画面卡顿现象。

16.67ms
   ↓
Exceeded
   ↓
Frame missed
   ↓
 Jank

这种问题在那些会持续生成新帧的交互操作中最为明显:

  • 滚动
  • 动画效果
  • 拖放操作
  • 窗口或面板的大小调整
  • 同时更新多个数值的仪表板

MDN也指出:为了使动画流畅,样式计算、布局处理和绘制操作都必须在帧预算范围内完成。

布局频繁重算:强制同步布局的操作

有一种特别常见的浪费帧预算的方式,在代码中看起来似乎并无危害。下面的代码片段先设置宽度,读取尺寸值,再设置高度并再次读取:

element.style.width = "500px";
console.log(element.offsetWidth);
element.style.height = "500px";
console.log(element.offsetHeight);

它生成的模式是写入与读取的交替进行:

Write
 ↓
Read layout
 ↓
Write
 ↓
Read layout

通常情况下,浏览器会推迟布局计算,直到即将绘制下一帧时再处理,这样多个样式写入操作可以在一次遍历中完成。然而,像offsetWidth和offsetHeight这样的属性必须返回当前值。在写入后立即读取会迫使引擎立即同步处理所有待处理的样式和布局任务。如果在循环中多次重复这种操作,尤其是针对大量元素时,就会导致布局性能严重下降:本只需一次完整布局计算的情况却要多次进行。

解决办法是将操作分组,先执行所有读取操作,然后再执行所有写入操作:

Bad:
Write
Read
Write
Read
Write
Read

Better:
Read
Read
Read
Write
Write
Write

代码的重构方式取决于具体应用,但原则始终不变:避免让浏览器不必要的频繁重新计算布局。如果您想了解更多强制重绘的示例,该博客有篇关于布局抖动与实时DOM集合的详细文章。

提升渲染效率的实际技巧

动画化变换与透明度

与其使用布局属性来移动元素,不如:

left: 100px;

采用符合设计需求的变换方式:

transform: translateX(100px);

若要实现元素的淡入淡出效果,可在这两个值之间切换:

opacity: 0;
opacity: 1;

通常比动画化那些会影响布局的属性更为合适。

尽量减少DOM更改

每次 DOM 变更都可能为浏览器增加更多工作量。像这样依次追加节点:

element.appendChild(...);
element.appendChild(...);
element.appendChild(...);

通常更好的做法是先在文档外部构建新内容(例如使用 DocumentFragment),然后再一次性插入。DOM 越大、越复杂,每进行一次可避免的更改所带来的成本就越高。

批量更新而非交替进行

那些在更新操作与布局处理之间交替进行的代码,最终会形成如下顺序:

Update
 ↓
Layout
 ↓
Update
 ↓
Layout
 ↓
Update
 ↓
Layout

其目的是让浏览器能够一次性处理一组变更:

Update
Update
Update
 ↓
Browser processes the changes

对于动画及其他高频视觉效果,requestAnimationFrame() 会将更新操作安排在浏览器渲染下一帧之前执行,这样就能让这些操作与渲染周期保持同步,而非与之冲突。

不要为布局属性添加动画效果

那些会改变几何形状的属性并不适合用于每帧都会运行的动画:

width
height
margin
padding
top
left
right
bottom
font-size

它们中的每一个都可能触发布局重排。在视觉效果允许的情况下,应改为为这些属性添加动画:

transform
opacity

为图片预留空间

没有指定尺寸的图片标签会让浏览器无法知道需要留出多少空间:

<img src="image.jpg">

当文件加载完毕且其实际大小确定后,周围的文本内容会发生位移,从而导致再次布局重排。提前声明图片的固有尺寸可以避免这种情况:

<img
  src="image.jpg"
  width="800"
  height="600"
  alt="Example"
/>

CSS的aspect-ratio属性也能实现响应式图片的相同效果。无论哪种方式,浏览器都可以在图片加载前就预留出合适的空间,从而防止布局出现可见的位移。

优化之前先进行测量

并非每一次重绘或布局处理都是需要解决的问题;浏览器引擎已经过深度优化,能够快速完成大部分相关工作。应通过简单的循环以证据为基础来制定改进方案:

Performance Problem
       ↓
    Measure
       ↓
Find Bottleneck
       ↓
    Optimize
       ↓
 Measure Again

Chrome DevTools中的性能面板会记录一个时间轴,显示以下内容:

  • JavaScript代码执行
  • 样式重新计算
  • 布局调整
  • 绘制操作
  • 合成处理
  • 耗时较长的任务
  • 每帧的计时信息

应解决实际分析中发现的瓶颈,而非仅凭代码外观就认为它很耗时的问题。

整个流程一览

从源代码到屏幕显示的完整路径如下:

                HTML
                  │
                  ↓
             Parse HTML
                  │
                  ↓
                 DOM
                  │
                  │
CSS ──→ Parse CSS ──> CSSOM
                  │
                  ↓
          DOM + CSSOM
                  │
                  ↓
             Render Tree
                  │
                  ↓
          Style Calculation
                  │
                  ↓
           Layout / Reflow
                  │
                  ↓
            Paint / Repaint
                  │
                  ↓
             Composite
                  │
                  ↓
           Pixels on Screen

在首次渲染之后有变化时,关键决策点在于几何结构是否受到影响:

CSS / DOM Change
       ↓
Style Recalculation
       ↓
Does geometry change?
       │
    ┌──┴──┐
   YES    NO
    │      │
    ↓      ↓
 Layout   Paint
    │      │
    └──┬───┘
       ↓
   Composite
       ↓
     Screen

核心要点

三个简短的总结就能涵盖大部分关键内容。重排是成本较高的操作:

RELayout / Reflow
        ↓
Changes geometry
        ↓
Width / Height / Position / Layout
        ↓
Usually leads to Paint

重新绘制则成本较低:

     Repaint
        ↓
Changes appearance
        ↓
Color / Background / Shadow / etc.
        ↓
Doesn't necessarily require Layout

而对于动画而言,所选择的属性在很大程度上决定了成本:

Layout properties
    ↓
Potentially expensive

transform + opacity
    ↓
Often better for smooth animations

你无需了解每个引擎的内部机制,但应能理解其整体流程:

HTML
 ↓
DOM

CSS
 ↓
CSSOM

DOM + CSSOM
 ↓
Render Tree
 ↓
Style
 ↓
Layout
 ↓
Paint
 ↓
Composite
 ↓
Pixels

基于这一模型:

  • 如width这样的几何属性变化会触发重排,随后再进行重新绘制;而如color这样的外观属性变化通常只需重新绘制即可。
  • transform和opacity往往可以跳过布局计算和绘制步骤,因此适合用于动画。
  • 将DOM写入操作与布局读取操作交错进行会导致布局性能问题;应先批量读取数据,再执行写入操作。
  • 图片应标注尺寸或宽高比,且所有优化都应以 DevTools 的性能分析为起点。
  • 浏览器始终会实际将代码转换为像素。目标并非消除这一过程,而是避免要求其做超出页面实际需求的工作。