Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

浏览器初渲染/更新过程 #2

Open
mengqiuleo opened this issue Apr 6, 2024 · 0 comments
Open

浏览器初渲染/更新过程 #2

mengqiuleo opened this issue Apr 6, 2024 · 0 comments

Comments

@mengqiuleo
Copy link
Owner

mengqiuleo commented Apr 6, 2024

之前阅读过李兵老师的《浏览器工作原理与实践》,但还是对其中有些概念模糊,于是趁着国庆,对浏览器渲染更新原理进行梳理。

注:本篇只是对一些优秀资料的总结及自己的理解,并非作者原创!!!

如果时间充裕建议阅读本文最后的引用。

先放图:
liucheng

帧维度解释帧渲染过程

在一个流畅的页面变化效果中(动画或滚动),渲染帧,指的是浏览器从js执行到paint的一次绘制过程,帧与帧之间快速地切换,由于人眼的残像错觉,就形成了动画的效果。那么这个“快速”,要达到多少才合适呢?

我们都知道,下层建筑决定了上层建筑。受限于目前大多数屏幕的刷新频率——60次/s,浏览器的渲染更新的页面的标准帧率也为60次/s--60FPS(frames/per second)。

  • 高于这个数字,在一次屏幕刷新的时间间隔16.7ms(1/60)内,就算浏览器渲染了多次页面,屏幕也只刷新一次,这就造成了性能的浪费。
  • 低于这个数字,帧率下降,人眼就可能捕捉到两帧之间变化的滞涩与突兀,表现在屏幕上,就是页面的抖动,大家通常称之为卡顿

来个比喻。快递每天整理包裹,并一天一送。如果某天包裹太多,整理花费了太多时间,来不及当日(帧)送到收件人处,那就延期了(丢帧)。

标准渲染帧:
xuanranzhen

在一个标准帧渲染时间16.7ms之内,浏览器需要完成Main线程的操作,并commit给Compositor进程

丢帧:
diuzhen

主线程里操作太多,耗时长,commit的时间被推迟,浏览器来不及将页面draw到屏幕,这就丢失了一帧

一些名词解释

Renderer进程

  • Main线程:浏览器渲染的主要执行步骤,包含从JS执行到Composite合成的一系列操作。负责解析html css 和主线程中的js,我们平时熟悉的那些东西,诸如:Calculate Style,Update Layer Tree,Layout,Paint,Composite Layers等等都是在这个线程中进行的。 总之,就是将我们的代码解析成各种数据,直到能被合成器线程接收去做处理。
  • Compositor(合成)线程:
    1. 接收一个vsync信号,表示这一帧开始
    2. 接收用户的一些交互操作(比如滚动) ,然后commit给Main线程
    3. 唤起Main线程进行操作
    4. 接收Main线程的操作结果
    5. 将图层划分为图块(tile),并交给栅格化线程
    6. 拿到栅格化线程的执行结果,它的结果就是一些位图
    7. commit给真正把页面draw到屏幕上的GPU进程
  • Compositor Tile Work(s)线程:Compositor调起Compositor Tile Work(s)来辅助处理页面。Rasterize意为光栅化。这里的 Tile 其实就是位图的意思(下文会详细说明),合成线程会将图层划分为图块(tile),生成位图的操作是由栅格化来执行的。栅格化线程不止一个,可能有多个栅格化线程。

GPU进程

整个浏览器共用一个。主要是负责把Renderer进程中绘制好的tile位图作为纹理上传至GPU,并调用GPU的相关方法把纹理draw到屏幕上。GPU进程里只有一个线程:GPU Thread。

这里其实只需要知道:GPU进程把 render进程的结果 draw 到 页面上。

rendering(渲染) vs painting(绘制)⭐

这里的 painting 也可以理解成上面的 draw,火焰图中也会出现这两个关键词。
huoyantu

