最近看网络上大神们讨论提到了合成层爆炸的问题,这个名词我还是第一次看到,遂去检索了相关的信息,发觉自己之前背的“从输入url到页面显示内容”这个常规八股里实际上还是有很多东西需要细究的,其中就包括浏览器渲染流程这一环节。今天对浏览器渲染的流程做一个总体的整理。
首先我们先简单了解一下浏览器渲染流水线上的一些工作环节,初次渲染时会经过以下几步。
- 构建DOM树;
- 样式计算;
- 布局定位;
- 图层分层;
- 图层绘制;
- 合成显示;

下面按照这个流程在详细阐述每个环节发生了什么。
解析HTML,构建DOM树
主要转化过程为:
📌字节→字符→令牌→节点→对象模型(DOM)

转换: 浏览器从磁盘或网络读取 HTML 的原始字节,并根据文件的指定编码(例如 UTF-8)将它们转换成各个字符。
令牌化: 浏览器将字符串转换成W3C HTML5 标准规定的各种令牌,例如,“”、“”,以及其他尖括号内的字符串。每个令牌都具有特殊含义和一组规则。
Token分为Tag Token 和文本 Token。下面来看一个HTML代码是如何被拆分的:
1
2
3
4
5<body>
<div>
<p>hello world</p>
</div>
</body>对于这段代码,可以拆成词:

可以看到,Tag Token 又分 StartTag 和 EndTag,
<body>、<div>、<p>就是 StartTag ,</body>、</div>、</p>就是 EndTag,分别对应图中的蓝色和红色块,文本 Token 对应绿色块。这里会通过状态机将字符拆分成token,所谓的状态机就是将每个词的特征逐个拆分成独立的状态,然后再将所有词的特征字符合并起来,形成一个连通的图结构。那为什么要使用状态机呢?因为每读取一个字符,都要做一次决策,这些决策都和当前的状态有关。
实际上,状态机的作用就是用来做词法分析的,将字符流分解为词(token)。
词法分析: 发出的令牌转换成定义其属性和规则的“对象”。
DOM构建: 最后,由于 HTML 标记定义不同标记之间的关系(一些标记包含在其他标记内),创建的对象链接在一个树数据结构内,此结构也会捕获原始标记中定义的父项-子项关系:HTML 对象是 body 对象的父项,body 是 paragraph 对象的父项,依此类推。
这个过程是通过栈结构来实现的,这个栈主要用来计算节点之间的父子关系,上面步骤中生成的token会按顺序压入栈中,该过程的规则如下:
- 如果分词器解析出来是StartTag Token,HTML 解析器会为该 Token 创建一个 DOM 节点,然后将该节点加入到 DOM 树中,它的父节点就是栈中相邻的那个元素生成的节点;
- 如果分词器解析出来是 文本Token,那么会生成一个文本节点,然后将该节点加入到 DOM 树中,文本 Token 是不需要压入到栈中,它的父节点就是当前栈顶 Token 所对应的 DOM 节点;
- 如果分词器解析出来的是EndTag Token,比如是 EndTag div,HTML 解析器会查看 Token 栈顶的元素是否是 StarTag div,如果是,就将 StartTag div从栈中弹出,表示该 div 元素解析完成。
通过分词器产生的新 Token 就这样不停地入栈和出栈,整个解析过程就这样一直持续下去,直到分词器将所有字节流分词完成。
下面来看看这的Token栈是如何工作的,有如下HTML结构:开始时,HTML解析器会创建一个根为 document 的空的 DOM 结构,同时将 StartTag document 的Token压入栈中,然后再将解析出来的第一个 StartTag html 压入栈中,并创建一个 html 的DOM节点,添加到document上,这时Token栈和DOM树如下:1
2
3
4
5
6<html>
<body>
<div>hello juejin</div>
<p>hello world</p>
</body>
</html>

接下来body和div标签也会和上面的过程一样,进行入栈操作:

随后就会解析到 div标签中的文本Token,渲染引擎会为该 Token 创建一个文本节点,并将该 Token 添加到 DOM 中,它的父节点就是当前 Token 栈顶元素对应的节点:

接下来就是第一个EndTag div,这时 HTML 解析器会判断当前栈顶元素是否是 StartTag div,如果是,则从栈顶弹出 StartTag div,如下图所示:

再之后的过程就和上面类似了,最终的结果如下:

样式计算(解析CSS,构建CSSOM树)
同样,浏览器也是无法直接理解CSS代码的,需要将其浏览器可以理解的CSSOM树。实际上。浏览器在构建 DOM 树的同时,如果样式也加载完成了,那么 CSSOM 树也会同步构建。CSSOM 树和 DOM 树类似,它主要有两个作用:
- 提供给 JavaScript 操作样式的能力;
- 为渲染树的合成提供基础的样式信息。
不过,CSSOM 树和 DOM 树是独立的两个数据结构,它们并没有一一对应关系。DOM 树描述的是 HTML 标签的层级关系,CSSOM 树描述的是选择器之间的层级关系。可以在浏览器的控制台,通过document.styleSheets命令来查看CSSOM树:
这个阶段主要有三个步骤
格式化样式表
我们拿到的是0,1字节流数据,浏览器无法直接识别,所以渲染引擎收到CSS文本数据后,会执行一个操作,转换为浏览器可以理解的结构-
styleSheets标准化样式表
当遇到以下CSS样式:
1
2
3
4
5body { font-size: 2em }
p {color:blue;}
div {font-weight: bold}
div p {color:green;}
div {color:red; }可以看到上面CSS中有很多属性值,比如2em、blue、red、bold等,这些数值并不能被浏览器直接理解。所以,需要将所有值转化为浏览器渲染引擎容易理解的、标准化的计算值,这个过程就是属性值标准化。经过标准化的过程,上面的代码会变成这样:
1
2
3
4
5body { font-size: 32px }
p {color: rgb(0, 0, 255);}
div {font-weight: 700}
div p {color: (0, 128, 0);}
div {color: (255, 0, 0); }可以看到,2em被解析成了32px,blue被解析成了rgb(255, 0, 0),bold被解析成700。现在样式的属性已被标准化了,接下来就需要计算 DOM 树中每个节点的样式属性了,这就涉及到 CSS 的继承规则和层叠规则。
计算每个DOM节点具体样式
计算规则:继承和层叠
- 继承:每个子节点会默认去继承父节点的样式,如果父节点中找不到,就会采用浏览器默认的样式,也叫UserAgent样式。
- 层叠:样式层叠,是CSS一个基本特征,它定义如何合并来自多个源的属性值的算法。某种意义上,它处于核心地位,具体的层叠规则属于深入 CSS 语言的范畴,这里就不展开篇幅说了。
不过值得注意的是,在计算完样式之后,所有的样式值会被挂载到window.getComputedStyle当中,也就是可以通过JS来获取计算后的样式,非常方便。
这个阶段,完成了DOM节点中每个元素的具体样式,计算过程中要遵循CSS的继承和层叠两条规则,最终输出的内容是每个节点DOM的样式,被保存在ComputedStyle中。
对于以下代码:
1 | <html> |
最终生成的CSSOM树大致如下:

布局定位(构建渲染树)
上述过程已经完成DOM树(DOM树)构建,以及样式计算(DOM样式),接下来就是要通过浏览器的布局系统确定元素位置,也就是生成一颗布局树(Layout Tree),之前说法叫 渲染树。
创建布局树
在DOM树上不可见的元素,head元素,meta元素等,以及使用display:none属性的元素,最后都不会出现在布局树上,所以浏览器布局系统需要额外去构建一棵只包含可见元素布局树。
我们直接结合图来看看这个布局树构建过程:

为了构建布局树,浏览器布局系统大体上完成了下面这些工作:
- 遍历DOM树可见节点,并把这些节点加到布局树中
- 对于不可见的节点,head,meta标签等都会被忽略。对于body.p.span 这个元素,它的属性包含display:none,所以这个元素没有被包含进布局树。
这里在查找的过程中,出于效率的考虑,会从 CSSOM 树的叶子节点开始查找,对应在 CSS 选择器上也就是从选择器的最右侧向左查找。所以,不建议使用标签选择器和通配符选择器来定义元素样式。
除此之外,同一个 DOM 节点可能会匹配到多个 CSSOM 节点,而最终的效果由哪个 CSS 规则来确定,就是样式优先级的问题了。当一个 DOM 元素受到多条样式控制时,样式的优先级顺序如下:内联样式 > ID选择器 > 类选择器 > 标签选择器 > 通用选择器 > 继承样式 > 浏览器默认样式
布局计算
接下来就是要计算布局树节点的坐标位置,
通过计算渲染树上每个节点的样式,就能得出来每个元素所占空间的大小和位置。当有了所有元素的大小和位置后,就可以在浏览器的页面区域里去绘制元素的边框了。这个过程就是布局。这个过程中,浏览器对渲染树进行遍历,将元素间嵌套关系以盒模型的形式写入文档流:

盒模型在布局过程中会计算出元素确切的大小和定位。计算完毕后,相应的信息被写回渲染树上,就形成了布局渲染树。同时,每一个元素盒子也都携带着自身的样式信息,作为后续绘制的依据。
图层分层
页面上可能有很多复杂的场景,比如3D变化、页面滚动、使用z-index进行z轴的排序等。所以,为了实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树。
那什么是图层呢?我们可以在Chrome浏览器的开发者工具中,选择Layers标签(如果没有,可以在更多工具中查找),就可以看到页面的分层情况,以掘金首页为例,其分层情况如下:

可以看到,渲染引擎给页面分了很多图层,这些图层会按照一定顺序叠加在一起,就形成了最终的页面。这里,将页面分解成多个图层的操作就成为分层, 最后将这些图层合并到一层的操作就成为合成, 分层和合成通常是一起使用的。Chrome 引入了分层和合成的机制就是为了提升每帧的渲染效率。

