从 B 站出发,用 Chrome devTools performance 分析页面如何渲染

版主 又拍云 2023-2-27 422

页面是如何渲染的?通常会得到“解析 HTML、css 合成 Render Tree,就可以渲染了”的回答。但是具体都做了些什么,却很少有人细说,我们今天就从 Chrome 的性能工具开始,具体看看一个页面是如何进行渲染的,以及进行页面优化时需要关注哪些指标。

以“老二次元”网站 bilibili 为例,我们将通过分析 performance 面板,串联起 Chrome 页面渲染流程,以及页面的部分量化指标的含义,来看页面具体是如何渲染的。


获取performance数据


首先,打开Chrome devTools, 选择 performace面板,点击录制按钮开始录制。

之后为了防止我们分析页面时出现无关的干扰,我们通过以下步骤降低干扰项:
  1. 打开 Chrome 无痕模式。
  2. 关闭所有在 Chrome 无痕模式下启用的拓展(如果有的话)。
  3. 在地址栏输入 www.bilibili.com 前,先打开 devTools,选择 performance 面板,点击录制按钮。
  4. 在已经录制的情况下,地址栏回车,请求 B 站,大概 10s 后,停止录制。


我们从上到下,将图分成以下几块,如下图所示:

  1. 控制面板
  2. 概览面板
  3. 网络面板
  4. Web Vitals
  5. 线程面板
  6. 内存面板
  7. 聚合面板

控制面板
控制面板有 4 部分内容,分别为:

  • disable javascript samples:启用后会隐藏一些 JS 调用栈的展示。在一些性能较弱的设备例如移动端上,可以开启这项功能。
  • Network:可以用来模拟各种网络状况。
  • enableadvanced paint instrumention (slow):启用后 paint 面板会显示与绘制相关事件的更详细的信息。
  • CPU:可以用来模拟不同的 CPU 性能。


概览面板

概览面板是各项指标的一个概览,包含了 FPS 帧数、CPU 占用、NET 情况、内存使用情况等。
简单举个例子,比如 FPS 帧数可以直观的看出 FPS 的高低,绿色代表低的部分。而 CPU 栏的黄色代表着 js,紫色代表计算样式和布局,绿色代表绘制。
网络面板
网络面板用于展示正在请求中的各部分的组成情况。

Web vitals
Web vitals 是网站的 Web 体验指标,其中包括 LCP(最大内容绘制)、FID(首次输入延迟)、cls (累计布局偏移)等。
线程面板
线程面板用于展示渲染当前页面所使用到的线程,包含有 Main 线程、GPU 线程、Raster 线程、Chrome_ChildIOThread、Compositor 线程等等。其中 Main 线程,就是我们平时说的大部分 js 的运行环境,即主线程。
内存面板
展示 js 内存、GPU 内存、节点数、监听事件数的变化。
聚合面板
当点击主线程中的火焰图时,此面板会显示显示具体包含执行时间、执行组成、调用栈等等的信息集合。


Chrome是如何渲染页面的?


第一个请求

以第一个请求为例,我们来具体看一下 Chrome 是如何进行页面渲染的?依然是以对 https://www.bilibili.com 的请求为例,来看一下 1ms 的 performance 面板,即下图中红线部分、中间 NET 栏蓝色细长条开始的部分和 Network 中水平箱线图开始的部分。

其中两边横线中间深浅色方框的部分是水平箱线图,是用来展示某部分在整体中的比例关系。比如我们看到这个长长的箱型图,通过直观感受,就能知道对前面一部分横线挺长的,蓝色部分里浅色部分很长,深色的短,右边的横线几乎看不到。那这些又分别能展示什么信息?
首先,点开箱型图最下方的聚合面板(Summary),上面赫然写着:此乃页面源。欲求小破站, 终生皆让我……耗时一秒半。

然后在 Network tab 里查看该请求的 timing 部分,可以得到如下图:

这里的各个部分分别代表:
  • Queueing(排队):浏览器会在一些情况下让请求排队等待,比如这个请求的优先级不高,有更高优先级的请求存在;在使用 HTTP/1.0 或者 HTTP/1.1 时,同域请求最大并发数量为 6 个,此时已经达到了最大值;而上图中的请求是属于最高优先级的第一个请求,即浏览器正在硬盘缓存中分配空间,从图上可以看到有 14.72ms 用于在磁盘缓存中分配空间。
  • Stalled(停顿):它可能会因为上述排队中的任何原因而停顿。
  • DNS lookup(DNS 查询):解析这个域名的IP地址。需要注意的是,当我们多次访问同一域名时,这部分不会出现在 timing 中。
  • Initial connection(初始连接):浏览器建立连接,包括 tcp 三次握手、重试以及协商 SSL。图中的紫色部分,就代表了在初始连接过程中的 SSL 协商部分。
  • Request sent(发送请求):正在发送请求
  • Waiting (TTFB) 等待第一字节时间:浏览器在等待第一个响应的字节,TTFB 即 Time To First Byte。这个时间包括一个往返的延迟和服务准备响应的时间之和。
  • Content Download (内容下载):浏览器正在接收响应,浏览器可以通过网络或者 serviceWorker 来直接接收。这个值是读取响应体的总时间。由于网络不佳或者浏览器正在忙于执行其他工作而延迟了对响应体的读取,读取的时间可能会比预期的要长。

这里相信已经有小伙伴注意到了,当浏览器忙于其他事情时也会让读取时间变长。也就是说,当你的 js 把主线程长期占据的时候,就会影响 content download。
下图是 Network 下的对应资源的 waterfall:

现在我们回到最开始说的各色横条上,在水平箱线图中左上角的深蓝色小方块代表着这个请求有着更高的优先级。遇到有浅蓝色的,则表示较低优先级。同时左边横线对应 Network 面板中显示的 Request Sent之前的所有事情的时间。浅色的 bar对应 Network 中 Request Sent 和 Waiting(TTFB)的时间。深色的 bar对应 Network中Content Download 的时间。右边的横线表示等待主线程所花费的时间,在  Network 面板中没有体现。
此外,可能还有些同学注意到,在蓝色箱线图上面还可以看到还有几个灰色的箱线图。不是说www.bilibili.com 是页面的第一个请求吗,难道它之前还有请求?
事实上,这个灰色箱线图相当于上一个页面的结束。如果我们是通过重新录制的方式记录 performance,那就会经历页面刷新的过程。而这几个灰色的其实就是页面刷新 unload 时发起的,是 bilibili 用来记录页面卸载时的一些数据。
说回到箱线图,可以看到在 summary 中显示 Duration 1.08 s (822.88 ms Network transfer + 260.20 ms resource loading)。这个的意思是 260ms 的时间是在 resource loading ,这里resource loading所花费的时间其实就是箱线图右侧的那条横线,等待主线程的时间。
而在 main 进程中,有横线结束的地方,可以看到解码的数据 138,933 Bytes。

这里就出现了几个问题:为什么Encode Data 33479 bytes 算下来是 33479/1024 = 32.69 k,而不是前面 Network 面板里的 33.5k ? 而且 Decode body 138993/1024 = 135.7k 也不是前面的 139k?缺少的一部分数据是什么呢?
为了验证这个问题,需要清空过去所有请求记录,重新点击录制,录制完成后,导出网络请求的 HAR 文件。使用 vscdoe 打开 json 格式的 HAR 文件,寻找 GET https://www.bilibili.com/  content-Type: text/HTML 的那个请求。经过前后的文件对比,找到了这个请求的 response content:

可以看到,图上的 size 有140682 字节。text则是 base64 编码的 HTML 内容,已经被 decode 过。需要注意的是,这里的 decode 不是对 base64 的 decode,是对 gzip 的 decode。
而在这个 text 内容之后,还有一段如下内容:
其中的 _transferSize: 35593 是网络传输的体积,即传输的体积 35593 和 decode 体积 140682。同时我们在 performance 里的主进程中的 finish loading中可以看到下图数据:
这样一看,二者是相同的。说明这个 HTML 的传输体积就是 35593 Bytes。
那为什么在 Network 面板里,我们看到的是 35.6k transferred over Network 呢?