我们可以想象成 除了浏览器之外,还有一个后台工人,浏览器使用双缓冲,始终有两张图

  • rendering 渲染:后台工人画的过程,这里就是 浏览器的render进程
  • painting 绘制:当后台工人画好后往浏览器页面上放的过程,GPU进程负责将画好的东西paint(draw)到浏览器上
    gongren

后台工人先render一张,render完毕后,把浏览器的那张图替换下来叫paint(draw),然后后台工人又开始在替换下来的那张图上进行render

浏览器每一帧会替换一次,保证动画是连续的,很像动画那样一帧一帧

位图

weitu

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

纹理

纹理其实就是GPU中的位图,存储在GPU video RAM中。前面说的位图里的元素存什么你自己定义好就行,是用3字节存256位rgb还是1个bit存黑白你自己定义即可,但是纹理是GPU专用的,GPU和CPU是分离的,需要有固定格式,便于兼容与处理。所以一方面纹理的格式比较固定,如R5G6B5、A4R4G4B4等像素格式, 另外一方面GPU 对纹理的大小有限制,比如长/宽必须是2的幂次方,最大不能超过2048或者4096等。

总结:render进程中的叫位图,GPU进程中的叫纹理,生成位图(纹理)的这个过程叫栅格化,ok,过...

Rasterize(光栅化)

guangshanhua

guangshanhua2

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

同时,光栅化从早期的 Full-screen Rasterization基本都进化到了现在的Tile-Based Rasterization, 也就是不是对整个图像做光栅化,而是把图像分块(tile,亦有翻译为瓦片、贴片、瓷片…)后,再对每个tile单独光栅化。光栅化好了将像素填充进纹理,再将纹理上传至GPU。

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

看到下图中蓝色的那些青色的矩形了吗?他们就是tiles。
tiles

可以想见浏览器的一次绘制过程就是先把想绘制的内容如文字、背景、边框等通过分块Rasterize绘制到很多纹理里,再把纹理上传到gpu的存储空间里,gpu把纹理绘制到屏幕上。

上面balabala说了一大堆,看得懂就看,看不懂就直接看总结...

所以,什么是光栅化,光栅化本质也是生成位图(纹理),不过会先分块,然后对每一块进行生成位图,这个分块的过程是由合成线程实现的,生成位图的过程是栅格化线程实现的。为什么要先分块,再栅格化,而不直接对整块屏幕做栅格化?为了减少内存占用和多线程处理(那这就意味着栅格化线程不止一个,可能有多个栅格化线程)。

名词解释完了,开始详细介绍浏览器渲染的每一步。再次摆出整个渲染流程图。
liucheng

或者另外一张类似的流程图
leisi

1. 浏览器的某一帧开始:vsync

Compositor(合成)线程接收一个vsync信号,表示这一帧开始

2. Input event handlers

Compositor线程接收用户的交互输入(比如touchmove、scroll、click等)。然后commit给Main线程,这里有两点规则需要注意:

  • 并不是所有event都会commit给Main线程,部分操作比如单纯的滚动事件,打字等输入,不需要执行JS,也没有需要重绘的场景,Compositor线程就自己处理了,无需请求Main线程
  • 同样的事件类型,不论一帧内被Compositor线程接收多少次,实际上commit给Main线程的,只会是一次,意味着也只会被执行一次。(HTML5标准里scroll事件是每帧触发一次),所以自带了相对于动画的节流效果!scroll、resize、touchmove、mousemove等事件,由于Compositor Thread的机制原因,都会每一帧只执行一次

3. requestAnimationFrame

window.requestAnimationFrame() 这个方法,既然已经说明了它是一个方法,那它一定是在 JavaScript 中执行的。

4. 强制重排(可能存在)

Avoid large, complex layouts and layout thrashing

下面对这个引用文章进行解释:

这里本来已经走到了我们熟知的浏览器渲染过程:

js修改dom结构或样式 -> 计算style -> layout(重排) -> paint(重绘) -> composite(合成)

首先运行 JavaScript,然后运行样式计算,最后运行布局。然而,可以使用 JavaScript 强制浏览器提前执行布局。这称为强制同步布局。

