/ web

以通俗的方式理解关键渲染路径

以通俗的方式理解关键渲染路径

我在看了 google 的 Critical Rendering Path (中文)后, 想把 CRP(Critical Rendering Path) 用通俗易懂的方式描述出来。 官方文档当然是描述最为详尽且可靠的。 文章里的有些图片是直接引用自官方文档。 如果存在侵权, 立刻删除。

1. 什么是 CRP ?

游览器从开始请求 HTML 文档, 到首次渲染到屏幕上(首屏), 背后需要做很多的事情, 这一连串事情就是 CRP 。 开发 app 的时候很多优化都是和缩短 CRP 有关的。 缩短 CRP 的长度能够有效降低首屏时间。 这也是理解 CRP 最大的好处。

CRP 大概包括两个部分: 1、 本地加载渲染, 2、 网络请求 。

2. 游览器的渲染原理

从游览器加载 HTML 文档到首屏, 中间发生了什么?

TL;DR

  • 游览器解析 HTML 形成 DOM 树。
  • 游览器解析 CSS 代码输出 CSSOM 。
  • DOM 和 CSSOM 一起构造出一颗 Render Tree 。
  • 游览器对 Render Tree 上的节点计算精确位置(布局)。
  • 使用 Render Tree 绘制 (Painting) 到屏幕上。

DOM 用来描述文档结构, 因而是树形结构。 getElementById , querySelector 等 API 就是针对DOM操作的。 CSSOM 用来描绘 DOM 上的节点的样式, 所以图中的 CSSOM 的根节点是 body , 这是因为 head 标签没有样式信息。 样式的继承过程就是发生在构造 CSSOM 的阶段。 当你使用 element.style 访问元素的样式的时候, 其实访问的是CSSOM 上对应的节点。 然后利用 DOM 和 CSSOM 生成 Render Tree , 这中间做了很多事情, 比如说如果检测到一个元素所对应的 CSSOM 节点存在 display: none , 那这个节点将不会输出到 Render Tree 。

接下来要利用 Render Tree 计算每个节点在视口上具体的位置, 这就是布局。 布局阶段输出 CSS 的 “盒子模型”, 最后利用这些盒子, 绘制到屏幕上 (Painting) , 在这个过程中如果检测到 visibility: hidden , 游览器会将其绘制成一个空白(这里的空白是完完全全的空白, 而不是白色。。。)

现在对于 visibility: hiddendisplay: none 的理解应该加深了不少, 具体的区别可以 google 。

这就是游览器的大概的渲染过程。

3. 深入 CRP

其实讨论 CRP 更多的时候是在讨 CSS 和 JS 。 因为 CSS 和 JS 会阻塞渲染。 为什么说 CSS 和 JS 会阻塞渲染? 很难想象如果游览器先呈现一段没有样式的页面,然后突然刷新, 这是一种及其糟糕的体验。 而 JS 脚本的执行会访问 DOM 和 CSSOM, 为什么 JS 是同步加载, 而不是异步加载呢? 游览器为什么不像处理样式文件一样处理脚本文件呢? 这其实很好理解, 脚本文件一般包含着你应用的逻辑, 如果脚本都是异步加载, 那应用的逻辑岂不是乱套。。

什么是 CRP 长度?

获取所有阻塞资源(关键资源)所需的往返次数, 比如说样式文件, 脚本文件; 图片不属于关键资源, 因为图片不会导致阻塞游览器渲染。

关于 CRP 中几个关键的时间点:

  • domLoading 这是整个 CRP 的开始的时间点。
  • domInteractive 游览器刚好构建完 DOM 的时间点。
  • domContentLoaded DOM 构建完成, 且没有任何样式会阻塞脚本允许的时间点。 意思就是当没有脚本文件执行的时候, DOM 构建完成时就到达这个时间点, 毕竟没有脚本需要执行怎么会存在阻塞呢? 不过这种情况很少见。 当有脚本文件要执行的时候, 样式( CSSOM )会阻塞脚本文件执行, 此时要等到样式全部就绪的时候才会到达这个时间点。 所以, 当存在脚本文件的时候, 一般 domContentLoaded 会往后推移许多。
  • domCompelete 表示所有资源都已经下载完成, 包括图片, 字体等。 这个时间点将触发 onload 事件。

如图:

** talk is cheap show me the code **

HTML 文件:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" href="./style.css">
  
  <title>Test</title>
</head>
<body>
  <div>
    <figure>
      <img id="emma" src="http://photocdn.sohu.com/20150227/Img409195726.jpg" alt="emma">
      <figcaption>Emma.Watson</figcaption>
    </figure>
    <button id="switch">Switch</button>
  </div>
  <script src="./index.js"></script>
</body>
</html> 	

这段代码首先在 head 里引用了样式文件, 在文档的最后引用了 script 文件, 这是因为 script 标签会导致游览器阻塞 DOM 的解析, 游览器甚至会花时间等待 script 引用的脚本资源的请求过程(同步加载), 直至请求响应后且解析完脚本后才会将控制权交还给游览器来继续解析 DOM 。 因此脚本执行时, 很可能 DOM 没有创建完成, 将 script 放到最后是明智之举。 这里一共请求了三个资源, 但是几乎都是同时发起的, 因此算作一次往返, 所以 CRP 长度是1。

