浏览器渲染的详细过程

这是一篇转载文章。

原作者博客已失效,留了一篇在掘金。很担心掘金的也会消失掉,赶快敲一遍,侵删。

主要还是我比较笨拙,看内容很好的文章还真是很喜欢一个字一个字敲一遍,基于理解稍微整理层级改变细节。感觉这个方式对我来说真的很利于领会和记忆,能够触及每一个细节。

就是实在太费时间了……这篇文章长达七千多字,边理解边抄弄了有十个小时……

另外,我真的非常喜欢这位作者。我和他很像,都是钻牛角尖的人,追求对细节的了解,认为流程中缺失了不确定的部分就不是真正理解。不同的是,这位是一名十分优秀的程序员,能够从规范级别的文档中找到自己需要的答案。而我往往周旋于良莠不齐的国内博客,看着大家东抄西抄的东西,不仅找不到答案,还频频被误导。

所以说,第一个目标:学好英语。

页面渲染的时机

从 event loop 开始

event loop 是整个渲染过程的关键,涉及浏览器进行渲染的时机。先看 HTML5 关于 event loop 的官方规范

  • 从第 1 到第 5 步,从多个 task 队列中选出一个 task 队列(浏览器为了区分不同 task 的优先级,所以时常有多个 task 队列),从这个 task 队列中取出最老的一个 task,执行它,然后将它从队列中移除;
  • 第 6 步,执行一个 microtask 检查点(preform a microtask checkpoint)。只要 microtask 队列不为空,这一步会一直从 microtask 队列中取出 microtask,然后执行。如果 microtask 执行过程中又增加了 microtask,仍然会执行新添加的 microtask。(链接第 7 步的“return to the microtask queue handling step”很关键。)
  • 第 7 步,更新渲染(update the rendering)
    • 判断当前 document 是否需要渲染。因为只需要保持 60Hz 的刷新率即可,而每轮 event loop 都是非常快的,所以没必要每轮都进行渲染,而是差不多间隔 16ms 的时候渲染一次。同时,对于一些卡顿得已经不能保证 60Hz 的页面,若再在此时执行渲染会雪上加霜,所以浏览器可能会下调渲染频率为 30Hz。
    • run the resize steps
      • 若浏览器 resize 过,那么这里会在 window 上触发“resize”事件
    • run the scroll steps
      • 首先,每当我们在某个 target 上滚动时(target 可以是某个可滚动元素,也可以是 document),浏览器就会在 target 所属的 document 上的 pending scroll event targets 列表里存放这个发生滚动的 target
      • 现在,run the scroll steps 这一步会从 pending scroll event targets 列表里取出 target,然后在 target 上触发事件
    • 计算是否触发 media query
    • 执行 css animation,触发 animationstart 等 animation 相关事件
    • run the fullscreen rendering steps:如果在之前的 task 或 microtask 中执行过 requestFullscreen() 等 full screen 相关 api,此时会执行全屏操作
    • run the animation frame callbacks:执行 requestAnimationFrame 的回调
    • 执行 IntersectionObserver 的回调
    • 更新,渲染用户界面
  • 继续返回第一步

event loop 过程中与渲染相关的值得注意的点

  1. 不是每轮 event loop 都会 update the rendering,只有浏览器判断这个 document 需要更新页面时才会让其更新。这意味着两次渲染之间最小间隔是 16ms,setInterval 1ms 渲染一次其实也依然是 16ms 更新一次。
  2. resize 和 scroll 事件是在渲染流程里触发的。这意味着,如果你想在 scroll 事件上绑定回调执行动画,根本不需要用 requestAnimationFrame 去节流。scroll 事件本身就是在每帧真正渲染前执行,自带节流效果。当然,滚动图片懒加载、滚动内容无限加载等业务逻辑而非动画逻辑,还是需要节流的。
  3. MDN 介绍 [requestAnimationFrame 的回调是在重绘前执行的],上面的流程是这一逻辑的保证。
  4. 页面的重绘是在 event loop 结束时执行的。页面的重绘竟然是和 event loop 紧密耦合的,而且是被精确定义在 event loop 中的。这也解释了 JS 修改 DOM 样式之后,这样式到底什么时候呈现。

页面渲染的过程(基于规范的理论部分)

位图、纹理与光栅化