注意:首先说明,这里讨论的是 WebKit,描述的是 Chrome 的实现细节,而并非是 web 平台的功能,因此这里介绍的内容不一定适用于其他浏览器。
- Chrome 拥有两套不同的渲染路径(rendering path):硬件加速路径和旧软件路径(older software path)
- Chrome 中有不同类型的层: RenderLayer(负责 DOM 子树)和GraphicsLayer(负责 RenderLayer的子树),只有 GraphicsLayer 是作为纹理(texture)上传给GPU的。
- 什么是纹理?可以把它想象成一个从主存储器(例如 RAM)移动到图像存储器(例如 GPU 中的 VRAM)的位图图像(bitmapimage)
- Chrome 使用纹理来从 GPU上获得大块的页面内容。通过将纹理应用到一个非常简单的矩形网格就能很容易匹配不同的位置(position)和变形(transformation)。这也就是3DCSS 的工作原理,它对于快速滚动也十分有效。
什么是渲染层合成
在 DOM 树中每个节点都会对应一个渲染对象(RenderObject),当它们的渲染对象处于相同的坐标空间(z 轴空间)时,就会形成一个 RenderLayers,也就是渲染层。渲染层将保证页面元素以正确的顺序堆叠,这时候就会出现层合成(composite),从而正确处理透明元素和重叠元素的显示。
这个模型类似于 Photoshop 的图层模型,在 Photoshop 中,每个设计元素都是一个独立的图层,多个图层以恰当的顺序在 z 轴空间上叠加,最终构成一个完整的设计图。
对于有位置重叠的元素的页面,这个过程尤其重要,因为一旦图层的合并顺序出错,将会导致元素显示异常。
1、渲染对象(RenderObject)
一个 DOM 节点对应了一个渲染对象,渲染对象依然维持着 DOM 树的树形结构。一个渲染对象知道如何绘制一个 DOM 节点的内容,它通过向一个绘图上下文(GraphicsContext)发出必要的绘制调用来绘制 DOM 节点。
2、渲染层(RenderLayer)
这是浏览器渲染期间构建的第一个层模型,处于相同坐标空间(z轴空间)的渲染对象,都将归并到同一个渲染层中,因此根据层叠上下文,不同坐标空间的的渲染对象将形成多个渲染层,以体现它们的层叠关系。所以,对于满足形成层叠上下文条件的渲染对象,浏览器会自动为其创建新的渲染层。能够导致浏览器为其创建新的渲染层的,包括以下几类常见的情况:
- 根元素 document
- 有明确的定位属性(relative、fixed、sticky、absolute)
- opacity < 1
- 有 CSS fliter 属性
- 有 CSS mask 属性
- 有 CSS mix-blend-mode 属性且值不为 normal
- 有 CSS transform 属性且值不为 none
- backface-visibility 属性为 hidden
- 有 CSS reflection 属性
- 有 CSS column-count 属性且值不为 auto或者有 CSS column-width 属性且值不为 auto
- 当前有对于 opacity、transform、fliter、backdrop-filter 应用动画
- overflow 不为 visible
DOM 节点和渲染对象是一一对应的,满足以上条件的渲染对象就能拥有独立的渲染层。当然这里的独立是不完全准确的,并不代表着它们完全独享了渲染层,由于不满足上述条件的渲染对象将会与其第一个拥有渲染层的父元素共用同一个渲染层,因此实际上,这些渲染对象会与它的部分子元素共用这个渲染层。
3、图形层(GraphicsLayer)
GraphicsLayer 其实是一个负责生成最终准备呈现的内容图形的层模型,它拥有一个图形上下文(GraphicsContext),GraphicsContext 会负责输出该层的位图。存储在共享内存中的位图将作为纹理上传到 GPU,最后由 GPU 将多个位图进行合成,然后绘制到屏幕上,此时,我们的页面也就展现到了屏幕上。
所以 GraphicsLayer 是一个重要的渲染载体和工具,但它并不直接处理渲染层,而是处理合成层。
4、合成层(CompositingLayer)
满足某些特殊条件的渲染层,会被浏览器自动提升为合成层。合成层拥有单独的 GraphicsLayer,而其他不是合成层的渲染层,则和其第一个拥有 GraphicsLayer 的父层共用一个。
那么一个渲染层满足哪些特殊条件时,才能被提升为合成层呢?这里列举了一些常见的情况:
- 3D transforms:translate3d、translateZ 等
- video、canvas、iframe 等元素
- 通过 Element.animate() 实现的 opacity 动画转换
- 通过 СSS 动画实现的 opacity 动画转换
- position: fixed
- 具有 will-change 属性
- 对 opacity、transform、fliter、backdropfilter 应用了 animation 或者 transition
这里值得注意的是,不少人会将这些合成层的条件和渲染层产生的条件混淆,这两种条件发生在两个不同的层处理环节,是完全不一样的。
另外,有些文章会把 CSS Filter 也列为影响 Composite 的因素之一,然而我验证后发现并没有效果。
隐式合成
上边提到,满足某些显性的特殊条件时,渲染层会被浏览器提升为合成层。除此之外,在浏览器的 Composite 阶段,还存在一种隐式合成,部分渲染层在一些特定场景下,会被默认提升为合成层。
这是一种什么样的情况呢,通俗意义上来说,就是z-index比较低的节点会提升为一个单独的图层,那么层叠等级比它高的节点都会成为一个独立的图层。
这句话可能不好理解,它其实是在描述一个交叠问题(overlap)。举个例子说明一下:
- 两个 absolute 定位的 div 在屏幕上交叠了,根据
z-index的关系,其中一个 div 就会”盖在“了另外一个上边。

- 这个时候,如果处于下方的 div 被加上了 CSS 属性:
transform: translateZ(0),就会被浏览器提升为合成层。提升后的合成层位于 Document 上方,假如没有隐式合成,原本应该处于上方的 div 就依然还是跟 Document 共用一个 GraphicsLayer,层级反而降了,就出现了元素交叠关系错乱的问题。

- 所以为了纠正错误的交叠顺序,浏览器必须让原本应该”盖在“它上边的渲染层也同时提升为合成层。