使用 DevTools 的 Timeline 查看页面加载情况。 如果你对 DevTools 不熟悉, 可以看下 Chrome DevTools 快速入门 的官网文档 。 实际上这里并用不到很多这方面的知识。 稍微有所了解就行。 在使用 Timeline 的时候要注意开启隐身模式, 不然会存在诸如游览器插件等干扰因素。

上面的代码的加载性能截图:

可以看到图中有两条非常接近的垂直的线, 蓝色表示触发 DOMContentLoaded 事件的时间点, 红色表示触发 onload 事件的时间点。两个时间点非常接近, 这是由于游览器在请求外部脚本时会等待其响应, 因此将 DOMContentLoaded 事件向后推迟了。 这里的 CRP 长度是1。 假如没有脚本文件的请求, 那我们应用的 DOMContentLoaded 将在 DOM 解析完成后发生, 而不至于等到所有样式就绪( CSS 解析完成)。 这里即便是将脚本文件内联到 HTML 文档中也是一样的结果。 因为脚本代码的执行会访问 CSSOM , 游览器在解析脚本代码的时候会等待所有 CSSOM 就绪才开始执行脚本代码。 因此效果是一样的。

这是上面代码的 CRP 流程图:

可以看到在 T1 到 T2 这个时间段, 游览器解析 DOM 的操作是被阻塞的。 即便内联了脚本文件。

因为大多数的 JavaScript 框架的逻辑开始部分都是从 DOMContentLoaded 事件点开始的(因为 DOMContentLoaded 总比 onload 快, onload 会等到图片等资源都就绪才触发, 会拖慢首屏时间)。 因此, 提前 DOMContentLoaded 是很有必要的。

一般的 CRP 流程是这样的:

图上有两个灰色的方块, 这两个方块是导致 CRP 时间变长的罪魁祸首!

4. 减少 CRP , 出发!

缩短 CRP 就是尽快使 DOMContentLoaded 事件产生, 以便尽快执行 APP 逻辑, 不要拖到 onload , 节约一分一秒! 因为 DOMContentLoaded 产生后, 就会构建 Render Tree , 然后顺水推舟, 用户就能看到 APP 了。

** 不要引用过多的脚本文件! **

在前几年的前端开发中, 喜欢将对脚本的引用写在 HTML 文件里, 这会导致 DOMContentLoaded 触发大大延迟。 因为每一次解析一个脚本都会阻塞游览器构建 DOM 。 当一个 HTML 文件引用几十个外部脚本文件时, 那肯定是一场灾难! 所幸最近几年各种前端打包工具使得这个问题得以解决。

在笔试腾讯的时候遇到一个问题: 使用 HTTP2 的时候还有没有将 JS 文件打包的必要? 答案是: 有必要, 而且是必须的! 虽然 HTTP2 可以实现同一个 TCP 连接里的所有资源的并行传输(使用流)。 但是游览器处理外部脚本文件的策略不会变! 所以依然有打包的必要!

** 将外部脚本的引用放到 HTML 文件的最后, 或者使用 defer 属性。**

即便你使用打包工具只引用了一个外部脚本文件, 但是如果这个脚本文件的传输延迟和执行延迟, 会导致后面的非关键资源的请求被延迟, 虽然这不会减慢 APP 的首屏时间。 但是图片等非关键资源的呈现时间却被延迟了。 使用defer属性可以将执行时间推迟到 domContentLoaded 时间点后。 DOMContentLoaded也是这个时间点后触发。那会先执行 defer 还是先触发 DOMContentLoaded 事件? 在 Chrome 游览器下的顺序是先执行 defer 再触发DOMContentLoaded 事件。

** 对外部脚本使用 async 属性 **

async 属性告诉游览器异步请求外部脚本文件, 不要阻塞在这里。 这样可以使得游览器继续构建 DOM 。 或者处理后面的资源请求。

** 将样式文件请求置于 head 标签内。 **

尽早在 HTML 文档内指定所有 CSS 资源,以便浏览器尽早发现 link 标记并尽早发出 CSS 请求。

** 尽量不要使用 @import 指令。 **

CSS 中的 @import 表示在一个样式文件中导入另外一个样式文件。 一个样式文件 import 另外一个样式文件时, 只有在这个样式文件被收到且被解析完成才会 import 。 这样会增加 CRP 长度。

** 内联样式 **

使用 style 标签将样式内联到 HTML 文件内, 虽然这样做会增大 HTML 文件的体积。 但是却可以减少 CRP 长度。 同时也避免了脚本代码的执行被阻塞的情况。

为什么没有内联脚本呢? 因为绝大多数情况下不可能做到完全的内联样式。 这个时候即便使用内联脚本也会因为 CSSOM 而被阻塞, 会阻止 DOM 构建。 因而这并不是一种优化措施。

** 减少你的关键资源的大小和数量 **

这是优化最好的手段。

** Else **

这里并没有提到如何去优化应用的逻辑来增加速度, 因为这并不属于 CRP 。

5. 常用优化工具安利。

** Lighthouse **

Lighthouse 是一个网络应用审核工具, 可以帮助你找到 CRP 的瓶颈所在。

Lighthouse 的截图:

** Navigation Time API **

这个内置的 API 可以帮助你记录下各个时间点的具体时间值, 上面说的那几个时间点都有。

6. 最后的最后

如果你觉得有哪里写的不是很恰当或你不能理解的, 可以在评论里告诉我。。

如果你觉得我写的内容对你有所帮助。 可以选择关注我。

我的博客地址是: mrcode