阅读 155

浏览器渲染原理

前言

之前看完兵哥的浏览器工作原理与实践之后,发现有很多知识点晦涩难懂,后续通过翻阅相关博客和请教大佬才明白。所以在此整理记录,方便后续查阅复习。也欢迎指错勘误!!!

开始

浏览器渲染原理主要包含如下几个阶段

  • 构建DOM树

  • 样式计算

  • 布局阶段

  • 分层

  • 绘制

  • 分块

  • 栅格化

  • 合成

  • 关于回流、重绘、合成


你可以想象一下,从0,1字节流到最后页面展现在你面前,这里面渲染机制肯定很复杂,所以渲染模块把执行过程中化为很多的子阶段,渲染引擎从网络进程拿到字节流数据后,经过这些子阶段的处理,最后输出像素,这个过程可以称为渲染流水线

构建DOM树

这个过程主要工作就是 将HTML内容转换为浏览器DOM树结构

什么是 DOM

从网络传给渲染引擎的 HTML 文件字节流是无法直接被渲染引擎理解的,所以要将其转化为渲染引擎能够理解的内部结构,这个结构就是 DOM

DOM树和HTML内容几乎是一样的,但是和 HTML 不同的是,DOM 是保存在内存中树状结构,可以通过 JavaScript 来查询或修改其内容,下图是HTML和DOM树的区别

简言之,DOM 是表述 HTML 的内部数据结构,它会将 Web 页面和 JavaScript 脚本连接起来,并过滤一些不安全的内容

DOM树是怎么生成的

在渲染引擎内部,有一个 HTML 解析器(HTMLParser),它的职责就是负责将 HTML 字节流转换为 DOM 结构

前面我们说过代码从网络传输过来是字节流的形式,那么后续字节流是如何转换为 DOM 的呢?参考下图

从图中可以看出,字节流转换为 DOM 需要三个阶段。

转换Token

第一个阶段,通过分词器将字节流转换为 Token,分为 Tag Token 和文本 Token。

上述 HTML 代码通过词法分析生成的 Token 如下所示:

由图可以看出,Tag Token 又分 StartTag 和 EndTag,比如就是 StartTag ,就是EndTag,分别对于图中的蓝色和红色块,文本 Token 对应的绿色块。

解析和添加

至于后续的第二个和第三个阶段是同步进行的,将 Token 解析为 DOM 节点并将 DOM 节点添加到 DOM 树中

HTML 解析器维护了一个 Token 栈结构,该 Token 栈主要用来计算节点之间的父子关系,在第一个阶段中生成的 Token 会被按照顺序压到这个栈中。具体的处理规则如下所示:

  • HTML 解析器开始工作时,会默认创建了一个根为 document 的空 DOM 结构,同时会将一个 StartTag document 的 Token 压入栈底

  • 如果压入到栈中的是 StartTag Token,HTML 解析器会为该 Token 创建一个 DOM 节点,然后将该节点加入到 DOM 树中,它的父节点就是栈中相邻的那个元素生成的节点。

  • 如果分词器解析出来是文本 Token,那么会生成一个文本节点,然后将该节点加入到 DOM 树中,文本 Token 是不需要压入到栈中,它的父节点就是当前栈顶 Token 所对应的 DOM 节点。

  • 如果分词器解析出来的是 EndTag Token,比如是 EndTag div,HTML 解析器会查看 Token 栈顶的元素是否是 StarTag div,如果是,就将 StartTag div 从栈中弹出,表示该 div 元素解析完成。

通过分词器产生的新 Token 就这样不停地压栈和出栈,整个解析过程就这样一直持续下去,直到分词器将所有字节流分词完成。

以下面HTML为例,看下整个过程

<html>   <body>     <div>1</div>     <div>test</div>   </body> </html> 复制代码

HTML 解析器开始工作时,会默认创建了一个根为 document 的空 DOM 结构,同时会将一个 StartTag document 的 Token 压入栈底。然后经过分词器解析出来的第一个 StartTag html Token 会被压入到栈中,并创建一个 html 的 DOM 节点,添加到 document 上,如下图所示:

然后按照同样的流程解析出来 StartTag body 和 StartTag div,其 Token 栈和 DOM 的状态如下图所示:

接下来解析出来的是第一个 div 的文本 Token,渲染引擎会为该 Token 创建一个文本节点,并将该 Token 添加到 DOM 中,它的父节点就是当前 Token 栈顶元素对应的节点,如下图所示:

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