位图

位图

就是数据结构中常说的“位图”。想绘制一张图片,首先要将这张图片表示为一种计算机能理解的数据结构:一个二维数组,数组中的每个元素记录图片中每个像素的具体颜色。所以浏览器可以用位图来记录它想在某个区域绘制的内容,绘制的过程也就是往数组中具体的下标里填写像素而已。

纹理

纹理其实就是 GPU 中的位图,储存在 GPU video RAM 中。前面说的位图中的元素怎么存可以随意,但纹理是 GPU 专用的,需要有固定的格式。因此,一方面,纹理的格式比较固定,如 R5G6B5、A4R4G4B4 等像素格式;另一方面,GPU 对纹理的大小有限制,比如长/宽必须是二的幂次方,最大不能超过 2048 或者 4096 等。

光栅化(Rasterize)

光栅化例一
光栅化例二

在纹理中填充像素,并不是简单地遍历位图中的每个元素然后填写像素颜色的。就像前面两幅图,光栅化的本质是坐标变换,几何离散化,然后再填充。

Full-screen Rasterize 和 Tile-based Rasterize

光栅化基本都已经从早期的 Full-screen Rasterize 进化到了现在的 Tile-based Rasterize,也就是,不是对整个图像做光栅化,而是现将图像分块(tile,可译为瓷片/瓦片/贴片),然后对每一块 tile 单独进行光栅化。

光栅化后,将像素填充进纹理,再将纹理上传至 GPU。

原因是,如上文所说,纹理是有大小限制的,即使整屏光栅化也要填充进有大小限制的纹理中,不如事先根据纹理的大小分块光栅化,再填充进纹理。另外,分块光栅化也可以减少内存占用(整屏光栅化意味着需要准备更大的 buffer 空间)和降低总体延迟(分块光栅化意味着可以多线程并行处理)。

图中蓝色的矩形就是 tiles

浏览器的一次绘制过程就是先把想绘制的内容通过分块光栅化绘制到很多纹理里,再把纹理上传到 GPU 的储存空间中,GPU 把纹理绘制到屏幕上。

Layout Object、Paint Layer 与 Graphics Layer

先看这张经典的图

Layout Object

DOM 树中的每个 Node 节点都有一个对应的 Layout Object。Layout Object 上实现了将其对应的 DOM 节点绘制进位图的方法,负责绘制这个节点的可见内容,如背景、边框、文字内容等。Layout Object 也是存放在一个树形结构中的。

既然 Layout Object 实现了绘制每个 DOM 节点的方法,那么开辟一段位图空间,直接深搜遍历 Layout Object 树,然后执行每个 Layout Object 上的绘制方法,是否就可以将 DOM 绘制进位图了呢?就像盖章一样,把每个 Layout Object 的内容一个一个盖在纸上,是不是就完成了绘制呢?

不是的,因为浏览器还有层叠上下文的概念,决定着元素间的相互覆盖关系(如 z-index)。这使得文档流中位置靠前的元素有可能覆盖位置靠后的元素,而上述的深搜”盖章“只能够无脑让文档流中靠后的元素覆盖靠前的元素。

因此,有了 Paint Layer。

Paint Layer

当然,Paint Layer 的出现并不是简单因为层叠上下文,也有一些需要先绘制好内容再对绘制出来的内容做一些统一处理的 CSS 效果,如 opacity 小于 1,存在 mask 等。

总之,就是有层叠、半透明(具体可参考无线性能优化:Composite)等情况的元素会被从 Layout Object 提升为 Paint Layer。没有被提升为 Paint Layer 的 Layout Object 从属于其父级元素中最近的 Paint Layer。

当然,根元素的 html 节点自己要提升为 Paint Layer。

现在,Layout Object 树就变成了 Paint Layer 树,每个 Paint Layer 又包含了属于自己的 Layout Object。

那么,在得到 Paint Layer 后,现代浏览器将如何绘制出一棵 Layer 树呢?

The children of each RenderLayer are kept into two sorted lists both sorted in ascending order, the negZOrderList containing child layers with negative z-indices (and hence layers that go below the current layer) and the posZOrderList contain child layers with positive z-indices (layers that go above the current layer).
每个 Layout Layer 的子 Render Layer 都是按照升序排列存储在两个有序列表当中的:negZOrderList 存储了负 z-indicices 的子 layers,posZOrderList 存储了正 z-indicies 的子 layers。
——出自《GPU 加速的 compositing》一文

