关键渲染路径优化

2,380 阅读12分钟
原文链接: bluest.xyz

天下武功唯快不破,页面加载速度与用户体验息息相关,也关乎企业收益:最知名的当属 Amazon 加载速度每增加一秒一年少赚 16 亿美刀的例子(还是 N 年前的统计),真是应验那句话:

时间 == 金钱!

对此 Google Developer 专门有一篇文章用于阐述页面载入速度的重要性,此外还专门做了个工具,用于展示页面加载速度与收益关系。除了说,他们也这么做了: Google 直接把页面速度纳为搜索排名的指标。

关键渲染路径优化

优化页面加载速度环节众多,今天要说的是关键渲染路径(critical rendering path)优化,及缩短首次渲染页面的时间。

浏览器如何呈现页面的

知己知彼,百战不殆。我们先得了解页面渲染过程,进而从中寻找出优化环节。

DOM

以下面的简单页面为例:

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
    <title>Critical Path</title>
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg"></div>
  </body>
</html>

浏览器如何处理页面流程:字节字符令牌节点对象模型,最终得到的是文档对象模型 (DOM)。

  1. 转换: 浏览器读取 HTML 原始字节,根据指定编码转化为字符
  2. 令牌化:将字符转化为 token 以及字符串
  3. 词法分析:将 token 转化为定义其属性与规则的 node
  4. DOM 构建:将 node 连接在一起组成一颗树

DOM 树捕获文档标记的属性和关系,至于如何定义外观样式。那是 CSSOM 的责任。

CSS

浏览器处理 CSS 过程:字节字符令牌节点CSSOM

浏览器在构建 DOM 时,遇到了一个引用有 style.cssc 的 link 标记,浏览器便发出请求,获取到 CSS 资源文件,与 HTML 类似,我们最终会得到一个称为“CSS 对象模型”(CSSOM) 的树结构。

body { font-size: 16px }
p { font-weight: bold }
span { color: red }
p span { display: none }
img { float: right }

以上并非完整的 CSSOM 树,因为浏览器还有默认样式(也称为 User Agent Stlyesheet)。处理 CSS 所需时间在开发者工具 performance 栏中为 Parse StyleRecalculate Style 事件。

渲染树构建、布局及绘制

DOM 与 CSSOM 只是独立的数据结构,还需将两者合并最终在浏览器上渲染出来。浏览器做了以下几件事:

  1. DOM 树与 CSSOM 树合并后形成渲染树,
  2. 渲染树只包含渲染网页所需的节点,不可见元素以及 CSS 控制的 display: none 隐藏元素会被忽略
  3. 布局计算每个对象的精确位置和大小,计算出盒模型,重排。
  4. 最后一步是绘制,使用最终渲染树将像素渲染到屏幕上

这些过程在 Chrome DevTools 中 Performance 栏中对应的事件是 LayoutPaintComposite Layers

最终,浏览器完成的步骤有:

  1. 处理 HTML 标记并构建 DOM 树。
  2. 处理 CSS 标记并构建 CSSOM 树。
  3. 将 DOM 与 CSSOM 合并成一个渲染树。
  4. 根据渲染树来布局,以计算每个节点的几何信息。
  5. 将各个节点绘制到屏幕上。

优化关键渲染路径就是指最大限度缩短执行上述第 1 步至第 5 步耗费的总时间。

阻塞渲染的 CSS

从浏览器完成页面渲染的过程看:同时具有 DOM 和 CSSOM 才能构建渲染树,所以 HTML 和 CSS 都是阻塞渲染的资源,且 HTML 是必须的,不过没有了样式,页面也是不可用的!

CSS 是阻塞渲染的资源。需要将它尽早、尽快地下载到客户端,以便缩短首次渲染的时间。

JavaScript

现在引入新的变量:JavaScript,接下来看看浏览器遇到 script 标签会如何处理,以下面的 HTML 为例:

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
    <title>Critical Path: Script</title>
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg"></div>
    <script>
      var span = document.getElementsByTagName('span')[0];
      span.textContent = 'interactive'; // change DOM text content
      span.style.display = 'inline';  // change CSSOM property
      // create a new element, style it, and append it to the DOM
      var loadTime = document.createElement('div');
      loadTime.textContent = 'You loaded this page on: ' + new Date();
      loadTime.style.color = 'blue';
      document.body.appendChild(loadTime);
    </script>
  </body>
