前端性能优化之关键路径渲染优化

3,273 阅读15分钟
原文链接: github.com

关键路径

浏览器加载流程

浏览器在渲染页面时需要将 HTML 标记转化成 DOM 对象

CSS 标记转化成 CSSOM 对象

DOM 和 CSSOM 是独立的树形结构,

当 DOM 树和 CSSOM 树都构建完成的时候,他们就会合并在一起构建 render tree,因为要在页面上渲染不仅需要这个页面的结构,也需要知道整个页面的样式,所以 render tree 是 DOM 树和 CSSOM 树的结合体。

浏览器的渲染管道可以表示为如下:

构建 render tree 的过程就是完成了 style 的过程,计算样式的匹配与权重来确定每个节点的样式,接下来进入 Layout 阶段,最后,既然我们知道了哪些节点可见、它们的计算样式以及几何信息,我们终于可以将这些信息传递给最后一个阶段:将渲染树中的每个节点转换成屏幕上的实际像素。这一步通常称为“绘制”或“栅格化”。

下面简要概述了浏览器完成的步骤:

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

经过以上整个流程我们才能看见屏幕上出现渲染的内容,优化关键渲染路径就是指最大限度缩短执行上述第 1 步至第 5 步耗费的总时间,让用户最快的看到首次渲染的内容。

阻塞渲染的因素

外部样式表

从上面的整个流程我们已经知道,浏览器的渲染需要 render tree, render tree 需要 CSSOM 树才行,所以样式表的加载是会阻塞页面的渲染的,如果有一个外部的样式表处于下载中,那么即使 HTML 已经下载完毕,也会等待外部样式表下载并解析完毕才会开始构建 render tree。

脚本

脚本就更麻烦了,先明确一点, JS 引擎和 UI 的渲染引擎是互斥的,所以当脚本在执行的时候浏览器要将控制权就给 JS 引擎,等到 JS 执行完毕再还给 UI 引擎,不论这个脚本是以何种形式加载的,在执行时均会阻塞 UI 的渲染

接下来分别看不同形式加载的脚本对页面渲染的阻塞情况:

内联脚本
<script>...</script>

内联的脚本随着 HTML 一起下载,在开始执行时已经完成了 字节 → 字符 → 令牌 → 节点 → 对象模型 的整个过程,所以不存在下载的时间(其实也不能这么说,下载的时间算在了 HTML 的下载时间中),执行时是会阻塞关键渲染路径的。

外部脚本
<script src="sample.js"></script>

外部脚本的整个加载过程及执行过程都是阻塞关键渲染路径的。

带 defer 和 async 的外部脚本
<script src="sample.js" defer></script>
<script src="sample.js" async></script>

带 defer/async 的脚本会与 HTML 并行下载,下载的过程不会阻塞 DOM 的构建,但是执行是会的,不同的是 defer 是在 DomContentLoaded 之前执行,async 是加载完之后立刻执行。

defer/async 的脚本在下载期间不会阻塞页面解析不是一个技术原因而是一个选择,因为内联脚本/外部脚本是要等待他们执行,所以不得不等待他们下载。而页面并不需要等待 defer/async 的脚本,所以他们的下载与页面的解析是并行的。

动态生成的脚本
var dynamicScript = document.creatElement('script')
dynamicScript.src = 'sample.js'
document.head.appendChild(dynamicScript)
dynamicScript.onload = function(){...}

动态生成的脚本的下载过程不会阻塞页面的解析,执行会阻塞解析,有点 async 的感觉。

脚本与样式表的依赖关系

脚本不仅能够访问 DOM 元素,还能访问 DOM 的样式,如果将要执行脚本时浏览器尚未完成 CSSOM 的下载及构建,浏览器将延迟脚本执行和 DOM 构建,直至其完成 CSSOM 的下载和构建。

所以,CSSOM 的构建会阻塞 HTML 的渲染,也会阻塞 JS 的执行,JS 的下载与执行(内联及外部样式表)也会阻塞 HTML 的渲染。

优化方法

为尽快完成首次渲染,我们需要最大限度减小以下三种可变因素:

  • 关键资源的数量**:** 可能阻止网页首次渲染的资源。
  • 关键路径长度**:** 获取所有关键资源所需的往返次数或总时间。
  • 关键字节的数量**:** 实现网页首次渲染所需的总字节数,它是所有关键资源传送文件大小的总和。我们包含单个 HTML 页面的第一个示例包含一项关键资源(HTML 文档);关键路径长度也与 1 次网络往返相等(假设文件较小),而总关键字节数正好是 HTML 文档本身的传送大小。

