探寻浏览器渲染的秘密

Author Avatar
我爱吃包子 9月 04, 2019

探寻浏览器渲染的秘密

起因是这样,有运营小姐姐跟我反馈某个页面卡顿的厉害。心中突然一想,妈耶不会有bug吧,心慌慌的。然后自己打开页面,不卡呀,流畅的一xx,肯定是她弄错了。带着去教她如何正确的使用电脑的想法我自信的下了楼,然后自信的在她电脑上打开了页面,我滑,我滑,我再滑。woc,页面咋不动啊,woc,电脑都卡死了。???什么情况,然后有其他运营反馈 air 上并不卡顿。页面下滑为何卡顿?在mbp和mba上的表现为何不同?这一切的问题究竟是从何而起?请老板们带着这两个问题往下看,我将一步一步揭开浏览器渲染的面纱。

先上张图让大家感受一下被支配的恐惧。

mbp上

知识储备

要搞懂我下面说的,首先你需要先知道现代浏览器的架构以及显卡、GPU 和屏幕分辨率的关系。当然了,就算这些不了解,也是可以接着往下看的,我会简单的讲一下,嘻嘻嘻。

现代浏览器的架构

因为这里并没有什么规范,各大浏览器厂商的各自的架构设计也并不相同(不过都是大同小异),我就以 chrome 浏览器为例说一下 chrome 的设计。

chrome 浏览器从最初的单进程发展到现在的多进程架构。我们可以从上面我发的图看到浏览器包括: 一个浏览器进程、一个 GPU 进程、一个网络进程、多个渲染进程和多个插件进程。

渲染进程

了解了上面的浏览器的架构,下面我们说说今天的主角渲染进程,关于浏览器多进程之间是如何配合最后在屏幕上展示内容的,这个后面会写文章记录。现在我们说说渲染进程的事儿。

渲染流水线

按照渲染的时间顺序可以分成以下几个子阶段:构建 DOM 树、样式计算、布局、分层、绘制、分块、光栅化、合成。东西有点多,为了快速记忆和理解,需要重点关注每个子阶段的输入和输出以及做了哪些处理。

还需要了解渲染进程中的几个线程。包括主线程(main thread)、工作线程(work thread)、合成线程(compositor thread)以及光栅化线程(raster thread)。后面会总结这些线程的具体功能,我们先看一下整体的渲染流程。

构建 DOM 树

构建 DOM 树

DOM 树是什么相信大家都知道,我就不多 BB 了。因为浏览器无法直接理解和使用 html 文件,所以需要将 html 文件转为浏览器能够理解的结构 DOM 树。

网页中常常包含图片、css、js 等资源文件,这些资源浏览器会去各种渠道获取(缓存、网络下载等)。在构建 DOM 树的时候主线程会去请求他们,相关资源会通过进程之间的通信(IPC)通知网络进程去下载这些资源。在遇到 <script> 标签的时候,解析 DOM 树的工作会暂停,等 js 代码执行完毕之后在去重新解析 DOM 树。

总结一下构建 DOM 树子阶段的输入、输出以及操作过程:

  • 输入:html 文件
  • 输出:DOM 树
  • 操作过程:解析 html 结构为浏览器可以理解的 DOM 树结构,期间会去下载次级资源以及执行 js 代码。
样式计算

样式计算是为了获取每个节点的样式,其主要分为三步来完成。

样式计算

首先和解析 DOM 树一样,浏览器是无法理解 css 代码的,需要将 css 文件转成浏览器可以理解的数据结构 styleSheets。具体 styleSheets 是什么样的结构这里我们就不去重点了解了,只需要了解到主进程会将 css 代码转成浏览器可以理解的结构,这个结构支持查询和修改。可以在开发者工具上通过 document.styleSheets 打印出来。

为了适配多端样式,我们可能使用的是 rem、vh 等 css 代码。这些属性值不容被渲染引擎理解,所以需要将这些不是标准化的样式转为标准样式。比如 rem 转成 px、bule 转成 rgba 等。

我们获取到标准化后的样式表,最后就是计算每个节点的样式了。这一步骤涉及到 css 的继承规则和层叠规则。有些属性是可以被子元素继承的,有些属性是会覆盖前面的样式。这一块也不多做讨论了。