缺点: 一个大型的项目中,一个z-index比较低的节点被提升为单独图层后,层叠在它上面的元素统统都会提升为单独的图层,我们知道,上千个图层,会增大内存的压力,有时候会让页面崩溃。这就是文章开头我们提到的合成层爆炸
层爆炸和层压缩
1、层爆炸
从上边的研究中我们可以发现,一些产生合成层的原因太过于隐蔽了,尤其是隐式合成。在平时的开发过程中,我们很少会去关注层合成的问题,很容易就产生一些不在预期范围内的合成层,当这些不符合预期的合成层达到一定量级时,就会变成层爆炸。
层爆炸会占用 GPU 和大量的内存资源,严重损耗页面性能,因此盲目地使用 GPU 加速,结果有可能会是适得其反。CSS3硬件加速也有坑 这篇文章提供了一个很有趣的 DEMO,这个 DEMO 页面中包含了一个 h1 标题,它对 transform 应用了 animation 动画,进而导致被放到了合成层中渲染。由于 animation transform 的特殊性(动态交叠不确定),隐式合成在不需要交叠的情况下也能发生,就导致了页面中所有 z-index 高于它的节点所对应的渲染层全部提升为合成层,最终让这个页面整整产生了几千个合成层。
消除隐式合成就是要消除元素交叠,拿这个 DEMO 来说,我们只需要给 h1 标题的 z-index 属性设置一个较高的数值,就能让它高于页面中其他元素,自然也就没有合成层提升的必要了。点击 DEMO 中的复选按钮就可以给 h1 标题加上一个较大的 z-index,前后效果对比十分明显。
2、层压缩
当然了,面对这种问题,浏览器也有相应的应对策略,如果多个渲染层同一个合成层重叠时,这些渲染层会被压缩到一个 GraphicsLayer 中,以防止由于重叠原因导致可能出现的“层爆炸”。这句话不好理解,具体可以看看这个例子:
还是之前的模型,只不过这次不同的是,有四个 absolute 定位的 div 在屏幕内发生了交叠。此时处于最下方的 div 在加上了 CSS 属性 transform: translateZ(0) 后被浏览器提升为合成层,如果按照隐式合成的原理,盖在它上边的 div 会提升为一个新的合成层,第三个 div 又盖在了第二个上,自然也会被提升为合成层,第四个也同理。这样一来,岂不是就会产生四个合成层了?

- 然而事实并不是这样的,浏览器的层压缩机制,会将隐式合成的多个渲染层压缩到同一个 GraphicsLayer 中进行渲染,也就是说,上方的三个 div 最终会处于同一个合成层中,这就是浏览器的层压缩。

当然了,浏览器的自动层压缩并不是万能的,有很多特定情况下,浏览器是无法进行层压缩的,无线性能优化:Composite 这篇文章列举了许多详细的场景。
层合成是一个相对复杂的浏览器特性,为什么我们需要关注这么底层又难理解的东西呢?那是因为渲染层提升为合成层之后,会给我们带来不少好处:
- 合成层的位图,会交由 GPU 合成,比 CPU 处理要快得多;
- 当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层;
- 元素提升为合成层后,transform 和 opacity 才不会触发 repaint,如果不是合成层,则其依然会触发 repaint。
当然了,利弊是相对和共存的,层合成也存在一些缺点,这很多时候也成为了我们网页性能问题的根源所在:
- 绘制的图层必须传输到 GPU,这些层的数量和大小达到一定量级后,可能会导致传输非常慢,进而导致一些低端和中端设备上出现闪烁;
- 隐式合成容易产生过量的合成层,每个合成层都占用额外的内存,而内存是移动设备上的宝贵资源,过多使用内存可能会导致浏览器崩溃,让性能优化适得其反。
图层绘制
完成了图层的构建,接下来要做的工作就是图层的绘制了。图层的绘制跟我们日常的绘制一样,每次都会把一个复杂的图层拆分为很小的绘制指令,然后再按照这些指令的顺序组成一个绘制列表,类似于下图。