优化关键渲染路径的常规步骤如下:

  1. 对关键路径进行分析和特性描述:资源数、字节数、长度。
  2. 最大限度减少关键资源的数量:删除它们,延迟它们的下载,将它们标记为异步等。
  3. 优化关键字节数以缩短下载时间(往返次数)。
  4. 优化其余关键资源的加载顺序:您需要尽早下载所有关键资产,以缩短关键路径长度。

关键 CSS

上面已经分析过了,样式表会阻塞渲染,在加载完毕之前是不会显示的,为了让用户以最快的速度看到页面上的内容,可以将页面的某一部分的样式抽离出来,单独放在一个样式表中或者内联在页面中,这样的样式称为关键样式,它可以是页面的骨架屏或者是用户刚加载进页面时看到的首屏的内容。

<!doctype html>
<head>
  <style> /* inlined critical CSS */ </style>
  <script> loadCSS('non-critical.css'); </script>
</head>
<body>
  ...body goes here
</body>
</html>

预加载 —— preload

使用 preload meta 来提升资源加载的优先级。preload 的定义

preload is a declarative fetch, allowing you to force the browser to make a request for a resource without blocking the document’s onload event.

注意和 prefetch 的区别

<link rel=“prefetch”> is a directive that tells a browser to fetch a resource that will probably be needed for the next navigation. That mostly means that the resource will be fetched with extremely low priority

preload 会提升资源的优先级因为它标明这个资源是本页肯定会用到 —— 本页优先

prefetch 会降低这个资源的优先级因为它标明这个资源是下一页可能用到的 —— 为下一页提前加载

preload 最大的作用就是将下载与执行分离,并且将下载的优先级提到了一个很高的地步,再由我们去控制资源执行的位置。

加速样式表下载

样式表是阻塞页面呈现的(注意是呈现,不是解析),正常通过 link 加载的外部样式表要等下载,构建 CSSOM 树才会让页面呈现完成,但是 preload 能够让样式表的下载和呈现分离。

试想,当你在页面的 head 中写了如下的两个样式表:

<link href="critial.css" rel="stylesheet" />
<link href="non-critial.css" rel="stylesheet" />

第一个是关键 CSS,第二个不是关键 CSS,当页面解析了这两个 link 标签后开始下载,但是即使 critical.css 下载解析完毕也不会呈现页面,因为页面还要下载和解析 non-critical.css。

这时候,就要将 non-critial.css 作为预加载,当样式表作为被 preload 后,他就不会再阻塞页面的呈现,也就是所谓的异步下载,修改后的代码如下:

<link href="critial.css" rel="stylesheet" />
<link rel="preload" href="non-critial.css" as="style" />
<link href="non-critial.css" rel="stylesheet" />

如此一来,页面在解析完 critical.css 之后就会呈现(暂不考虑脚本),而 non-critial 也在下载,但是并不阻塞页面,指导它下载和解析完毕后才会应用到页面上。

现在并不是所有的浏览器都支持 preload,我们可以用 loadCSS 这个库来做 polyfill,其实现的思路也是遍历所有带 preload 和 as 的标签,然后修改标签的 media 为不匹配任何条件并开始下载,在下载完毕后再还原该 link 原来的 media 标签将它应用。

加速脚本下载

preload 将脚本的加载及执行分离,加了 preload 的 <link> 标签的作用是将脚本提到高优先级尽快完成下载,但是并未执行。

<link rel="preload" href="//cdn.staticfile.org/jquery/3.2.1/jquery.min.js" as="script" />

还需要在你想要他执行的地方引入一个正常的 <script> 标签执行这个脚本

<script src="//cdn.staticfile.org/jquery/3.2.1/jquery.min.js"></script>

否则 chrome 大约会在 3s 后报一个 warning 来提醒你这个资源被浪费了完全没有被使用到。

preload 的功能听起来很像被 defer 的脚本,但是:

  1. defer 无法控制脚本执行的时机,是在 DOMContentLoaded 执行前触发
  2. defer 会阻塞 onload 事件,preload 不会阻塞 onload 事件
  3. defer 的脚本下载的优先级是 low,preload 的脚本优先级是 high