总结一下样式计算子阶段的输入、输出和操作过程:

  • 输入:css 样式文件
  • 输出:对应每个 DOM 的样式
  • 操作过程:进行了三个操作,包括:转成浏览器可以理解的 styleSheets、将 css 转成标准化的样式、最后是计算每个节点的样式。
布局阶段

想要渲染一个完整的页面,仅知道 DOM 树和 DOM 树元素的样式还是不够的,我们还需要知道 DOM 树中元素的位置。

布局阶段

同样的布局这个子阶段也分为两个过程操作,分别是合成布局树和计算节点位置。

布局树和 DOM 树类似,不过布局树上只包含会显示的节点内容,不包含如 等元素。也不包含 display: none 样式的元素。只包含可见节点。有了一颗完成的布局树,主线程会计算出每个元素的位置信息以及盒子大小。

总结一下布局阶段子阶段的输入、输出和操作过程:

  • 输入:css 样式表、DOM 树
  • 输出:布局树
  • 操作过程:合成布局树、计算节点位置
分层

有了布局树,计算出了每个节点的位置。那么下面是不是进行绘制了呢?答案是否定的,因为页面有很多复杂的效果,比如滑动、z-idnex 等。为了更好的实现这些效果,渲染引擎主线程还需要为特定的阶段生成专用的图层,并生成一颗对应的图层树

分层

分层这一步其实没什么好解释了,唯一需要了解的是哪些元素会被单独分层。布局树和图层树并不是一一对应的关系,不是每个布局树的节点都会生成一个单独的图层树节点。如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。那么哪些操作会让节点生成一个单独的图层呢?接着往下面看。

1)拥有层叠上下文属性的元素会单独生成一个图层。

浏览器是一个二维的概念,但是层叠上下文可以让元素具有三维的概念。比如 css 属性中的 z-index、position、css 滤镜等。

  • 3D 或透视变换的 css 属性
  • 使用加速视频解码的 video 元素
  • canvas 元素
  • opacity 属性

2)需要裁剪的地方也会单独生成一个图层

裁剪就是需要滚动的地方,里面内容会单独生成一个图层。如果有滚动条,滚动条也会单独生成一个图层。(所以想一想我那个性能很差的页面有多少个图层?手动狗头)

总结一下布局阶段子阶段的输入、输出和操作过程:

  • 输入:布局树
  • 输出:图层树
  • 操作过程:为特定的节点生成单独的图层、并将这些图层合成图层树
图层绘制

在完成图层树的构建之后,渲染引擎主线程会对每个图层进行绘制。这里说的绘制不是真正的绘制画面,而是生成一个绘制指令列表。

图层绘制

如果我们要在白纸上绘制一些东西,比如黄底、白圆、黑字的一个图案。通常我们会把操作分解成几步来完成:

  • 我们会先在白纸上涂上黄色的底。
  • 然后我们会在黄底上画一个白色的圆。
  • 最后我们会在白色圆上画出黑色的字。

渲染引擎的图层绘制和这个类似,会把每一个图层的绘制拆分成很多的绘制指令

总结一下布局阶段子阶段的输入、输出和操作过程:

  • 输入:图层树
  • 输出:每个图层的绘制指令
  • 操作过程:将每个图层的绘制拆分成多个绘制指令,传给合成线程
栅格化

绘制列表只是用来生成记录绘制指令的列表,实际的绘制操作是有渲染进程的合成线程来执行的。

栅格化

绘制指令生成之后,渲染进程主线程会将绘制指令发送给合成线程,由合成线程来完成最后的绘制工作。合成线程会将图层划分为图块。简单解释下图块是什么,浏览器的视口内容是有限的,有些图层可能非常大。渲染进程不会把该图层的所有内容都渲染出来,而是会将这些图层划分为一个一个小的图块。栅格化子进程会将视口区域内的图块转化为位图(磁贴),并将这位存入 GPU 显存中。GPU 操作是在 GPU 进程中,所以渲染进程会通过 IPC 通信协议来通知 GPU 进程来进行操作。