现代浏览器渲染引擎遍历 Layer 树,访问每一个 Paint Layer,然后递归 negZOrderList 里的 layer、自己的 Layout Object、posZOrderList 里的 layer,就可以将一棵完整的 Layer 树绘制出来。

Layer 树决定了网页绘制的层次顺序,而从属于 Paint Layer 的 Layout Object 决定了这个 Layer 的内容。因此,Layout Object 和 Paint Layer 共同决定了网页在屏幕上最终呈现的样子。

层叠上下文的问题通过 Paint Layer 解决了,那么现在,开辟一个位图空间 -> 绘制 Paint Layer -> 给 GPU 显示,经历这个过程是不是没有其他问题了呢?

不是的。

Graphics Layer

上面的过程可以搞定绘制过程,但是,浏览器中时常有动画、video、canvas、通过 css 进行 3D 变换的元素等。当页面上有这些元素时,页面显示会经常变动,这意味着位图会经常变动。在每秒 60Hz 的动效中,每次变动都重绘整个位图是很可怕的性能开销。

为了优化这一过程,浏览器引出了合成层(Compositing Layer)的概念。某些特殊的 Paint Layer 会被认为是合成层 Compositing Layer。Compositing Layer 拥有单独的 Graphics Layer,因此,也可以说 Graphics Layer 就是合成层 Compositing Layer。

某些具有 CSS3 3D transform 的元素、在 opacity/transform 属性上具有动画的元素、硬件加速的 video、canvas 等,这些元素在上一步会被提升为 Paint Layer,现在它们会被提升为 Graphics Layer。在上一步,你可能不清楚《无线性能优化:Composite》里的那些情况为什么也能提升为 Paint Layer,现在你应该明白了,他们是为提升为 Graphics Layer 做准备的。

每个 Paint Layer 都属于它祖先中最近的那个 Graphics Layer。当然,根元素自己也会提升为 Graphics Layer。

Paint Layer 提升为 Graphics Layer 的情况
  • 3D 或透视变换(perspective、transform)CSS 属性
  • 使用加速视频解码的元素
  • 拥有 3D(WebGL)上下文或加速的 2D 上下文的元素
  • 混合插件(如 Flash)
  • 对 opacity、transform、filter 或 backdropfilter 应用了 animation 或者 transition (需要是 active 的 animation 或 transition,在 animation 或 transition 的效果未开始或结束后,提升 Graphics Layer 会失效)
  • will-change 设置为 opacity、transform、top、left、bottom、right(其中 top、left 需要设置明确的定位属性,如 relative 等)
  • 拥有加速 CSS 过滤器的元素
  • 元素有一个 z-index 较低且包含一个 Graphics Layer 的兄弟元素(即该元素在 Graphics Layer 的上面渲染)
  • 详见:《无线性能优化:Composite》

其中最常见的即为:1. 3D transform;2. will change 设置为 opacity、transform;3. 包含 opacity 或 transform 的 CSS 动画。

另外,除了上述直接导致 Paint Layer 提升为 Graphics Layer,还有下面这种因为 B 被提升,导致 A 也被隐式提升的情况,详见:CSS GPU Animation: Doing It Right

从 Graphics Layer 到最终内容

每个 Graphics Layer 拥有一个 Graphics Context,Graphics Context 会为该 Layer 开辟一段位图,也就意味着每个 Graphics Layer 都有一个位图。

Graphics Layer 负责将自己的 Paint Layer 即其子代的 Render Object 绘制到位图里,然后将位图作为纹理交给 GPU。所以现在,GPU 收到了根元素 HTML,以及其他被提升为 Graphics Layer 的元素的纹理。

现在,GPU 需要对多层纹理进行合成。在合成时,GPU 可以对每一层纹理指定不同的合成参数,从而实现先对纹理的 transform、mask、opacity 等属性进行操作,之后再合成。GPU 对于这个过程的执行是底层硬件加速的,性能很好。最终,纹理合成一副内容被 draw 到屏幕上。

所以,对元素 opacity、transform 等属性的 animation 或 transition 的处理会很高效,这些属性的动画不需要重绘(repaint),只需要合成即可。