根据脚本在文档中的位置不同和他们是否是 async,defer 和阻塞,它们会有不同的优先级:

  • 阻塞脚本在第一个图片前发起请求的优先级为:Medium(DevTools 中为 high)
  • 阻塞脚本在第一个图片后发情请求的优先级为:Low(DevTools 中为 Medium)
  • async/defer/动态插入的脚本(不论他们在文档中的什么位置)的优先级为:Lowest(DevTools 中为 Low)

我们以掘金的首页为例:

image

可以看到 high 的全是写在 HTML 中进行加载的静态资源你,Low 的都是 thunk 在 JS 中的脚本,为其他页面预加载。

加速字体下载

自定义的字体在加载之前会处于 FOUC 现象,具体的可以看我写的一篇关于 @font-face 加载的文章,虽然我们可以使用类似 webFont 一类的库来控制字体的闪现和添加钩子函数,但最佳解决方法还是让字体的加载达到最快的速度。

使用 preload 也可以来加速字体的下载,在 head 中声明 preload,比先下载样式表再从中读到 @font-face 的 src 再去加载要快得多。

<link rel="preload" as="font" href="https://at.alicdn.com/t/font_zck90zmlh7hf47vi.woff">

但是要注意

preload 字体不带 crossorigin 也将会二次获取! 确保你对 preload 的字体添加 crossorigin 属性,否则他会被下载两次,这个请求使用匿名的跨域模式。这个建议也适用于字体文件在相同域名下,也适用于其他域名的获取(比如说默认的异步获取)。

preload 如果不带 crossorigin meta ,默认情况下 (即未指定crossOrigin属性时), CORS 根本不会使用,这样 http 的 request header 中就不会有 origin,默认不去跨域,但是 @font-face 中去加载字体是默认跨域请求的,所以会造成两次的 request header 不同,无法命中缓存,造成重复请求。

image
image

解决方法就是带上 crossorigin,

<link rel="preload" as="font" href="//at.alicdn.com/t/font_327081_19o9k2m6va4np14i.woff" crossorigin>
<link rel="preload" as="font" href="//at.alicdn.com/t/font_327081_19o9k2m6va4np14i.woff" crossorigin="anonymous">
<link rel="preload" as="font" href="//at.alicdn.com/t/font_327081_19o9k2m6va4np14i.woff" crossorigin="fi3ework">

空关键字和无效关键字都会被当做 anonymous。

其他资源

preload 不仅可以将这些在 head 中的资源加速,还可以提前加载一些隐藏在 CSS 和 JS 中的资源,比如刚才隐藏在 CSS 中的字体资源,或者 JS 中请求的资源。

preload 的标签可以动态生成,这意味着在任何时候你都可以在页面中提前加载但不执行一个脚本,然后通过动态脚本来立刻执行它。

var preload = document.createElement("link");
link.href = "myscript.js";
link.rel = "preload";
link.as = "script";
document.head.appendChild(link);
var script = document.createElement("script");
script.src = "myscript.js";
document.body.appendChild(script);

媒体查询

现在的页面基本上都具有响应式设计,即针对移动端或桌面端会采用 media 进行媒体查询,有两种包含媒体查询的 CSS 代码的方法:1. 将需要媒体查询的代码和基础样式代码放在同一文件中,使用 @media 来使媒体查询生效。 2. 将需要媒体查询的代码放在单独的一个外部样式表中,使用 media meta 对需要媒体查询的 link 进行控制。

这两种方法各有好处,如果需要媒体查询的代码量很小,那么和基础样式放在一起也没有关系,可以节省一次 HTTP 请求。如果比较大的话,那么就会让样式表的体积增加,造成 FOUC 的时间边长,这时候更适合使用第二种。

另外请注意“阻塞渲染”仅是指浏览器是否需要暂停网页的首次渲染,直至该资源准备就绪。无论哪一种情况,浏览器仍会下载 CSS 资产,只不过不阻塞渲染的资源优先级较低罢了。

优先级较低意味着浏览器在解析 HTML 时发现要下载这个样式表,但并不一定会立刻开始下载,而是可能会将它滞后一段时间再下载(等级低没人权),从 DevTools 上也可以看到 Highest 和 Lowest 的区别。

image

image

如果媒体查询的样式表符合当前的页面,那么媒体查询的样式表也会阻塞关键路径渲染(就好像他是个正常的一样),同时,它的下载优先级也会恢复到最高(恢复人权)。