从图中可以看出,绘制列表中的指令其实非常简单,就是让其执行一个简单的绘制操作,比如绘制粉色矩形或者黑色的线等。而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。
绘制列表准备好了之后,渲染进程的主线程会给合成线程发送commit消息,把绘制列表提交给合成线程。接下来就是合成线程一展宏图的时候啦。
很多情况下,图层可能很大,比如一篇长文章,需要滚动很久才能到底,但是用户只能看到视口的内容,所以没必要把整个图层都绘制出来。因此,合成线程会将图层划分为图块,这些图块的大小通常是 256x256 或者 512x512。合成线程会优先将视口附近的图块生成位图。实际生成位图的操作是在光栅化阶段来执行的,所谓的光栅化就是按照绘制列表中的指令将图块转换为位图。
- 图块是栅格化执行的最小单位
- 渲染进程中专门维护了一个栅格化线程池,专门负责把图块转换为位图数据
- 合成线程会选择视口附近的图块(tile),把它交给栅格化线程池生成位图
- 生成位图的过程实际上都会使用 GPU 进行加速,生成的位图最后发送给
合成线程
当所有的图块都被光栅化之后,合成线程就会生成一个绘制图块的命令,浏览器相关进程收到这个指令之后,就会将其页面内容绘制在内存中,最后将内存显示在屏幕上,这样就完成了页面的绘制。
合成显示
栅格化操作完成后,合成线程会生成一个绘制命令,即”DrawQuad”,并发送给浏览器进程。
浏览器进程中的viz组件接收到这个命令,根据这个命令,把页面内容绘制到内存,也就是生成了页面,然后把这部分内存发送给显卡,那你肯定对显卡的原理很好奇。
1 | 无论是 PC 显示器还是手机屏幕,都有一个固定的刷新频率,一般是 60 HZ,即 60 帧,也就是一秒更新 60 张图片,一张图片停留的时间约为 16.7 ms。而每次更新的图片都来自显卡的前缓冲区。而显卡接收到浏览器进程传来的页面后,会合成相应的图像,并将图像保存到后缓冲区,然后系统自动将前缓冲区和后缓冲区对换位置,如此循环更新 |
这个时候,心中就有点概念了,比如某个动画大量占用内存时,浏览器生成图像的时候会变慢,图像传送给显卡就会不及时,而显示器还是以不变的频率刷新,因此会出现卡顿,也就是明显的掉帧现象。
一张图总结全部流程

回流、重绘、合成
在CSS属性改变时,重渲染会分为“回流”、“重绘”和“直接合成”三种情况,分别对应从“布局定位”/“图层绘制”/“合成显示”开始,再走一遍上面的流程。
1、重绘:修改了一些不影响布局的属性,比如颜色;

跳过了布局树和建图层树,直接去绘制列表,然后在去分块,生成位图等一系列操作。
可以看到,重绘不一定导致回流,但回流一定发生了重绘。
2、回流(又叫重排):元素位置、大小发生变化导致其他节点联动,需要重新计算布局;;

具体一点,有以下的操作会触发回流:
- 一个 DOM 元素的几何属性变化,常见的几何属性有
width、height、padding、margin、left、top、border等等, 这个很好理解。 - 使 DOM 节点发生
增减或者移动。 - 读写
offset族、scroll族和client族属性的时候,浏览器为了获取这些值,需要进行回流操作。 - 调用
window.getComputedStyle方法。
一些常用且会导致回流的属性和方法:
clientWidth、clientHeight、clientTop、clientLeftoffsetWidth、offsetHeight、offsetTop、offsetLeftscrollWidth、scrollHeight、scrollTop、scrollLeftscrollIntoView()、scrollIntoViewIfNeeded()getComputedStyle()getBoundingClientRect()scrollTo()
依照上面的渲染流水线,触发回流的时候,如果 DOM 结构发生改变,则重新渲染 DOM 树,然后将后面的流程(包括主线程之外的任务)全部走一遍。
3、直接合成:合成层的transform、opacity修改,只需要将多个图层再次合并,而后生成位图,最终展示到屏幕上;;

还有一种情况:就是更改了一个既不要布局也不要绘制的属性,那么渲染引擎会跳过布局和绘制,直接执行后续的合成操作,这个过程就叫合成。
举个例子:比如使用CSS的transform来实现动画效果,避免了回流跟重绘,直接在非主线程中执行合成动画操作。显然这样子的效率更高,毕竟这个是在非主线程上合成的,没有占用主线程资源,另外也避开了布局和绘制两个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率。
利用这一点好处:
- 合成层的位图,会交由 GPU 合成,比 CPU 处理要快
- 当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层
- 对于 transform 和 opacity 效果,不会触发 layout 和 paint
提升合成层的最好方式是使用 CSS 的 will-change 属性