按照同样的规则,一路解析,最终结果如下图所示:

样式计算

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

  • 格式化样式表

  • 标准化样式表

  • 计算每个DOM节点具体样式

格式化样式表

对于CSS,浏览器拿到的也就是0,1字节流数据,浏览器无法直接去识别的,所以渲染引擎收到CSS文本数据后,会执行一个操作,转换为浏览器可以理解的结构 styleSheets(CSSOM)

标准化样式表

body { font-size: 2em } p {color:blue;} span {display: none} div {font-weight: bold} 复制代码

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

body { font-size: 36px; } p {color: rgb(0, 0, 255);} span {display: none;} div {font-weight: 700;} 复制代码

计算每个DOM节点具体样式

主要分为两点

  • 继承规则:每个子节点会默认去继承父节点的样式,如果父节点中找不到,就会采用浏览器默认的样式,也叫UserAgent样式

  • 层叠规则:层叠是 CSS 的一个基本特征,比如:.box p {}

在计算完样式之后,所有的样式值会被挂在到window.getComputedStyle当中,也就是可以 通过JS来获取计算后的样式

样式计算的整个过程就是完成了DOM节点中每个元素的具体样式,计算过程中要遵循CSS的继承和层叠两条规则,最终输出的内容是每个节点DOM的样式,被保存在ComputedStyle

生成布局树

现在,我们有 DOM 树和 DOM 树中元素的样式,但这还不足以显示页面,因为我们还不知道 DOM 元素的几何位置信息。那么接下来就需要计算出 DOM 树中可见元素的几何位置,也就是生成一颗__布局树(Layout Tree)__,之前说法叫 渲染树(Render Tree)

Chrome 在布局阶段需要完成两个任务:创建 Layout Tree 和 布局计算

创建布局树(Layout Tree)

DOM 树还含有很多不可见的元素,比如 head 标签,还有使用了 display:none 属性的元素。所以在显示之前,我们还要 额外地构建一棵只包含可见元素的布局树Layout Tree

结合下图来看看Layout Tree的构造过程:

从上图可以看出,DOM 树中所有不可见的节点都没有包含到布局树中

为了构建布局树,浏览器大体上完成了下面这些工作:

  • 遍历 DOM 树中的所有可见节点,并把这些节点加到布局树中

  • 而不可见的节点会被布局树忽略掉,如 head 标签下面的全部内容,再比如 body.p.span 这个元素,因为它的属性包含 dispaly:none,所以这个元素也没有被包进布局树

布局计算

浏览器根据 Layout Tree 所体现的节点、各个节点的 CSS 定义以及它们的从属关系,计算出每个节点在屏幕中的位置

Web 页面中元素的布局是相对的,在页面元素位置、大小发生变化,往往会导致其他节点联动,需要重新计算布局,这时候的布局过程一般被称为 回流(Reflow)

分层(Layer)

浏览器在构建完布局树后,还需要进行一系列操作,这样子可能考虑到一些复杂的场景,如一些复杂的 3D 变换、页面滚动,或者使用 z-index 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的 图层树(Layer Tree)

最终看到的页面,就是由这些图层一起叠加构成的,它们按照一定的顺序叠加在一起,就形成了最终的页面。也就是说 浏览器的页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面

看看图层树与布局树之间关系

上图中的每一层,叫做 渲染层(PaintLayers)

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

那么需要满足什么条件,渲染引擎才会为特定的节点创建新的图层呢?

渲染层

这是浏览器渲染期间构建的第一个层模型,处于相同坐标空间(z轴空间)的渲染对象,都将归并到同一个渲染层中,因此根据层叠上下文,不同坐标空间的的渲染对象将形成多个渲染层,以体现它们的层叠关系

浏览器自动创建新的渲染层的条件有两个,一个是 满足层叠上下文,第二个是需要剪裁(clip)的地方

满足层叠上下文

  • 根元素 document

  • 有明确的定位属性(relative、fixed、sticky、absolute)

  • opacity < 1

  • 当前有对于 opacity、transform、fliter、backdrop-filter 应用动画

  • overflow 不为 visible

  • 有 CSS transform 属性且值不为 none

  • 有 CSS fliter 属性

  • 有 CSS mask 属性

  • 有 CSS mix-blend-mode 属性且值不为 normal

  • backface-visibility 属性为 hidden

  • 有 CSS reflection 属性

  • 有 CSS column-count 属性且值不为 auto或者有 CSS column-width 属性且值不为 auto