接下来解释 强制重排,也叫强制同步布局。

首先要记住的是,当 JavaScript 运行时,前一帧中的所有旧布局值都是已知的,可供您查询。因此,例如,如果您想在帧的开头写出元素(我们称之为“盒子”)的高度,您可以编写如下代码:

// Schedule our function to run at the start of the frame:
requestAnimationFrame(logBoxHeight);

function logBoxHeight () {
  // Gets the height of the box in pixels and logs it out:
  console.log(box.offsetHeight);
}

如果您在询问框的高度之前更改了框的样式,则会出现问题:

function logBoxHeight () {
  box.classList.add('super-big');

  // Gets the height of the box in pixels and logs it out:
  console.log(box.offsetHeight);
}

现在,为了回答高度问题,浏览器必须_首先_应用样式更改(因为添加了super-big类),_然后_运行布局。只有这样它才能返回正确的高度。这是不必要且可能昂贵的工作。这就是强制重排。

强制重排意思是可能会在JS里强制重排,当访问scrollWidth系列、clientHeight系列、offsetTop系列、ComputedStyle等属性时,会触发这个效果,导致Style和Layout前移到JS代码执行过程中

浏览器有自己的优化机制,包括之前提到的每帧只响应同类别的事件一次,再比如这里的会把一帧里的多次重排、重绘汇总成一次进行处理。

flush队列是浏览器进行重排、重绘等操作的队列,所有会引起重排重绘的操作都包含在内,比如dom修改、样式修改等。如果每次js操作都去执行一次重排重绘,那么浏览器一定会卡卡卡卡卡,所以浏览器通常是在一定的时间间隔(一帧)内,批量处理队列里的操作。但是,对于有些操作,比如获取元素相对父级元素左边界的偏移值(Element.offsetLeft),但在此之前我们进行了样式或者dom修改,这个操作还攒在flush队列里没有执行,那么浏览器为了让我们获取正确的offsetLeft(虽然之前的操作可能不会影响offsetLeft的值),就会立即执行队列里的操作。
qiangzhichongpai

所以我们知道了,就是这个特殊操作会影响浏览器正常的执行和渲染,假设我们频繁执行这样的特殊操作,就会打断浏览器原来的节奏,增大开销。

而这个特殊操作,具体指的就是:

  • elem.offsetLeft, elem.offsetTop, elem.offsetWidth, elem.offsetHeight, elem.offsetParent
  • elem.clientLeft, elem.clientTop, elem.clientWidth, elem.clientHeight
  • elem.getClientRects(), elem.getBoundingClientRect()
  • elem.scrollWidth, elem.scrollHeight
  • elem.scrollLeft, elem.scrollTop
  • ...

更多会触发强制重排的属性:See more:What forces layout / reflow

5. parse HTML(构建DOM树)

如果有DOM变动,那么会有解析DOM的这一过程。

DOM

6. 计算样式

样式计算的目的是为了计算出DOM节点中每个元素的具体样式,这个阶段大体可分为三步来完成

6.1 把CSS转换为浏览器能够理解的结构

那CSS样式的来源主要有哪些呢?你可以先参考下图:

yangshi

从图中可以看出,CSS样式来源主要有三种:

  • 通过link引用的外部CSS文件
  • <style> 标记内的 CSS
  • 元素的style属性内嵌的CSS
  • 和HTML文件一样,浏览器也是无法直接理解这些纯文本的CSS样式,所以当渲染引擎接收到CSS文本时,会执行一个转换操作,将CSS文本转换为浏览器可以理解的结构——styleSheets。
  • 为了加深理解,你可以在Chrome控制台中查看其结构,只需要在控制台中输入document.styleSheets,然后就看到如下图所示的结构

从图中可以看出,这个样式表包含了很多种样式,已经把那三种来源的样式都包含进去了。当然样式表的具体结构不是我们今天讨论的重点,你只需要知道渲染引擎会把获取到的CSS文本全部转换为styleSheets结构中的数据,并且该结构同时具备了查询和修改功能,这会为后面的样式操作提供基础

