经典性能优化面试题: 从一个 Transform 动画引发的关于浏览器渲染的深度思考
1 Transform动画
类似点击商品飞入购物车🛒内的移动动画的需求大家应该也都遇到过或自己开发过。但是和 Absolute
相比, Transform
的实现有什么区别呢?
从上图看 Transform
实现和 Absolute
实现的动画似乎并没有什么区别,为了更好的观察两种实现的区别,我们打开浏览器的重绘开关来观察元素的重绘情况:
案例显示 Absolute
实现的元素在移动中一直处于重绘之中,而使用 Transform
的元素却没有重绘。看来 transform
并不会触发重绘操作。要了解其中的缘由就需要从浏览器的渲染说起了。
2 浏览器渲染
2.1 DOM树之后发生了什么?
我们都知道,浏览器请求到前端资源之后构建 DOM树
和 CSSOM树
,创建的 CSSOM树
和 DOM树
组合成一个 Render树
,然后用于计算每个可见元素的布局,然后将其绘制到屏幕上。Render Tree
并非最终的数据。从 DOM
到浏览器的画面,中间还会经历许多的步骤,诸如 Render Object
,RenderLayer
,Graphics Layer
。
2.2 Render Object Tree
上文提到 CSSOM
和 DOM
组合成 Render Tree
,计算每个可见元素的布局。这个可见元素的界定和存储就是我们的新同学 Render Object
的工作。
DOM Tree 中每一个可视节点都与一个 Render Object
对应。Render Object
存储在称为渲染树的并行树结构中,所以我们可以简单的理解 Render Object Tree
就是 DOM
和 CSSOM
的合成并剔除了不可视节点的产物。
生成Render Object 规则:
- DOM 树中的Document节点
- 可视节点[
1
] - 某些情况下生成的匿名Render Object 对象[
2
]
[1] 不可视节点
诸如 meta
, head
, script
等没有可视意义的节点。 display: none;
的节点(注意 visibility: hidden;
是可视的)
[2] 匿名Render Object
某些情况下浏览器会主动生成匿名 Render Object
, 例如根据 CSS 规范,inline 元素只能包含 block元素或 inline 元素中的一种。如果包含多种,会自动创建一个匿名盒模型,这个盒模型也对应一个 Anonymous RenderObject
。
RenderObject
知道如何在画布上绘制Node
的内容。它通过对GraphicsContext
发出必要的绘制调用来实现。GraphicsContext
负责将像素写入位图,最终将其显示在屏幕上。在Chrome中,GraphicsContext
包装了我们的2D图形库Skia。
2.3 Render Layer
回想一下,网页的排版顺序是非常复杂的,有 z-index
的层级关系,也有 overflow
的包含,裁剪等关系。这种层叠关系 Render Object
中是无法体现的。所以就有 Render Layer
帮助浏览器去存储关于层的信息。
Render Layer
和 Render Object
并非一一对应。共享相同坐标空间(例如受相同CSS变换影响的 Render Object
属于同一个 Render Layer
。每个 Render Object
都直接或通过祖先 Render Object
间接与 Render Layer
关联。
RenderObject
关联 RenderLayer
的常见情况:
- 页面的根对象。
- 具有明确的CSS位置属性(
relative
,absolute
,transform
)。 - 是透明的 (
opacity
< 1)。 - 有
overflow
,alpha mask
或reflection
属性。 - 有一个CSS filter过滤器。
<canvas>
2D / 3D上下文(WebGL)。<video>
元素。
2.4 Graphics Layer
浏览器将DOM分隔成多个 Render Layer
并栅格化,独立地绘制进位图中, 然后作为纹理上传到GPU进行复合。但是如果栅格化的 Render Layer
中包含视频,Web GL等高耗内容时,一个小小的更新就可能让浏览器遭遇性能瓶颈。
为了避免此种情况,浏览器会为特定的 RenderLayer
提供后端存储(Graphics Layer),对于这些操作,可以跳过 Reflow 和 Repaint,直接在 GPU 中进行 Composite(合成)。
每个 Graphics Layer
(图形层,也叫合成层)都有一个 GraphicsContext
供关联的 RenderLayer
绘制。浏览器会在随后的过程中通过合成器将 GraphicsContext
的位图作为纹理上传到GPU中并合成到最终的屏幕图像中。既解放了主线程,也利用了GPU对图形处理的极大优势。
理论上所有的 Render Layer
都可以提升为 Graphics Layer
,但实际上这样的做法会非常浪费内存等资源。目前满足以下条件的 Render Layer
能拥有自己的Graphics Layer
。
- 3D 或透视变换 (
perspective
,transform
) CSS 属性。 - 使用加速视频解码的元素。
- 拥有 3D (WebGL) 上下文或加速的 2D 上下文的元素。
- 混合插件(如 Flash)。
- 对
opacity
做 CSS 动画或使用一个transform
变换动画的元素。 - 拥有加速 CSS 过滤器(filter)的元素。
- iframe或含有
position: fixed
的元素。
隐式合成
- 元素有一个包含复合层的后代节点(换句话说,就是一个元素拥有一个子元素,该子元素在自己的层里)。
- 元素有一个
z-index
较低且包含一个复合层的兄弟元素(换句话说就是该元素在复合层上面渲染)。
合成器
合成步骤与合成器是息息相关的。
Chrome的合成器是一个软件库,用于管理 Graphics Layer
树和协调框架生命周期。
渲染通常都发生在两个阶段:绘画和合成。在硬件加速架构中,合成是通过调用特定于平台的3D API(Windows上为D3D;其他任何地方为GL)在GPU上进行的。当页面通过合成器渲染时,其所有像素都通过GPU进程直接绘制到窗口的后缓冲区中。
合成器可以在每个合成层的基础上执行其他工作。例如,合成器负责在合成之前对每个合成层的位图应用必要的转换(由图层的CSS Transform属性指定)。此外,由于层的绘画与合成是分离的,因此使这些层之一无效只会导致仅重新绘画该层的内容并进行合成。
合成器的基本任务是从主线程中获取足够的信息,以响应将来的用户输入而独立生成帧,即使主线程很忙并且无法请求其他数据。
或许这就能解释为什么开发者偏爱transform了。
应用了transform动画的容器会被提升为Graphics Layer(合成层),针对合成层,合成器能在合成前使用GPU对纹理(上传的位图)进行处理,这样的操作也就导致了动画容器跳过了重排重绘的阶段,直接进行合成。且合成器是对主线程的数据的副本进行操作,即使主线程忙于其他JavaScript操作,合成器也不会被阻塞。
2.5 提升合成层
合成层优点:
- 合成层的位图作为纹理交由 GPU 生成,GPU 处理图形计算快速。
- 合成层作为独立层,当回流、重绘时不影响其他层。
既然合成层这么厉害,为什么不好好利用呢?聪明的开发者们很早之前就开始了实践。
2.5.1 transform: translateZ(0)
最早(低版本浏览器)开发者使用 transform: translateZ(0)
来欺骗浏览器(因为它将使用GPU计算透视失真(即使最终根本没有失真))达到提升图层的作用。
注释:这里有一个坑就是使用了transform的元素会创建包含块, 这会导致 position: fixed/absolute
会以该元素作为父元素。
2.5.2 backface-visibility: hidden
transform: translateZ(0)`使元素在**早期版本**(现在不会了)的Chrome和Safari中闪烁,因此人们建议改为`backface-visibility: hidden
2.5.3 will-change:
2016年3月,IOS9获得了 will-change
属性的支持,该属性会告知浏览器某个属性将有可能改变,浏览器可以推测性地应用优化以适应这些将来的变化。在这种情况下使用 will-change: transform
或will-change: opacity
,它将迫使该元素提升为合成层。
2.6 合理利用合成层这把双面剑
尽管合成层作为动画优化是一大利器,但是使用不当也会反受其害的。
2.6.1 创建开销
渲染分为两步:绘画和合成。绘画(JS,Style,Layout,Paint)都是在CPU上完成的,合成是在GPU上。但是从CPU到GPU的转换是需要一些前置工作的。
- CPU将每个合成层绘制为单独的图像。
- 准备图层数据(大小,偏移,不透明度等)。
- 为动画准备着色器(如果适用)。
- 将数据发送到GPU。
所以每一次提升为合成层和去除合成层的状态转变都是伴随着这些步骤的。具体消耗的资源也就和合成层本身的大小和层数息息相关。
2.6.2 层爆炸
如上所说,GPU会对上传的纹理进行缓存管理,以便在以后的动画中重复使用,CPU和GPU之间的数据传输也需要一定的带宽,而CPU和GPU之间总线的带宽并不是无线的,这些因素导致合成层的出现可能会变得比较昂贵(特别是您的计算机的资源比较匮乏的时候)。
例如一张320x240 px的纯色图片,如果该图片是JPG(RGB)则占用320 × 240 × 3 = 230,400 bytes计算机内存,而如果是包含透明像素(PNG,RGBA)则需要使用320 × 240 × 4 = 307,200 bytes的内存。
不管是内存占用还是上传时的带宽都是有一定影响的。
其中我们将固有的合成原因(例如,具有3D变换的图层)称为“直接”合成原因。加上隐式合成原因,合成层的数量很容易脱离开发者的控制,资源大量占用进而导致性能表现不佳反而让人失望。这就是层爆炸问题。 浏览器为了防止由于在直接合成原因的图层上放置许多元素时发生“图层爆炸”,会将多个Render Layers与直接合成原因的Render Layer重叠,然后将它们“挤压”到单个后备存储中。 尽管浏览器有一定的优化手段(层压缩),但是合理提升层数量减轻设备的负担也是我们要去考虑的。
2.6.3 管理合成层
通过devtools我们能很直观的了解到页面的合成层的数量和占用的内存大小等。打开控制台,按住SHIFT+ Command + P打开搜索界面,输入show Layer,点击打开Layer面板。
第二张图片左侧都是一个合成层,右下角显示了当前合成层的合成原因及占用内存大小等信息。
- 减少合成层层数
最直观的办法当然就是减少合成层的层数问题。这里就分为主动提升和隐式合成。主动提升当然需要开发者自己去斟酌使用主动提升的性价比。我们比较关注的隐式合成,如下:
A和B元素处于重叠的状态,如果此时给B元素是一个应用了合成层的元素(如canvas,iframe,video等)处于其上的A元素就会被动提升为合成层。这时你就要考虑A放在B上是否合理,或者用其他的方式代替这样不明智的设计。
- 减少合成层大小
左边的图片是直接使用了一张39KB的图片,而右边则是使用的400B的小图并加上transform:scale(...)
放大到和最左边图片相同尺寸。
先缩减图片的width
和height
然后进行transform:scale
操作是一个很好的思路,虽然我们不会都使用纯色的图片,但在用户可接受范围以内可以进行尝试,以小范围的精度缺失换取整个页面的流畅体验。
3 参考文档
CSS GPU Animation: Doing It Right