这是因为在 Network 里展示的体积,不是除以 1024 计算的,而是除以 1000,然后四舍五入后的结果。
不过 Summary 里的 pending for xxx ms,似乎是也是等待主线程的时间,但它又是如何在 performance 体现的。目前,我还没搞清楚,如果有了解的小伙伴欢迎留言讨论~ 

请求其它资源

言归正传,我们现在获取到了 bilibili 网站的 HTML,接下来就需要对这个 HTML 进行处理。
通过 response header 得到 content-type:html,此时会创建一个个渲染进程,也就是主线程的这个进程。但是可以看到在主线程中的蓝色 parse HTML 之前,已经有很多 set request 被发起了,而且这些 send request 都是 HTML 文档中的一些 js 和 css。
为什么会这样呢?不应该是先解析 HTML,才能知道对哪些资源进行发起请求吗?

在 HTML 中引入的 js,存在修改 Dom 的可能,所以浏览器一般在遇到 script 标签后,会先暂停 HTML  解析,优先 js 的下载和执行。但是下载是相对耗时的,如果因为下载时间久而卡住了页面解析,很容易导致用户体验变差,因此 Chrome 采用了一些优化策略。
具体来说,就是当 Chrome 渲染引擎接收到 HTML 的字节流时候,会开启一个专门用来分析字节流中所包含 js、css 文件的预解析线程。解析到相关信息之后,预解析线程会提前开始下载这些资源文件,这样在需要使用的时候就可以直接执行,避免了下载的等待时间。
但是也能观察到,在Parse HTML蓝色方块下方,还有一些 send request,这些怎么就不是提前下载的呢?
我的理解是,这些资源其实都是在预解析线程下载的,尽管在时间上会存在重叠,但和主线程不属于同一个线程,所以 performance 工具会这么显示。但这又带来了另一个问题,为什么有些 js 明明在 HTML 的后面,却在前面就 send request 了,而有些 link/script 明明写在 HTML 里的前面,却在 performance 里后 send request?
这是跟资源的优先级有关。比如普通的 script 标签引用的资源,普通 link 引用的资源,或是rel=prelaod 或 as="style"预加载的资源,可能会被优先处理。而当资源是 prefetch,或者用 <link rel="stylesheet" href="//s1.hdslb.com/bfs/static/jinkela/long/font/regular.css" media="print" onload="this.media='all'"/>这种方式的,由于优先级低,就会被延后下载。一般的其他资源,则按顺序下载。
回到 Network,可以看到在 www.bilibil.com 的箱线图之后,是一连串 js、css、Webp 资源需要加载的请求被发起了。把鼠标移动到这些箱线图上,会看到上面有优先级 lowest low high highest,这就表示了资源的重要程度。

那么这些资源的优先级是如何评定的?一般来说,访问域名获取的 HTML、 以及预加载资源时as="style",拥有最高优先级。普通的 <script>  、 <link> 标签、 使用preload的预加载,拥有 高优先级。使用了 async/defer 的 <script> 、as="script"的预加载资源拥有低优先级。使用了 
<link rel="stylesheet" href="//s1.hdslb.com/bfs/static/jinkela/long/font/medium.css" media="print" onload="this.media='all'"> 
这种方式的,和不加 as="xxx"的 prefetch 预加载,就相当于异步加载,拥有最低优先级。

HTML Parse

好,到现在为止,我们已经将用到的 js、css、图片等资源下载了,然后就该进入解析 HTML 的过程了。
在 Chrome 渲染引擎内部,有个 HTMLParser 的模块。HTML 解析器负责将 HTML 转化为 Dom 结构。HTML 解析器并不是等整个文档全部获取之后才开始解析,而是加载了"足够"的数据后,就开始解析了。
在 HTML Parser 的 summary 面板里可以看到,有个Range:www.bilibili.com[0...45]。点进去看一下可以发现,定位到了 HTML 的 45 行。
这也从侧面印证了解析 HTML 的过程并不是一次全部执行完的。