6.2 转换样式表中的属性值,使其标准化

现在我们已经把现有的CSS文本转化为浏览器可以理解的结构了,那么接下来就要对其进行属性值的标准化操作。

要理解什么是属性值标准化,你可以看下面这样一段CSS文本

body { font-size: 2em }
p {color:blue;}
span  {display: none}
div {font-weight: bold}
div  p {color:green;}
div {color:red; }

可以看到上面的CSS文本中有很多属性值,如2em、blue、bold,这些类型数值不容易被渲染引擎理解,所以需要将所有值转换为渲染引擎容易理解的、标准化的计算值,这个过程就是属性值标准化。

那标准化后的属性值是什么样子的?
biaozhunhua

从图中可以看到,2em被解析成了32px,red被解析成了rgb(255,0,0),bold被解析成了700……

6.3 计算出DOM树中每个节点的具体样式

现在样式的属性已被标准化了,接下来就需要计算DOM树中每个节点的样式属性了,如何计算呢?

这就涉及到CSS的继承规则和层叠规则了。

首先是CSS继承。CSS继承就是每个DOM节点都包含有父节点的样式。这么说可能有点抽象,我们可以结合具体例子,看下面这样一张样式表是如何应用到DOM节点上的

body { font-size: 20px }
p {color:blue;}
span  {display: none}
div {font-weight: bold;color:red}
div  p {color:green;}

这张样式表最终应用到DOM节点的效果如下图所示:
jutiyangshi

从图中可以看出,所有子节点都继承了父节点样式。比如body节点的font-size属性是20,那body节点下面的所有节点的font-size都等于20。

为了加深你对CSS继承的理解,你可以打开Chrome的“开发者工具”,选择第一个“element”标签,再选择“style”子标签,你会看到如下界面
chrome

这个界面展示的信息很丰富,大致可描述为如下

  • 首先,可以选择要查看的元素的样式(位于图中的区域2中),在图中的第1个区域中点击对应的元素元素,就可以了下面的区域查看该元素的样式了。比如这里我们选择的元素是

    标签,位于html.body.div.这个路径下面

  • 其次,可以从样式来源(位于图中的区域3中)中查看样式的具体来源信息,看看是来源于样式文件,还是来源于UserAgent样式表。这里需要特别提下UserAgent样式,它是浏览器提供的一组默认样式,如果你不提供任何样式,默认使用的就是UserAgent样式。
  • 最后,可以通过区域2和区域3来查看样式继承的具体过程。

以上就是CSS继承的一些特性,样式计算过程中,会根据DOM节点的继承关系来合理计算节点样式。

样式计算过程中的第二个规则是样式层叠。层叠是CSS的一个基本特征,它是一个定义了如何合并来自多个源的属性值的算法。它在CSS处于核心地位,CSS的全称“层叠样式表”正是强调了这一点。关于层叠的具体规则这里就不做过多介绍了,网上资料也非常多,你可以自行搜索学习

总之,样式计算阶段的目的是为了计算出DOM节点中每个元素的具体样式,在计算过程中需要遵守CSS的继承和层叠两个规则。这个阶段最终输出的内容是每个DOM节点的样式,并被保存在ComputedStyle的结构内。

7. 构建Render Tree(渲染树)的流程:

  1. 从DOM树的根开始,遍历每个可见节点。
    1. 一些节点不可见(例如,脚本标签,meta标签等),由于它们未反映在输出中,因此将其省略。
    2. 一些节点通过CSS隐藏,在渲染树中也被省略。注意visibility: hidden有所不同于display: none。
  2. 对于每个可见节点,找到合适的CSSOM规则并应用它们。
  3. 输出每个可见节点具有的内容及其样式。

最终产出一个Render Tree,其中包含屏幕上所有可见内容的内容和样式信息。