总结一下布局阶段子阶段的输入、输出和操作过程:

  • 输入:绘制指令列表、图层树。
  • 输出:位图
  • 操作过程:将图层划分为图块,将图块转换成位图。
合成和显示

等所有图块都被栅格化,合成线程会收集位图信息来创建合成帧。合成帧随后会通过 IPC 协议将消息传给浏览器主进程。浏览器主进程收到消息后,会将页面内容绘制到内存中,最后再将内存显示在屏幕上。

总结

到这里,我们整个浏览器的渲染进程也就讲完了。下面我们通过一张图来总结一下渲染过程中,浏览器各进程各线程是如何工作的。

总结

  • 主线程将 html 文件转化为浏览器能够读懂的 DOM 树结构。其中会通过网络进程加载次级资源,遇到 js 会停止构建 DOM 树,并执行 js。

  • 主线程将 css 文件转化为浏览器能够读懂的 styleSheets 结构,并将其中的属性标准化,最后计算每个节点的样式

  • 主线程通过得到的 DOM 树和 styleSheets 样式表合成一颗布局树并计算每个节点的具体位置

  • 主线程通过得到的布局树进行图层分层并得到一个图层树
  • 主线程通过分层树对每一个图层分解绘制指令,得到一个绘制指令列表
  • 合成线程对图层进行分块处理,并对视口区域内的图块进行位图转换,将得到的结果通过 GPU 进程存入到 GPU 显存中。
  • 合成线程收集位图信息创建合成帧,并将消息通过 IPC 协议传给浏览器主进程,主进程收到消息后,会将页面内容绘制到内存中,最后再将内存显示在屏幕上。

上面已经讲完了浏览器整个渲染流程,我们来讲讲产生这个例子中产生卡顿的原因。我们的页面中有一个 table 表格布局,表格中有 50 条数据,其中一列内容很长,我给表格 item 设置了固定高度,产生了 50 个滚动条和滚动区域。这样根据上面的理论,我们页面就多出了 100 个图层(layer)。通常情况下图层是有助于性能的,但是创建的每一层都需要内存和管理,而这些并不是免费的。事实上,在内存有限的设备上,对性能的影响可能远远超过创建层带来的任何好处。每一层的纹理都需要上传到 GPU,使 CPU 与 GPU 之间的带宽、GPU 上可用于纹理处理的内存都受到进一步限制。所以解决方法很简单,我们只需要消除每条数据的滑动就行,即去掉表格 item 固定高度,使其自由撑开,这样图层就会减少,减轻了 GPU 的负担,也就解决了卡顿问题。

屏幕分辨率、显卡等关系

讲完了渲染流程,也找到了页面卡顿的原因。但是我们还是不知道为何页面在 mbp 和 mba 上有差异。这就是接下来我们要将的内容了。

我们需要了解几个概念:屏幕尺寸、分辨率、屏幕像素密度

  • 屏幕尺寸,单位通常是英寸,其大小是显示器的对角线长度。
  • 分辨率也就是屏幕上由多少个像素组成,mbp 的屏幕分辨率是 2560 * 1600,也就是在横向的宽度上有 2560 个像素,竖向的高度上有 1600 个像素。
  • 屏幕像素密度(ppi ),每英寸屏幕有多少个像素。

mbp 的屏幕分辨率是 2560 1600,mba 的屏幕分辨率是 1440 900。这样算下来 mbp 有 4096000 个像素,mba 有1296000 个像素。显卡压力会小很多,内存占用也会更少。

总结一下啦啦啦啦

至此整个问题就全部解决、全部了解清楚了。其实刚开始就把这个问题解决了,但是其中很多东西一直都不怎么了解,趁着这次机会把整个过程都了解清楚。其实像我们这种做开发的人,就是要有一种死钻牛角的精神,不能把问题解决了就行了,更要了解其中的原理,为什么会这样。期间我也有想放弃不整了,还是在小伙伴的帮助下完成这次的探寻之旅。在毕业初期能够遇到一个和自己讲的来话的学长真的能给自己很大的帮助。

共勉。

最后放一张解决了问题后的图。

解决后

参考链接