image

media 配合 preload 能做到响应式加载资源,如下代码,分别是两副图片适配移动端与 PC 端,如果不加 preload 的话,那么其中一幅就会以 Lowest 的等级延迟加载,但是如果我们是一个移动端优先的网站,不希望用户浪费流量及网速下载PC 端的大图的话,就在每个 link 上加上 preload 即可,只有在打开网页时符合 media 的资源会被加载,不符合 media 的资源始终不会被加载,即使后面将浏览器的宽度拉宽也不会加载。

<link rel="preload" href="bg-image-narrow.png" as="image" media="(max-width: 600px)">
<link rel="preload" href="bg-image-wide.png" as="image" media="(min-width: 601px)">

如果用户真的拉宽了屏幕,或者切换端设备,可以使用 Window.matchMedia,来进行 media 的匹配。

var mediaQueryList = window.matchMedia("(max-width: 600px)");
var header = document.querySelector('header');

if(mediaQueryList.matches) {
    header.style.backgroundImage = 'url(bg-image-narrow.png)';
} else {
    header.style.backgroundImage = 'url(bg-image-wide.png)';
}

DNS 预读取 —— dns-prefetch

dns-prefetch 的使用方法更加简单:

<link rel="dns-prefetch" href="//host_name_to_prefetch.com">

link 标签的 rel 设定为 dns-prefetch,href 设定为需要预加载的主机域名即可。

在讲 dns-prefetch 之前,先复习一遍 DNS 的作用及可以优化的点才能了解 dns-prefetch 带来的好处。

网络通讯大部分是基于TCP/IP的,而TCP/IP是基于IP地址的,所以计算机在网络上进行通讯时只能识别如“202.96.134.133”之类的IP地址,而不能认识域名。我们无法记住10个以上IP地址的网站,所以我们访问网站时,更多的是在浏览器地址栏中输入域名,就能看到所需要的页面,这是因为有一个叫“DNS服务器”的计算机自动把我们的域名“翻译”成了相应的IP地址,然后调出IP地址所对应的网页。

一图流表达如下,其中 3, 4, 5, 6, 7 都属于 DNS 解析的过程,也是 dns-prefetch 发挥作用的地方。

image

dns-prefetch 主要用来在用户点击一个链接之前解析对应的域名,这会自动去调用用户浏览器的解析机制。浏览器会在用户浏览网页时多线程完成预加载,当用户真正点击的时候就节省了用户等待域名解析的时间。

Chromium 的官方文档中很详细的介绍了 pre-fetch:

  1. Chromium 会根据页面中超链接的 href 去寻找主机名自动去 prefetch

  2. 如果访问的链接被重定向,那么浏览器可能无法自动识别出真正的主机进行 prefetch,此时需要我么手工预加载,也就是使用 prefetch 标签来指定主机。(这也是决定是否使用 dns-prefetch 的判断方法)

  3. 预加载不会对页面渲染造成损害,因为 Chromium 有8个专门用来预加载的线程。

  4. dns-prefetch 带来的网络消耗是很小的

    Each request typically involves sending a single UDP packet that is under 100 bytes out, and getting back a response that is around 100 bytes

    但是用最小的网络开销代价可以换来较好的用户体验。

  5. 默认情况下,Chromium 和 Firefox 出于安全考虑会关闭在 https 下的自动预加载,可以通过指定 meta http-equiv 来开启自动预加载。

    <meta http-equiv="x-dns-prefetch-control" content="on">

    PS: 如果通过 meta 显示的关闭了预加载,之后将无法再次开启预加载。

拿知乎举个例子,打开知乎,进入控制台,搜索 dns-prefetch

image

发现知乎用了如下的 link,都是知乎的静态资源服务器,因为在没有缓存(假设没有打开过知乎)时打开某个知乎页面,如果该页面有图片,并且是从以上的域名获取的话 dns-prefetch 就不会起作用。如果没有图片,那么上面的 dns-prefetch 就会解析域名,等到打开一个有图的知乎页面时 DNS 解析已经完成了。

参考

Preload: What Is It Good For?

用 preload 预加载页面资源

[译]Preload,Prefetch 和它们在 Chrome 之中的优先级

Preload, Prefetch And Priorities in Chrome

CORS settings attributes

Understanding Critical CSS

通过rel="preload"进行内容预加载

DNS Prefetching

预加载系列一:DNS Prefetching 的正确使用姿势