浏览器已经计算了哪些节点应该可见以及它们的样式,但是还没有计算它们在设备视口中的确切位置和大小,这是layout阶段该做的事,也称为“重排”

Render Tree中储存节点渲染信息的对象叫做Render Object(这个概念需要留意,下面会用到)

8. Layout(重排reflow),构建布局树

我们已经知道了DOM节点的大小,但是还不知道它在页面上的具体位置,这一步就是构建布局树,也叫重排。

主线程遍历Render Tree,并创建布局树,该树具有诸如xy坐标和边界框大小之类的信息。布局树的结构可能与DOM树类似,但它仅包含与页面上可见内容有关的信息。如果应用display: none,则该元素不属于布局树(但是,具有visibility: hidden的元素在布局树中)。同样,如果应用了具有类似的伪类p::before{content:"Hi!"},则即使它不在DOM中,它也将包含在布局树中。

但是现在有个问题,我们还不知道以什么顺序绘制它们,即不知道谁应该覆盖谁。
reflow

其实很多资料中都会把上面构建渲染树的步骤放到构建布局树的步骤中

9. 分层、合成层(或者叫update layer tree)

如果我们是首次渲染,那就是分层,如果是更新操作,叫update layer tree。

9.1 分层

现在我们有了布局树,而且每个元素的具体位置信息都计算出来了,那么接下来是不是就要开始着手绘制页面了?
答案依然是否定的。

因为页面中有很多复杂的效果,如一些复杂的3D变换、页面滚动,或者使用z-indexing做z轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)。如果你熟悉PS,相信你会很容易理解图层的概念,正是这些图层叠加在一起构成了最终的页面图像。

要想直观地理解什么是图层,你可以打开Chrome的“开发者工具”,选择“Layers”标签,就可以可视化页面的分层情况,如下图所示

从上图可以看出,渲染引擎给页面分了很多图层,这些图层按照一定顺序叠加在一起,就形成了最终的页面,你可以参考下图

现在你知道了浏览器的页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面。下面我们再来看看这些图层和布局树节点之间的关系,如文中图所示:
layoutTree

通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。如上图中的span标签没有专属图层,那么它们就从属于它们的父节点图层。但不管怎样,最终每一个节点都会直接或者间接地从属于一个层。

那么需要满足什么条件,渲染引擎才会为特定的节点创建新的层呢?通常满足下面两点中任意一点的元素就可以被提升为单独的一个图层。

第一点,拥有层叠上下文属性的元素会被提升为单独的一层。

页面是个二维平面,但是层叠上下文能够让HTML元素具有三维概念,这些HTML元素按照自身属性的优先级分布在垂直于这个二维平面的z轴上。你可以结合下图来直观感受下:
z-index

从图中可以看出,明确定位属性的元素、定义透明属性的元素、使用CSS滤镜的元素等,都拥有层叠上下文属性。

第二点,需要剪裁(clip)的地方也会被创建为图层。

不过首先你需要了解什么是剪裁,结合下面的HTML代码:

<style>
      div {
            width: 200;
            height: 200;
            overflow:auto;
            background: gray;
        } 
</style>
<body>
    <div >
        <p>所以元素有了层叠上下文的属性或者需要被剪裁,那么就会被提升成为单独一层,你可以参看下图:</p>
        <p>从上图我们可以看到,document层上有A和B层,而B层之上又有两个图层。这些图层组织在一起也是一颗树状结构。</p>
        <p>图层树是基于布局树来创建的,为了找出哪些元素需要在哪些层中,渲染引擎会遍历布局树来创建层树(Update LayerTree)。</p> 
    </div>
</body>

在这里我们把div的大小限定为200 * 200像素,而div里面的文字内容比较多,文字所显示的区域肯定会超出200 * 200的面积,这时候就产生了剪裁,渲染引擎会把裁剪文字内容的一部分用于显示在div区域,下图是运行时的执行结果
caijian

出现这种裁剪情况的时候,渲染引擎会为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层。你可以参考下图:
gundongtiao