HTML 的解析生成 Dom 树的过程,可以参考文章(https://medium.com/nybles/introduction-to-Dom-bee3b2dd9911)。简单来说就是将字节流转换成 token,然后把 token 解析成 Dom 节点并添加到 Dom 树中。

在 HTML 解析器工作过程中,会遇到 js、css 需要处理,比如蓝色条下面有黄色的 js 执行,有 parse stylesheet 的 css(这里的两个是 vendor.css 和 index.css)解析和 cssom 的构建。

当拿到了 vendor.css 和 index.css 这两个外部样式文件之后,就开始了 Recalculate Style 的过程,也就是在进行一些可能包括递归(比如想知道父容器的大小就得先知道子元素的大小)的样式计算。注意,这时候 HTML 还是没有完全解析完的,但是一旦样式计算结束,就开始 Layout过程。
这里的Layout对应的是将 Dom tree 和 cssom 结合成 render tree的过程。render tree 是不包含例如<meta>、display: none这些无需展示的元素。

分层

在样式计算之后,还需要经历一个pre-paint的过程,然后才能paint。
以前这里叫做 update layer tree, 2022年3月份之后改成了 pre-paint。这里其实是遍历 render tree 生成 layer tree 的过程。
render tree 和 layer tree 有啥不同呢?
render tree 是 Dom 和 cssom 结合的产物,是将计算后的样式添加到了 Dom 节点上。但是目前只是知道了节点是否可见以及可见样式,还不知道节点的精确位置和大小,这时候就需要布局。渲染引擎从 render tree 的根节点开始遍历,通过一定的规则处理后,将会得到一个 layout tree,这个 layout tree 精确的描述了每个视口内元素的位置和确切尺寸,所有的相对位置都会转变成屏幕上的绝对位置,在得知了节点是否可见、样式、位置几何信息之后,渲染引擎才有机会将 render tree 上的每个节点都转换成屏幕上的像素,这个过程也就是一般说的 绘制 paint或者栅格化 Rastering。
那 layer tree 在哪儿呢?layer tree 就在栅格化的过程当中。
在说栅格化之前,有必要提一下 Chrome 是如何将渲染视口内的内容的。
过去 Chrome 是只在用户可视区域内进行栅格化,随着用户滚动不断滚动页面而调整栅格化区域,继续栅格化并将内容填充到缺失部分。这样的缺点是当用户快速滚动的时候,页面会有卡顿感。
而现在 Chrome 采用了一种合成 composting 的方式,将页面中的某些部分分成不同的层,分别栅格化它们,然后在合成器线程中合成。这样在页面滚动时,原材料已经有了(准备好的那些层),只需要将视口内的蹭合成为一个新帧即可。这样在用户滚动时,新帧的合成效率更高。
既然需要分层,那就要知道那些元素应该在哪一层里,所以渲染引擎需要按照一定规则再遍历一次 layout tree 来创建 layer tree ,这个过程也就是 pre-paint,以前叫做 update layer tree。
分层也需要按照一定的规则,不是任意一个元素都可以被拎出来当做一层,主要是两个条件:

  • 拥有层叠上下文属性的元素会被创建成图层

页面是个二维的,但是层叠上下文属性会让 HTML 元素具有三维的概念。这些元素按照自身的属性优先级分布在垂直页面的 Z 轴之上,哪些元素拥有具体参考 MDN。

  • 需要被裁剪的地方会被创建为图层

当你实际的内容比容器还大的时候,就会出现裁剪,引擎会裁剪一部分内容显示在容器区域。一般来说,出现滚动条就会被创建为图层。
满足以上任意一个条件就会被提升成单独一层。
那这在 Chrome devtools 哪里可以体现呢?在 devtools -> 右侧三个点 -> more tools -> layers 里可以看到页面实际上被分成了许多层。
点击左侧的具体图层,可以看到详细的绘制过程。Details 里还有被提升为一层的原因composition reason。

Paint

通过前面分层,我们得知了元素的层级关系,但是还不知道同一层内元素的层级关系。一般来说,后面的内容会覆盖前面内容,但是浏览器该如何知道谁该覆盖谁呢?
这就需要渲染引擎为每一个图层创建绘制记录 patint record 并确定谁先画谁后画,那么后画的肯定就会覆盖先画的。绘制记录可以看做一个单向链表  div ->  div -> p -> span,遍历链表即可获得绘制顺序。
现在有了图层,也有了绘制记录顺序,这些信息将会被提交到合成器线程中进行绘图和合成。由于一个图层可能会非常大,超过了视口面积,那么图层就会经历一次分割过程,分割成一个个小的图块 Tile,通常是 256*256 或 512*512 大小,这些图块进行会传递给栅格化线程池。池中的栅格化线程执行栅格化任务 Raster Task,将图块生成位图 bitmap,并优先生成视口附近的位图。
这个过程在performamce里叫做Rasterize Paint。
栅格化过程也会使用 GPU 来加速,一般又称为快速栅格化,GPU 栅格化。这也是为什么会有些 css 里写 will-change:transfrom 或者 transform: translateZ(0),就是为了 GPU 参与绘制。本质上是利用 will-change 和 translateZ(0) 创建了新的渲染层,从而不影响其他层级的绘制内容。

当所有的 Tile 栅格化完毕,合成器线程收集 Draw Quads 的图块信息。Draw Quads 记录了图块在内存中的位置和在页面那个位置进行绘制。然后主线程收集这些 Draw quards 信息并合成合成器帧,并交给 GPU渲染,然后才是像素出现在屏幕之上。这个过程在 perfomrance 里是 Compositie layers。
可惜我没有在 performance 里找到更详细的信息来展示这个过程。
页面渲染大概就是上述的过程,主要是结合 performance 面板串联起过去的那些知识。了解了页面渲染流程,我们该如何优化页面性能呢?又需要关注那些指标呢?


页面优化关注哪些指标


这个指标不是凭空创造,也不是仅凭感觉,这应该是一些明确的、可以量化的指标。Chrome devtool lightHouse 列举了 6 个指标。

FCP

First content paint 代表浏览器渲染出第一个 Dom content 的时间。这里的 Dom content 包括图片、非空白 canvas、svgs 等。如果你的页面里有 iframe,iframe 里的任何东西都不会被当成 Dom content。
FCP 好坏标准也是随着收集到的页面数据来不断变化的,我们可以从 httparchive 地址来查看现在世界上的页面的中位数是多少。

根据目前的指标来看,FCP 时间可以简单的分为 3 档:
0 - 1.9s,1.9s - 3s,3s以上,它们分别代表还行、很一般、不太行。
当我们在某个页面中使用 LightHouse 进行评估的时候,可能会看到尽管 FCP 只有 1s,显示的也是橙色标记。
LightHouse 里的得分是根据百分比来的。也就是说当你的 FCP 时间,是所有页面中的前 10%, 那么可以得到 90 分, 前 1% 可以得到 99 分,得到 90-100 分才会是绿色。其他的几个指标也是同样的评判标准。

影响 FCP 的原因有很多,其中一个比较常见的原因是自定义字体的加载,字体文件的加载需要一定时间,在字体文件加载完成之前,不同的浏览器会采用不同的策略。
edge:在字体准备好前使用系统字体
Chrome:隐藏文本内容。如果 3s 后自定义字体还没准备好,则使用系统字体,直到字体准备好,然后替换字体。
火狐:同 Chrome
safari:隐藏文本直到字体准备好。
一个简单的办法是在@font-face 演示里增加 font-display: swap
@font-face {
  font-family: 'Pacifico';
  font-style: normal;
  font-weight: 400;
  src: url(https://fonts.gstatic.com/s/pacifico/v12/FwZY7-Qmy14u9lezJ-6H6MmBp0u-.woff2) format('woff2');
  font-display: swap;
}
设置 swap 就是告诉浏览器是使用该字体的文本应该立即使用系统字体进行显示,当自定义字体准备就绪后,替换系统字体。
或者使用 prefetch / preload 来提前获取字体相关资源。

Time to intercative

TTI 标记着页面多久可以进行完全交互。
这里的完全交互是指:
  • 页面已经展示了 FCP
  • 事件处理函数已经为大部分可见页面元素进行了注册绑定
  • 页面能够在 50ms 内对用户行为进行反应

同样我们在 httparchive  来看一下这个世界上网页 TTI 的中位数是多少。

降低 TTI 的常见思路是代码分割、按需加载,删除未使用代码、压缩代码、压缩网络负载、减少 JS 对主线程的长时间占用。

Speed Index

这个指标代表了用户感知的可见区域的页面加载的快慢。
也分成 0-3.4s 、 3.4 - 5.8、超过 5.8 三挡。但是得分也同样是跟全球网页数据来对比的。
提高 speed index 的主要是通过减少 js 对主线程阻塞,让一些非必要的 js 在 Dom 渲染后再执行。

Total Block Time

总阻塞时间。这个时间是从 FCP 到 TTI 之间所有的长任务阻塞部分的时间之和。
长任务是指执行时间超过 50ms 的任务,50ms 之后的时间量就是阻塞时间。
既然是阻塞时间,降低 TBT 的办法就是想办法减少不必要的 js 的加载、解析和执行。拆分大型脚本,对某些非同步必要的 js 使用 defer/async 或者 prefetch/preload 、或者允许的情况下进行懒加载/延迟加载、将静态资源部署到 CDN 等等。

Largest Contentful Paint

最大内容绘制。记录的是视口中最大的内容元素被渲染到屏幕的时间,也大致分为 0-2.5s、2.5s-4s、超过 4s 三个大范围。
这里的类容元素是指:
  • <img >
  • <svg> 内嵌的 <image> 元素
  • 使用了封面的<video> 元素
  • url() 加载的带背景图的元素
  • 包含文本或者其他行内文本元素子元素的块级元素

注意,如果元素溢出到可视区域之外,则不算 LCP。
LCP 主要手4个方面的影响:

  • 缓慢的服务器响应速度

应对方案:CDN、预加载、serviceWorker

  • js/css 的渲染阻塞

应对方案:

  1. 用optimize-css-assets-Webpack-plugin、uglyifyJS之类的 Webpack 插件压缩 css、js
  2. 对非必要的 js、css 延迟加载,如非必要 css 用预加载,在触发事件后再去 import xx from 'xxx'。
  3. 合适的情况下使用内联 css。
  • 缓慢的资源加载速度
    • 压缩图像
    • 预加载重要资源
    • 压缩文本文件 Gzip、br
    • serviceWorker 进行缓存
  • 客户端渲染
    • 压缩 js
    • 延迟加载未立即使用的 js
    • 尽可能减少polyfill。"targets":">0.25%"

 

Cumulative Layout Shift

有些时候我们会遇到,初始加载时字体忽然变大/变小, 元素位置突然移动位等。
CLS 就是通过测量发生偏移的频率来表示出页面的不稳定性。
常见的导致 CLS 比较差的原因有:
  • 没指定宽高的图片
  • 没有设置宽高的 iframe
  • 没有设置宽高的资源位(顶部 banner、广告等)
  • 前面提到的 无样式文本闪烁(FOUT, 用默认字体替换新字体)/ 不可见文本闪烁(FOIT,获取新字体前的显示不可见文本)。<link rel=preload>和font-display: optional结合使用
  • 动画使用了修改 width、height、top、right、bottom、left 等属性值的方式来实现。应优先使用 css transfrom来实现动画。

以上就是目前 Chrome lighthouse 用来判断页面体验的 6 个指标。如果我们要优化页面,也应从这 6 个方面来入手,逐一改进,在现有的可量化指标下有的放矢。

参考资料:https://www.debugbear.com/blog/devtools-performance


返回