页面渲染的过程(计算机的实际实现部分)

系统结构

进程

blink 和 webkit 引擎内部都是通过两个进程负责 JS 执行、页面渲染等核心任务:

  • Renderer 进程
    主要的进程,在 Chrome 浏览器中是每个 tab 一个。负责执行 JS 和页面渲染,其中包括了三个线程:Compositor Thread、Tile Worker、Main Thread。

  • GPU 进程
    整个浏览器共用一个。主要负责把 Renderer 进程中绘制好的 Tile 位图作为纹理上传至 GPU,并调用 GPU 的相关方法把纹理 draw 到屏幕上。所以,整个进程更应该被称为”和 GPU 打交道的进程“,而不是 GPU 里的一个进程。GPU 进程只有一个线程:GPU Thread。

Renderer 进程里的三个线程

  • Compositor Thread

    • 整个线程既负责接收浏览器传来的垂直同步信号(Vsync。水平同步表示画出一行屏幕线,垂直同步就表示从屏幕顶端到屏幕底端的绘制已完成,指示着前一帧的结束和新一帧的开始。),也负责接收 OS 传来的用户交互,如滚动,输入,点击,移动鼠标等。
    • 如果可能,Compositor Thread 会直接处理这些输入,然后转换为对 Layer 的位移和处理,并将新的帧 commit 到 GPU Thread,从而输出新的页面。否则,比如你在滚动、输入等事件上注册了回调,这时候 Compositor Thread 就会唤醒 Main Thread,让后者去执行 JS,完成重绘、重排等过程,产出新的纹理。然后,Compositor Thread 再将相关纹理 commit 到 GPU Thread,完成输出。
  • Main Thread
    devtools 中显示的主线程工作
    主线程大家就很熟悉了。Chrome devtools 中 Timeline 里 Main 一栏就是 Main Thread 的相关任务,包括某段 JS 的执行,Recalculate Style,Update Layer Tree,Paint,Composite Layer 等。

  • Compositor Tile Worker(s)
    可能有一个或多个线程,如 PC 端的 Chrome 是两个或四个,安卓和 Safari 是一个或两个不等。由 Compositor Thread 创建,专门用于处理 tile 的光栅化。(所以处理的不是分块 Tile,而是光栅化。)

总结一下,就是:

  • 在需要绘制时(如在用户输入后,如果需要重绘/重排/执行 JS),Compositor Thread 就会唤醒主线程
  • 主线程执行解析 HTML,重新计算样式,Update Layer Tree,Paint,Composite Layer 等一系列操作(全部是计算应如何绘制/合成,没有实际进行绘制和合成)
  • 主线程通知 Compositor Thread 可以进行光栅化,Compositor Thread 创造 Compositor Tile Worker 绘制并光栅化,生成纹理(位图)
  • 生成的纹理由 Compositor Thread commit 到 GPU Thread
  • GPU Thread 将纹理上传至 GPU,并调用 GPU 的相关方法把纹理 draw 到屏幕上
  • 根据实现不同,其实也有直接在 GPU 中绘制和光栅化的

可以看到,Compositor Thread 是一个很核心的线程,后面两个线程都是由它主要进行控制的。

用户的输入是直接进入 Compositor Thread 的,因此,在那些不需要执行 JS、没有 CSS 动画或不需要重绘等场景中,可以直接对用户的输入进行处理和响应,而主线程是有很复杂的任务流程的。这使得浏览器可以快速响应用户的打字、滚动等输入,完全不需要主线程。所以,即使主线程正在执行很高耗的任务,超过 16ms,但是你在滚动页面时浏览器仍能做出响应。这是因为 Compositor Thread 直接负责将下一帧输出到页面上。

具体流程

一般,我们在 devtools 的 Timeline 里可能会看到如下过程:

devtools 中的 Timeline

也就是页面渲染的过程,即下面这张图:

页面渲染的过程

图中后半部分的两次 commit,分别是主线程通知 Compositor Thread 可以进行光栅化了,以及光栅化完成、纹理生成完毕,Compositor Thread 通知 GPU Thread 可以将纹理按照指定的参数 draw 到屏幕上。

1. Vsync

接收到 Vsync 信号,本帧开始

2. Input event handlers

之前如果 Compositor Thread 接收到了用户输入,在这一刻会被传递给主线程,触发相关的 event 回调。

All input event handlers (touchmove, scroll, click) should fire first, once per frame, but that’s not necessarily the case; a scheduler makes best-effort attempts, the success of which varies between Operating Systems.
所有事件输入处理程序(touchmouve、scroll、click)应首先被触发,每帧一次,但不一定能做到。调度程序应该尽力尝试,成功与否视操作系统而异。

也就是说,尽管 Compositor Thread 能在 16ms 内多次接收 OS 传来的输入,但是触发相应事件,传入主线程被感知却是每帧一次的,甚至可能低于每帧一次。也就是说,touchmove、moucemove 最快也是每帧执行一次,所以自带了相对于动画的节流效果。

在最开始关于渲染时机的内容中,我们知道,scroll 和 resize 事件的触发,因为和渲染处于同一轮次,所以最快只能每帧执行一次。现在看来,不仅是 scroll 和 resize,就连 touchmove、mousemove 等事件,由于 Compositor Thread 的机制,也是如此。

3. requestAnimationFrame

图中红线的意思是,你可能在这里访问 scrollWidth、clientHeight,getComputedStyle 等,触发强制重排,导致 Recalc Style 和 Layout 前移到代码执行过程中。

4. Parse HTML

如果有 DOM 变动,会解析 HTML。

5. Recalc Style

如果你在 JS 执行过程中改变了 DOM 或样式,就会执行这一步,重新指定元素及子元素的样式。

6. Layout

也就是我们常说的重排(reflow)。如果有涉及元素位置信息的 DOM 变动或者样式改动,那么浏览器会重新计算所有元素的位置、尺寸信息。而单纯修改 color、background 等则不会触发重排。详见:Css Triggers

7. Update Layer Tree

这一步实际是更新 Paint Layer 的层叠排序关系。因为之前更新了相关样式信息和重排,所以层叠情况也有可能变动。

8. Paint

Paint 有两步,本步是第一步,是由主线程执行的。第一步是记录要执行哪些绘画调用,具体来说,就是把所需要的操作记录序列化进一个叫 SkPicture 的数据结构中。

The SkPicture is a serializable data structure that can capture and then later replay commands, similar to a display list.
SkPicture 是一个序列化的数据结构,能够捕获命令,然后在过会儿重新下达,类似于一个展示列表。

主线程中,也就是我们在 Chrome devtools 的 Timeline 中看到的 Paint 其实都是这个第一步操作。第二步详见第十条。

9. Composite

主线程里的这一步会计算出每个 Graphics Layer 合成时所需要的 data,包括位移(Transition)、缩放(Scale)、旋转(Rotation)、Alpha 混合等操作的参数,并把这些内容传递给 Compositor Thread。这就是我们在图中看到的第一个 commit:主线程告诉 Compositor Thread,我搞定了,你接手吧。

接着,主线程会去执行 requestIdleCallback。

这一步虽然叫 Composite,但其实并没有真正对 Graphics Layers 完成位图的 Composite。甚至位图都还没有实际绘制出,是在下一步才绘制的。

10. Raster Scheduled and Rasterize

第 8 步生成的 SkPicture records 在本步执行,即,将 SkPicture 中的操作 replay 出来,真正执行:光栅化和填充进位图。

软件光栅化和硬件光栅化

SkPicture records on the compositor thread get turned into bitmaps on the GPU in one of two ways: either painted by Skia’s software rasterizer into a bitmap and uploaded to the GPU as a texture, or painted by Skia’s OpenGL backend (Ganesh) directly into textures on the GPU.
Compositor Thread 上的 SkPicture record 被转变位图是借助以下两种方式之一的:被 Skia 的软件光栅绘制为位图并作为纹理上传至 GPU,或者被 Skia 的 OpenGL backend(Ganesh)直接在 GPU 中绘制为纹理。

也就是说,光栅化有两种形式:

  • 一种是基于 CPU,使用 Skia 库的软件光栅化(Software Rasterization),首先绘制进位图,然后作为纹理上传至 GPU。这一方式中,Compositor Thread 会创建出一个或多个 Compositor Tile Worker Thread,然后多线程并行执行 SkPicture records 中的绘画操作,以之前介绍的 Graphics Layer 为单位,绘制 Graphics Layer 中的 Layout Object。同时,这一过程是将 Layer 拆分为多个小的 tile 进行光栅化后写入 tile 对应的位图的。