所以说,元素有了层叠上下文的属性或者需要被剪裁,满足这任意一点,就会被提升成为单独一层。

9.2 update layer tree

这一步实际是更新Render Layer的层叠排序关系。

9.3 补充解释:Render Object、Render Layer、Graphics Layer(又称Compositing Layer)和Graphics Context

buchongjieshi

Render Object

首先我们有DOM树,但是DOM树里面的DOM是供给JS/HTML/CSS用的,并不能直接拿过来在页面或者位图里绘制。因此浏览器内部实现了Render Object

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

既然实现了绘制每个DOM节点的方法,那是不是可以开辟一段位图空间,然后DFS遍历这个新的Render Object树然后执行每个Render Object的绘制方法就可以将DOM绘制进位图了?就像“盖章”一样,把每个Render Object的内容一个个的盖到纸上(类比于此时的位图)是不是就完成了绘制。

不,浏览器还有个层叠上下文的东西。这使得文档流中位置靠前位置的元素有可能覆盖靠后的元素。上述DFS过程只能无脑让文档流靠后的元素覆盖前面元素。

因此,有了Render Layer。

Render Layer

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

总之就是有层叠、半透明等等情况的元素就会从Render Object提升为Render Layer。不提升为Render Layer的Render Object从属于其父级元素中最近的那个Render Layer。当然根元素HTML自己要提升为Render Layer。

因此现在Render Object树就变成了Render Layer树,每个Render Layer又包含了属于自己layer的Render Object。

现在浏览器渲染引擎遍历 Layer 树,访问每一个 RenderLayer,然后递归遍历negZOrderList里的layer、自己的RenderObject、再递归遍历posZOrderList里的layer。就可以将一颗 Layer树绘制出来。

Layer 树决定了网页绘制的层次顺序,而从属于 RenderLayer 的 RenderObject 决定了这个 Layer 的内容,所有的 RenderLayer 和 RenderObject 一起就决定了网页在屏幕上最终呈现出来的内容。

层叠上下文、半透明、mask等等问题通过Render Layer解决了。那么现在:

开辟一个位图空间->不断的绘制Render Layer、覆盖掉较低的Layer->拿给GPU显示出来 是不是就完全ok了?

不。还有GraphicsLayers和Graphics Context

Graphics Layer(又称Compositing Layer)和Graphics Context

合成层的东西。

上面的过程可以搞定绘制过程。但是浏览器里面经常有动画、video、canvas、3d的css等东西。这意味着页面在有这些元素时,页面显示会经常变动,也就意味着位图会经常变动。每秒60帧的动效里,每次变动都重绘整个位图是很恐怖的性能开销。

因此浏览器为了优化这一过程。引出了Graphics Layers和Graphics Context,前者就是我们常说的合成层(Compositing Layer)

某些具有CSS3的3D transform的元素、在opacity、transform属性上具有动画的元素、硬件加速的canvas和video等等,这些元素在上一步会提升为Render Layer,而现在他们会提升为合成层Graphics Layer。每个Render Layer都属于他祖先中最近的那个Graphics Layer。当然根元素HTML自己要提升为Graphics Layer。

Render Layer提升为Graphics Layer的情况:

  • 3D 或透视变换(perspective、transform) CSS 属性
  • 使用加速视频解码的 元素
  • 拥有 3D (WebGL) 上下文或加速的 2D 上下文的 元素
  • 混合插件(如 Flash)
  • 对 opacity、transform、fliter、backdropfilter 应用了 animation 或者 transition(需要是 active 的 animation 或者 transition,当 animation 或者 transition 效果未开始或结束后,提升合成层也会失效)
  • will-change 设置为 opacity、transform、top、left、bottom、right(其中 top、left 等需要设置明确的定位属性,如 relative 等)
  • 拥有加速 CSS 过滤器的元素
  • 元素有一个 z-index 较低且包含一个复合层的兄弟元素(换句话说就是该元素在复合层上面渲染)