</html>

在此之前我们先看看,JavaScript 的能力:

  1. JavaScript 可以修改 CSSOM
  2. JavaScript 可以修改 DOM

当浏览器遇到一个 script 标记时,DOM 构建将暂停,直至脚本完成执行,假如在该 script 之前还有外联的样式,JavaScript 的执行得等待 CSSOM 下载与构建完毕,这么做因为 JavaScript 可能会查询或者修改样式。

结论:

  1. CSS 会阻塞 JavaScript 脚本的执行。
  2. JavaScript 的执行会注阻塞脚本后面的 DOM 的解析与渲染。

那么 JavaScript 脚本在文档中的位置就尤为重要了。

渲染阻塞

上文关于渲染阻塞已经说得差不多了这里再总结与补充一下:

CSS

  • CSS 会阻塞页面的渲染
  • CSS 会阻塞 Javascript 执行
  • 动态插入的外链 CSS 不会阻塞 DOM 的解析或渲染
  • 动态插入的内联 CSS 会阻塞 DOM 的解析或渲染

JavaScript

  • 同步的 JavaScript 都会阻塞 DOM 的解析或渲染
  • 异步的 JavaScript 不会阻塞DOM 的解析或渲染
    • asyncdefer 的 JavaScript 脚本
    • 动态插入的外链 JavaScript 脚本

首次渲染

那么如何判定页面发生了首次渲染(first paint)了呢,最佳方式是使用 Chrome DevTools 中的 performance 工具查看:例如下图 Frames 栏中绿色色块开始的地方就是首次渲染开始的时间点。顺便说一下蓝线与红线分别代表文档 DOMContentLoadedLoad 事件产生的时间点。

DOMContentLoaded

当初始的 HTML 文档被完全加载和解析完成之后,DOMContentLoaded 事件被触发,而无需等待样式表、图像和子框架的完成加载。

注意DOMContentLoaded 事件必须等待其所属 script 之前的样式表加载解析完成才会触发。

via MDN

DOMContentLoaded 一般表示 DOM 和 CSSOM 均准备就绪的时间点,很多 JavaScript 逻辑都是在 DOMContentLoaded 之后执行的。所以优化关键渲染路径对页响应用户操作是有积极意义的。

Load

对于文档而言表示所页面上有资源都已经加载完毕,用户角度就是浏览器加载小圈停止旋转。

加载阻塞

对于外链的资源,比如外链的样式、脚本、媒体文件、字体等,比起内联资源还需要一个加载过程,那么浏览器是一个个串行去加载的吗?

考古 speculative preload(预先加载)

在早期浏览器 JavaScript 资源是阻塞加载的,即 script 是串行加载的,即下一个资源必须等待 script 加载并执行完成后才能加载。这样会带来很严重的性能问题。

2008 年,IE 提出了 speculative preload(预先加载)策略,即浏览器遇到 script 资源还会继续搜索其他资源并且加载,随后 Firefox 3.5、Safari 4 和 Chrome 2 也采取了类似策略。

资源加载优先级

浏览器才不会那么傻傻的串行加载资源,首先多个线程并行加载资源,而且其会尽早的提前发现并加载资源,比如 Chrome 在 DOM 解析的时候会提前扫描有那些资源并发送请求。那么问题来了,外部资源那么多,浏览器还有同一域下并发请求资源数限制,比如 Chrome 目前是 6 个。那么在有限的加载通道下,比如有谁先谁后的问题,浏览器是如何确定资源加载优先级的?

比如 Chrome 有如下策略:

  1. 资源的类型,如 CSS、script 这些阻塞渲染的优先级要高一些
  2. 资源位于文档的位置有关:<head> 内的资源优先级要高一些

以上只是举例,资源加载策略都是与浏览器自己实现,最好从源码与相关文档中学习。且浏览器也在不断优化加载策略,比如 Chrome 要开始对不在是视口内的图片资源执行懒加载了:Blink LazyLoad

评估关键渲染路径

  • 关键资源: 可能阻止网页首次渲染的资源。
  • 关键路径长度: 获取所有关键资源所需的往返次数或总时间。
  • 关键字节: 实现网页首次渲染所需的总字节数

理想状态下

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>Critical Path: No Style</title>
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg"></div>
  </body>
</html>

添加样式

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg"></div>
  </body>
</html>

  • 2 项关键资源
  • 2 次或更多次往返的最短关键路径长度
  • 9 KB 的关键字节
    `

添加 JavaScript

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg"></div>
    <script src="app.js"></script>
  </body>
</html>

  • 3 项关键资源
  • 2 次或更多次往返的最短关键路径长度
  • 11 KB 的关键字节

异步 JavaScript

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg"></div>
    <script src="app.js" async></script>
  </body>
</html>

  • 2 项关键资源
  • 2 次或更多次往返的最短关键路径长度
  • 9 KB 的关键字节

将 CSS 的 Media Query 设置为 print

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet" media="print">
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg"></div>
    <script src="app.js" async></script>
  </body>
</html>

  • 1 项关键资源
  • 1 次或更多次往返的最短关键路径长度
  • 5 KB 的关键字节

优化关键渲染路径

“优化关键渲染路径”在很大程度上是指了解和优化 HTML、CSS 和 JavaScript 之间的依赖关系谱。

优化 CSS

精简压缩 CSS

无需解释,更小的 CSS 会减少加载字节,加快 CSS 解析以及页面渲染速度。

将 CSS 位于 <head>

首先是便于浏览器尽早发现样式资源尽早执行加载。

PS:比如 Chrome 对此有优化,就算样式位于页面底部,被浏览器资源扫描到也会尝试提升其加载优先级。不过经自测,这种优化策略有点迷并不具有规律,另外更换网络环境,Chrome 也会采取不同的加载策略。

所以,还是老老实实把样式放置在头部吧,而且越靠前越好。

Fast 3G 模式下的资源加载瀑布流:

内联阻塞渲染的 CSS

这么做也能减少关键路径中的往返次数。当然这也有缺陷,比如增大了页面体积,以及无法利用缓存 CSS 缓存,我们需寻求一个平衡点,比如只将关键样式内联到文档内。

这也能避免 FOUC(Flash of Unstyled Content)即内容样式短暂失效,也就是我们通常所说的页面闪烁,由于默认样式与网站样式切换带来的变化所致。产生原因比如长时间等待获取样式资源或者 JavaScript 的阻塞等。

FOUC 在 Chrome 中很少见,因为其会等待 CSSOM 构建完毕后才开始页面渲染,但因其渲染模式会产生白屏现象。

使用 Media Query

<link href="style.css"    rel="stylesheet">
<link href="style.css"    rel="stylesheet" media="all">
<link href="portrait.css" rel="stylesheet" media="orientation:portrait">
<link href="print.css"    rel="stylesheet" media="print">

符合媒体查询的样式才会阻塞页面的渲染,当然所有样式的下载不会被阻塞,只是优先级会调低。

避免使用 CSS import

@import 引入的 CSS 需依赖包含其的 CSS 被下载与解析完毕才能被发现,增加了关键路径中往往返次数。

优化 JavaScript

优化精简 JavaScript、按需加载

同理 CSS。

JavaScript 脚本位于底部

非异步的 JavaScript 会阻塞其之后的 DOM 解析与渲染。

asyncdefer

异步的 JavaScript 不会阻塞 DOM 的解析,asyncdefer 使得浏览器对 JavaScript 外链脚本进行异步处理。

defer

规范中要求 defer 循序执行,且执行时机为 DOM 解析完毕之后执行。

async

规范中要求 defer 执行时机为当前脚本下载完毕之后执行,不要求顺序执行。

其他优化

使用 preload 提升优先级

字体文件

参考

  1. Building the DOM faster: speculative parsing, async, defer and preload(中)
  2. Building the DOM faster: speculative parsing, async, defer and preload(原)
  3. 从Chrome源码看浏览器如何加载资源
  4. Resource Fetch Prioritization and Scheduling in Chromium
  5. CSS 是如何工作的:关键渲染路径中的 CSS 解析和渲染
  6. 用 preload 预加载页面资源
  7. JS 和 CSS 的位置对资源加载顺序的影响
  8. JavaScript 的性能优化:加载和执行(文章较老,部分内容已经过时)
  9. 异步渲染的下载和阻塞行为
  10. 前端魔法堂:解秘 FOUC