会发现我其实在此之前一直有一个谬误,觉得绘制是先绘制到位图,然后再对位图进行光栅化的。其实不是,是先绘制(没有实际绘制到绘图),然后光栅化,接着写入到位图的。此时位图也就是纹理。毕竟纹理其实就是合乎 GPU 规则的位图。
另一个谬误是,我认为位图是主线程绘制的,然后由 Compositor Tile Worker Thread 进行光栅化。其实也不是,绘制也是 Compositor Tile Worker Thread 做的,绘制完直接光栅化,然后写入对应位图。

  • 另一种是基于 GPU 的硬件光栅化(Hardware Rasterization),也是基于 Compositor Tile Worker Thread,也是分 tile 进行。但是这个过程不是像软件光栅化那样在 CPU 中绘制到位图的,而是借助 Skia’s OpenGL backend(Ganesh)直接在 GPU 中绘制为纹理。
硬件光栅化的好处

软件光栅化的方式,受限于 CPU 与 GPU 之间的上传带宽,把位图从 RAM 上传到 GPU 的 VRAM 的过程是有不可忽视的性能开销的。若光栅化的区域较大,那么使用软件光栅化很有可能在这里出现卡顿。

11. commit

如果是软件光栅化,所有 tile 的光栅化完成后,Compositor Thread 会 commit 通知 GPU Thread,于是所有 tile 的位图都会被作为纹理,由 GPU Thread 上传至 GPU。如果是使用 GPU 的硬件光栅化,那么此时纹理都已经在 GPU 中。

接下来,GPU Thread 会调用平台对应的 3D API,把所有纹理绘制到最终的一张位图里,从而完成纹理的合并。

同时,十分关键的一点是,在纹理合并之前,可以借助 3D API 的相关合成参数,对纹理 transformations(也就是前文提到过的位移,旋转,缩放,Alpha 通道改变等操作),先变形再合并。合并完成之后就可以将内容呈现到屏幕上了。

并不是每一次渲染都会从头到尾执行上述的十一步,比如 Layout、Paint、Rasterize、commit 可能一次也没有,Layout 也有可能不止一次。

重排和重绘

在这里,可以结合浏览器机制讲一下重排和重绘。

重排(Layout)

首先,如果你更改了一个影响元素布局信息的 CSS 样式,比如 width、height、left、top 等(transform 除外),那么浏览器会将当前 Layout 标记为 dirty,使得浏览器在下一帧执行上述十一个步骤的时候执行 Layout。因为元素的位置信息变了,将可能会导致整个网页其他元素的位置情况都发生改变,所以需要执行 Layout 全局重新计算每个元素的位置。

强制重排(Force Layout)

需要注意的是,浏览器是在下一帧、下一次渲染的时候才进行 Layout,并不是 JS 执行完这一行改变样式的语句时立即 Layout。所以,你可以在 JS 里写一百条更改 CSS 的语句,但是只会在下一帧重排一次。

但是,如果你在当前 Layout 被标记为 dirty 的情况下,访问了 offsetTop、scrollHeight 等属性,那么,浏览器会立即重新 Layout,计算出此时元素正确的位置信息,以保证你在 JS 中取到的 offsetTop、scrollHeight 等结果都是正确的。

会触发重排的属性和方法

  • Element:clientHeightclientLeftclientTopclientWidthfocus()getBoundingClientRect()getClientRects()innerTextoffsetHeightoffsetLeftoffsetParentoffsetTopoffsetWidthouterTextscrollByLines()scrollByPages()scrollHeightscrollIntoView()scrollIntoViewIfNeededscrollLeftscrollTopscrollWidth
  • Frame、Image:heightwidth
  • Range:getBoundingClientRect()getBoundingClientRects()
  • SVGLocatable:computeCTM()getBBox()
  • SVGTextContent:getCharNumAtPosition()getComputedTextLength()getEndPositionOfChar()getExtentOfChar()getNumberOfChars()getRotationOfChar()getStartPositionOfChar()getSubStringLength()selectSubString()
  • SVGUse:instanceRoot
  • window:getComputedStyle()scrollBy()scrollTo()scrollXscrollYwebkitConvertPointFromNodeToPage()webkitConvertPointFromPageToNode()

这一过程被称为强制重排(Force Layout),这一过程强制浏览器将本来在上述渲染过程中才执行的 Layout 过程前移至 JS 执行过程中。前移不是问题,问题是,每次在 Layout 为 dirty 时访问会触发重排的属性,都会强制重排。这极大延缓了浏览器的执行效率。

1
2
3
4
// Layout 未 dirty,访问 domA.offsetWidth 不会强制重排
domA.style.width = (domA.offsetWidth + 1) + 'px'
// Layout 已经 dirty,访问 domB.offsetWidth,强制重排
domB.style.width = (domB.offsetWidth + 1) + 'px'

这三行代码的后两行都触发了 Force Layout,Layout 一次的时间视 DOM 数量级从几十微秒到几十毫秒不等。相当于执行一行 JS 不足一微秒的时间,这个开销是难以接受的。所以也就有了读写分离、纯靠变量储存等避免 Force Layout 的方法。否则,你就会在 Timeline 中看到十多次 Recalculate Style 和 Layout 的画面了。

多次强制重排在 Chrome Timeline 中的效果

另外,每次重排或强制重排后,当前 Layout 就不再 dirty。所以,你再访问 offsetWidth 之类的属性,并不会触发重排。

1
2
3
4
5
6
7
8
9
10
11
12
// Layout 未 dirty,访问多少次都不会重排
console.log(domA.offsetWidth)
console.log(domB.offsetWidth)

// Layout 未 dirty,访问 domA.offsetWidth 不会触发重排
domA.style.width = (domA.offsetWidth + 1) + 'px'
// Layout 已经 dirty,触发重排
console.log(domC.offsetWidth)

// Layout 不再 dirty,不会触发重排
console.log(domA.offsetWidth)
console.log(domB.offsetWidth)

重绘(Paint)

重绘也是相似的。一旦更改了某个元素的会触发重绘的样式,那么浏览器就会在下一帧的渲染步骤中进行重绘。有一个非常关键点行为,就是,重绘是以合成层(也就是 Compositing Layer / Graphics Layer)为单位的。也就是说,重绘的不是整个文档,也不是单个元素,而是这个元素所在的合成层。

来看两个 demo。

demo1:

1
2
3
4
5
6
html > .ab-right + #target

.ab-right {
position: absolute;
right: 0;
}

demo2:

1
2
3
4
5
6
7
html > .ab-right + #target

.ab-right {
will-change: transform; // 多了这行
position: absolute;
right: 0;
}

于是,在第二个 demo 中出现了两个合成层:HTML 根元素的合成层和 .ab-right 所在的合成层。

然后,我们在 JS 中修改了 #target 元素的样式。于是,#target 元素所在的合成层,即 HTML 根元素的合成层,被重绘了。

在 demo1 中,.ab-right 没有被提升为合成层,所以 .ab-right 也被重绘了;而在 demo2 中,.ab-right 没有被重绘。

demo 1 中,.ab-right 被重绘了

demo 2 中,.ab-right 没有被重绘

此时,还可以顺便 Raster 一栏看看 Rasterization 的具体过程。前面已经介绍过了,这里真正完成 Paint 的操作,将内容绘制到位图或纹理中,而且是分 tile 进行的。

demo 2 的 Rasterization 过程

transform 和 opacity 两个属性不触发重排重绘?

在 Chrome 中如何查看合成层

修改一些 CSS 属性,如 widthfloatborderpositionfont-sizetext-alignoverflow-y 等会触发重排,重绘和合成,修改另一些属性如 colorbackground-colorvisibilitytext-decoration 等则不会触发重排,只会重绘和合成。

接下来,很多文章就会说,修改 opacitytransform 这两个属性仅会触发合成,不会触发重排和重绘。所以要用这两个属性实现动画,效率很高。

然而事实不是这样的。

只有一个元素被提升为合成层后,此情况才成立。

回到我们之前说的渲染过程的第十一步:

同时,十分关键的一点是,在纹理合并之前,可以借助 3D API 的相关合成参数,对纹理 transformations(也就是前文提到过的位移,旋转,缩放,Alpha 通道改变等操作),先变形再合并。合并完成之后就可以将内容呈现到屏幕上了。

在合成多个合成层时,确实可以借助 3D API 的相关参数,从而直接实现合成层的 transform、opacity 效果。所以,如果将一个元素提升为合成层,然后修改其 transform 或 opacity(无论是用 JS 还是 CSS 动画),确实会避免 CPU Paint 的过程,因为 transform 和 opacity 可以直接基于 GPU 的合成参数来完成。

但是,只有更改合成层整体的 transform 和 opacity 时才会这样。对于没有提升为合成层的元素,其 transform 和 opacity 的实现也是在 Paint 和 Rasterize 中完成的。所以还是会重排,也就没有启用我们常说的 GPU 加速动画。

例一

比如一个 demo,假设有一个提升为合成层的 #father 和未提升为合成层的 #child,更改 #father 和 #child 的 transform 属性,接下来的渲染流程是:

  1. Recalc Style(重新计算样式)
  2. Paint - 绘制变动的合成层,即 #father
    1. Paint - 绘制 #father 背景和 textNode
    2. Paint - 绘制 #child
      1. Paint - 先 translate,完成移动
      2. Paint - 在移动后的区域里绘制 #child 的背景和 textNode
  3. Rasterize(光栅化)
  4. Composite - 合并合成层,在合成时借助 3D API 的相关合成参数完成合成层的位移、旋转等变换。所以,#father 的 translate 是在这里才实现的。

例二

1
2
3
4
5
6
7
8
div {
height: 100px;
transition: height 1s linear;
}

div:hover {
height: 200px;
}

这段 transition 的实现过程是这样的:

图一

而如果代码变成:

1
2
3
4
5
6
7
8
div {
transform: scale(0.5);
transition: transform 1s linear;
}

div:hover {
transform: scale(1.0);
}

那么同样效果的 transition 实现过程则是这样的:

图二

也就是说,主线程没有进行重排和重绘,只是在 Composite 步骤计算出具体的 Compositing 参数,然后在 commit 之后,在 GPU 中完成大小变化。

不要盲目提升合成层

例二中的第二个例子,元素提升为合成层的原因是:

对 opacity、transform、filter 或 backdropfilter 应用了 animation 或者 transition (需要是 active 的 animation 或 transition,在 animation 或 transition 的效果未开始或结束后,提升 Graphics Layer 会失效)

可以看出,元素在 opacity 等属性具有 animation/transition 时,并不是直接提升为合成层,而是 animation 或 transition 开始时才提升为合成层,结束时合成层的提升也会失效。

同时,元素提升为合成层,或合成层失效时也会触发重排和重绘。在例二的第二个例子中,transition 开始前,div 尚未被提升为合成层。transition 开始,div 立即被提升为合成层,导致其原本所在的合成层重绘(因为要剔除掉提升为合成层的 div 元素),而 div 因为被提升为合成层,也立刻重绘。两个重绘好的合成层 Rasterize 后上传至 GPU。

动画开始前的一帧,分别重绘两个合成层

动画开始后的一帧,合成层失效,Paint 进父合成层

在上述 demo 中,只有两个 DOM,所以 Paint 的开销可以忽略。如果 DOM 数量多一点,那么在动画开始前和结束后就会产生很大的开销:

在开始前和结束后的帧产生很大的开销

实际上,只要一个元素被提升为合成层,在提升前和合成层失效后都会有这个过程。一方面是重绘整个层(而不是增量)带来了绘制开销,另一方面,也有纹理上传过程中由于 CPU 到 GPU 的带宽带来的上传开销。因此,处理不好可能导致动画开始前和结束后的一帧出现卡顿/延迟。

此处插入一个我的疑问。文章提到,提升合成层也会触发重绘,需要上传位图至 GPU,所以合成层提升需要慎用。但是,如果不提升合成层,动画一样会重绘(甚至还可能会重排),一样需要将新位图上传至 GPU,甚至更过分,每帧都要这样做。那么为什么能够借这个说合成层提升需要慎用呢?感觉只单单说占用内存听起来更加合理呢。

另外,合成层提升还会占用 GPU 的 VRAM,VRAM 不会很大。对于移动端,上述问题更甚。并且,合成层还存在隐式提升的情况,因此需要合理使用。