3D transform、will-change设置为 opacity、transform等 以及 包含opacity、transform的CSS过渡和动画 这3个经常遇到的提升合成层的情况请重点记住。

所以在元素存在transform、opacity等属性的css animation或者css transition时,动画处理会很高效,这些属性在动画中不需要重绘,只需要重新合成即可。

在前端页面,尤其是在动画过程中,由于 Overlap 重叠导致的合成层提升很容易发生。如果每次都将重叠的顶部 RenderLayer 提升为合成层,那将消耗大量的 CPU 和内存(Webkit 需要给每个合成层分配一个后端存储)。为了避免 “层爆炸” 的发生,浏览器会进行层压缩(Layer Squashing):如果多个 RenderLayer 和同一个合成层重叠时,这些 RenderLayer 会被压缩至同一个合成层中,也就是位于同一个合成层。但是对于某些特殊情况,浏览器并不能进行层压缩,就会造成创建大量的合成层。

RenderObject、 RenderLayer、 GraphicsLayer 是 Webkit 中渲染的基础,其中 RenderLayer 决定了渲染的层级顺序,RenderObject 中存储了每个节点渲染所需要的信息,GraphicsLayer 则使用 GPU 的能力来加速页面的渲染。

使用 合成层提升 减少重绘重排

提升为合成层干什么呢?普通的渲染层普通地渲染,用普通的顺序普通地合成不好吗?非要搞啥特殊待遇!

浏览器就说了:我这也是为了大家共同进步(提升速度)!看那些搞特殊待遇的,都是一些拖我们队伍后腿的(性能开销大),分开处理,才能保证整个队伍稳定快速的进步!

特殊待遇:合成层的位图,会交由 GPU 合成,比 CPU 处理要快。当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层。

  • 对布局属性进行动画,浏览器需要为每一帧进行重绘并上传到 GPU 中
  • 对合成属性进行动画,浏览器会为元素创建一个独立的复合层,当元素内容没有发生改变,该层就不会被重绘,浏览器会通过重新复合来创建动画帧

通过生成独立的Compositing Layer,让此层内的重绘重排不引起整个页面的重绘重排

在介绍渲染树的时候提到满足某些条件的 RenderObjectLayer 会被提升为合成层,合成层的绘制是在 GPU 中进行的,比 CPU 的性能更好;如果该合成层需要 Paint,不会影响其他的合成层;一些合成层的动画,不会触发 Layout 和 Paint。

下面介绍几种在开发中常用的合成层提升的方式:

使用transform和opacity书写动画

上文提出,如果一个元素使用了 CSS 透明效果的动画或者 CSS 变换的动画,那么它会被提升为合成层。并且这些动画变换实际上是应用在合成层本身上。这些动画的执行过程不需要主线程的参与,在纹理合成前,使用 3D API 对合成层进行变形即可。

#cube {
  transform: translateX(0);
  transition: transform 3s linear;
}

#cube.move {
  transform: translateX(100px);
}
<body>
  <div id="button">点击移动</div>
  <div id="cube"></div>
  <script>
    const btn = document.getElementById('button');
    btn.addEventListener('click', () => {
      const cube = document.getElementById('cube');
      cube.classList = 'move';
    });
  </script>
</body>

对于上面的动画,只有在动画开始后,才会进行合成层的提升,动画结束后合成层提升也会消失。这也就避免了浏览器创建大量的合成层造成的 CPU 性能损耗。

will-change

这个属性告诉了浏览器,接下来会对某些元素进行一些特殊变换。当 will-change 设置为 opacity、transform、top、left、bottom、right(其中 top、left、bottom、right 等需要设置明确的定位属性,如 relative 等),浏览器会将此元素进行合成层提升。在书写过程中,需要避免以下的写法:

*{ will-change: transform, opacity; }

这样,所有的元素都会被提升为单独的合成层,造成大量的内存占用。所以需要只针对动画元素设定 will-change,且动画完成之后,需要手动将此属性移除。