需要裁剪的地方

比如一个div标签很小,50*50像素,你在里面放了非常多的文字,那么超出的文字部分就需要被剪裁。当然如果出现了滚动条,那么滚动条也会被单独提升为一个图层,也就是说这里会有三个图层,分别是div、文字、滚动条。如下图

绘制图层绘制(Paint)

在完成渲染层的构建之后,渲染引擎会对每个渲染层进行绘制,本质上是一个像素填充的过程。这个过程也出现于回流或一些不影响布局的 CSS 修改引起的屏幕局部重画,这时候它被称为 重绘(Repaint)

渲染引擎会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表

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

到此 Paint 阶段执行完毕,开始 Compsite  阶段

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

如上图所示,当图层的绘制列表准备好之后,主线程会把该绘制列表提交给 合成线程

分块(tile)

通常一个页面可能很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个部分叫做视口(viewport)

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

基于这个原因,合成线程会将图层划分为图块(tile),这些图块的大小通常是 256x256 或者 512x512,然后合成线程会 按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的

栅格化(raster)

所谓栅格化,是指将图块转换为位图。而图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的

当满足某些特殊条件的渲染层会被提升为合成层,而合成层的栅格化操作是在GPU中进行的。使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中

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

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

合成层(CompositingLayer)

满足某些特殊条件的渲染层,会被浏览器自动提升为合成层。合成层拥有单独的 GraphicsLayer,而其他不是合成层的渲染层,则和其第一个拥有 GraphicsLayer 的父层共用一个。需要注意的点是 document 是渲染层,不是合成层

那么一个渲染层满足哪些特殊条件时,才能被提升为合成层呢?这里列举了一些常见的情况:

  • 3D transforms:translate3d、translateZ 等

  • video、canvas、iframe 等元素

  • 通过 Element.animate() 实现的 opacity 动画转换

  • 通过 СSS 动画实现的 opacity 动画转换

  • position: fixed

  • 具有 will-change 属性

  • 对 opacity、transform、fliter、backdropfilter 应用了 animation 或者 transition(需要是 正在执行 的 animation 或者 transition,当 animation 或者 transition 效果未开始或结束后,提升合成层也会失效)

  • will-change 设置为 opacity、transform、top、left、bottom、right(其中 top、left 等需要设置明确的定位属性,如 relative 等)

合成层优缺点

优点

  • 合成层的位图,会交由 GPU 合成,比 CPU 处理要快得多

  • 当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层

  • 元素提升为合成层后,transform 和 opacity 才不会触发 repaint,如果不是合成层,则其依然会触发 repaint

缺点

  • 绘制的图层必须传输到 GPU,这些层的数量和大小达到一定量级后,可能会导致传输非常慢,进而导致一些低端和中端设备上出现闪烁

  • 隐式合成容易产生过量的合成层,每个合成层都占用额外的内存,而内存是移动设备上的宝贵资源,过多使用内存可能会导致浏览器崩溃,让性能优化适得其反。这个也叫做层爆炸(后面题目中会介绍)

图形层(GraphicsLayer)

GraphicsLayer 其实是一个负责生成最终准备呈现的内容图形的层模型,它拥有一个图形上下文(GraphicsContext),GraphicsContext 会负责输出该层的位图。存储在共享内存中的位图将作为纹理上传到 GPU,最后由 GPU 将多个位图进行合成,然后绘制到屏幕上,此时,我们的页面也就展现到了屏幕上,也就是说不会触发重绘和回流

所以 GraphicsLayer 是一个重要的渲染载体和工具,但它 并不直接处理渲染层,而是处理合成层

合成和显示

渲染进程的合成线程接收到图层的绘制消息时,会通过光栅化线程池将其提交给GPU进程,在GPU进程中执行光栅化操作,一旦所有图块都被光栅化,会将结果返回给渲染进程的合成线程,执行合成图层操作,图层合成完成后就会生成一个绘制的命令——“DrawQuad”,然后将该命令提交给浏览器进程

浏览器进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,浏览器进程里会执行显示合成(Display Compositor),也就是将 __所有的图层__合成为页面内容。并将其绘制到内存中,最后把这部分内存发送给显卡。到这里,经过这一系列的阶段,编写好的 HTML、CSS、JavaScript 等文件,经过浏览器就会显示出漂亮的页面了

显示器显示图像的原理解释:

当通过渲染流水线通过显卡生成一张图片之后,会将图片存储到显卡的后缓冲区,一旦显卡把合成的图像写到后缓冲区,系统就会让后缓冲区和前缓冲区互换;此时显示器会从前缓冲区中获取最新图片。一般情况下显示器的刷新频率是 60HZ,也就是每秒更新 60 张图片,也就是说渲染流水线需要在16.66667ms内就要生成一张图片。如果生成图片过久就会给用户造成视觉上的卡顿。

总结

一个完整的渲染流程大致可总结为如下:

  • 渲染进程将 HTML 字节流转换为 DOM 树

  • 渲染引擎将 CSS 样式表转化成styleSheets并计算出 DOM 节点的样式

  • 创建布局树(Layout Tree),并计算元素的布局信息

  • 对Layout Tree进行分层,并生成图层树(Layer Tree)

  • 为每个渲染层生成绘制列表,并将其提交到合成线程

  • 合成线程将渲染层分成图块,并在光栅化线程池中通过 GPU 加速将图块转换成位图

  • 合成线程发送绘制图块命令 DrawQuad 给浏览器进程

  • 浏览器进程根据 DrawQuad 消息生成页面,并显示到显示器上

关于回流、重绘、合成

回流

另外一个叫法是重排

回流触发的条件就是:当Layout Tree中部分或全部元素的尺寸、结构、或某些属性发生改变时,浏览器重新渲染部分或全部文档的过程称为回流。

以下的操作会触发回流:

  • 一个 DOM 元素的几何属性变化,常见的几何属性有width、height、padding、margin、left、top、border 等等

  • 使 DOM 节点发生增减或者移动

  • 读写 offset族、scroll族和client族属性的时候,浏览器为了获取这些值,需要进行回流操作

  • 调用 window.getComputedStyle 方法

一些常用且会导致回流的属性和方法:

  • clientWidth、clientHeight、clientTop、clientLeft

  • offsetWidth、offsetHeight、offsetTop、offsetLeft

  • scrollWidth、scrollHeight、scrollTop、scrollLeft

  • scrollIntoView()、scrollIntoViewIfNeeded()

  • getComputedStyle()

  • getBoundingClientRect()

  • scrollTo()

依照上面的渲染流水线,触发回流的时候,如果 DOM 结构发生改变,则重新渲染 DOM 树,然后将后面的流程(包括主线程之外的任务)全部走一遍。

重绘

当页面中元素样式的改变并不影响它在文档流中的位置时(例如:color、background-color、visibility等),浏览器会将新样式赋予给元素并重新绘制它,这个过程称为重绘。

根据概念,我们知道由于没有导致 DOM 几何属性的变化,因此元素的位置信息不需要更新,从而省去布局的过程,流程如下:

从图中可以看出,如果修改了元素的背景颜色,那么布局阶段将不会被执行,因为并没有引起几何位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段,这个过程就叫 重绘

相较于重排操作,重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些

合成

还有一种情况:就是更改了一个既不要布局也不要绘制的属性,那么渲染引擎会跳过布局和绘制,直接执行后续的合成操作,这个过程就叫合成

  • 不会改变图层的内容;如文字信息的改变,布局的改变,颜色的改变都会改变图层,就会牵扯到重排或者重绘

  • 合成线程中实现的是整个图层的几何变换,透明度变换,阴影等;比如滚动页面的时候,整个页面内容没有变化,这时候做的其实是对图层做上下移动,这种操作直接在合成线程里面就可以完成了

通常渲染路径越长,生成图像花费的时间就越多。这也解释了,为什么合成要优于重绘和重排,而重绘要优于重排。

举个例子:比如使用CSS的transform来实现动画效果,避免了回流跟重绘,直接在非主线程中执行合成动画操作。显然这样子的效率更高,毕竟这个是在非主线程上合成的,没有占用主线程资源,另外也避开了布局和绘制两个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率。

最后

到现在为止,已经将浏览器的渲染原理说完了,也介绍了回流、重绘、合成。其实里面还有很多有趣的知识点,比如层爆炸层压缩CSS 和 JS 会不会阻塞渲染等;碍于篇幅就不放在这篇文章了。


作者:zygg不含糖
链接:https://juejin.cn/post/7025635250344558628


文章分类
后端
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