Canvas

使用具有加速的 2D Context 或者 3D Contex 的 Canvas 来完成动画。由于具有独立的合成层,Canvas 的改变不会影响其他合成层的绘制,这种情况对于大型复杂动画(比如 HTML5 游戏)更为适用。此外,也可以设置多个 Canvas 元素,通过合理的Canvas 分层来减少绘制开销。

paint(图层绘制),重绘

重绘是以合成层为单位的

在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制,那么接下来我们看看渲染引擎是怎么实现图层绘制的?

试想一下,如果给你一张纸,让你先把纸的背景涂成蓝色,然后在中间位置画一个红色的圆,最后再在圆上画个绿色三角形。你会怎么操作呢?

通常,你会把你的绘制操作分解为三步

  • 制蓝色背景;
  • 在中间绘制一个红色的圆;
  • 再在圆上绘制绿色三角形

渲染引擎实现图层的绘制与之类似,会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表,如下图所示:
liebiao

从图中可以看出,绘制列表中的指令其实非常简单,就是让其执行一个简单的绘制操作,比如绘制粉色矩形或者黑色的线等。而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。所以在图层绘制阶段,输出的内容就是这些待绘制列表。

其实Paint有两步,第一步是记录要执行哪些绘画调用,第二步才是执行这些绘画调用。第一步只是把所需要进行的操作记录序列化进一个叫做SkPicture的数据结构里,就是上面所说的待绘制列表。

接下来的第二步里会将待绘制列表中的操作replay出来,这里才是将这些操作真正执行:光栅化和填充进位图。主线程中和我们在Timeline中看到的这个Paint其实是Paint的第一步操作。第二步是后续的Rasterize步骤(见后文),其实在Rasterize之前会先分成图块,关于这两个概念的解释在最开始有提到。

主线程:生成待绘制列表,交给合成线程
合成线程:分成图块,交给栅格化线程
栅格化线程:栅格化(生成位图)
接着就是将栅格化的结果交给 GPU进程进行draw到浏览器上
这里其实有争议,栅格化的结果是直接由栅格化线程交给GPU,还是栅格化线程先将结果交给合成线程,合成线程再把结果交给GPU进程。

分成图块 + 栅格化(raster)操作

绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的。你可以结合下图来看下渲染主线程和合成线程之间的关系:
raster1

如上图所示,当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给合成线程,那么接下来合成线程是怎么工作的呢?

那我们得先来看看什么是视口,你可以参看下图:
raster2

通常一个页面可能很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个部分叫做视口(viewport)。
在有些情况下,有的图层可以很大,比如有的页面你使用滚动条要滚动好久才能滚动到底部,但是通过视口,用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。

基于这个原因,合成线程会将图层划分为图块(tile),这些图块的大小通常是256x256或者512x512,如下图所示:
raster3

然后合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图。而图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的,运行方式如下图所示:
raster4

通常,栅格化过程都会使用GPU来加速生成,使用GPU生成位图的过程叫快速栅格化,或者GPU栅格化,生成的位图被保存在GPU内存中。

相信你还记得,GPU操作是运行在GPU进程中,如果栅格化操作使用了GPU,那么最终生成位图的操作是在GPU中完成的,这就涉及到了跨进程操作。具体形式你可以参考下图:
raster5

从图中可以看出,渲染进程把生成图块的指令发送给GPU,然后在GPU中执行生成图块的位图,并保存在GPU的内存中。

draw

GPU进程把结果draw到浏览器上

引用

https://developer.chrome.com/blog/inside-browser-part3/

浏览器渲染详细过程:重绘重排和 composite 只是冰山一角 - 掘金

https://segmentfault.com/a/1190000041295744

https://gist.github.com/paulirish/5d52fb081b3570c81e3a

渲染流程(下):HTML、CSS和JavaScript是如何变成页面的 | 浏览器工作原理与实践

如何不择手段提升scroll事件的性能

aooy/blog#5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant