关键渲染路径(CRP)
在介绍Web页面解析时曾提到过:DOM树和CSSOM树结合在一起会构建出 Render Tree(渲染树),渲染树结合 Layout 绘制在屏幕上,从而展现出来。常常把这个过程称为 关键渲染路径(Critical Rendering Path)简称 CRP。
也就是说,CRP 所用的时间直接决定了页面首次渲染页面的时间。即,通过优化关键渲染路径,可以显著缩短首次渲染页面的时间。
如果我们要优化一个 Web 页面性能,那就必须对关键渲染路径中每一步中发生了什么,只有优化了关键路径才能彻底优化渲染性能。通过优化关键渲染路径,我们可以显著缩短首次渲染页面的时间。此外,了解关键渲染路径还可以构建高性能交互式应用打下基础。
特别声明,接下来内容中关于“关键渲染路径” 术语都将以其缩写字母 CRP 来替代!
什么是CRP
关键渲染路径(Critical Rendering Path),简称 CRP。是指 浏览器将Web代码(HTML、CSS 和 JavaScript)转换为屏幕上可显示的像素所经历的过程。它有几个阶段,其中一些阶段可以并行进行以节省时间,但有些部分必须按照顺序进行。用下图来描述这几个阶段:
- 首先,一旦浏览器得到响应,它就开始解析它。当它遇到一个依赖关系时,它就会尝试下载它
- 如果它是一个样式文件(CSS文件),浏览器就必须在渲染页面之前完全解析它(这就是为什么说CSS具有渲染阻碍性)
- 如果它是一个脚本文件(JavaScript文件),浏览器必须: 停止解析,下载脚本,并运行它。只有在这之后,它才能继续解析,因为 JavaScript 脚本可以改变页面内容(特别是HTML)。(这就是为什么说JavaScript阻塞解析)
- 一旦所有的解析工作完成,浏览器就建立了 DOM 树和CSSOM树。将它们结合在一起就得到了渲染树。
- 倒数第二步是将渲染树转换为布局。这个阶段也被称为 重排
- 最后一步是绘制。它涉及到根据浏览器在前几个阶段计算出来的数据对像素进行字面上的着色
把这几步放到渲染引擎渲染页面的过程中来,就能更清晰的认识到,CRP 会经过下面几个过程:
简单地说,CRP 的步骤:
- 处理 HTML 标记并构建 DOM 树
- 处理 CSS 标记并构建 CSSOM 树
- 将 DOM 树和 CSSOM 树合并大一个渲染树
- 根据渲染树来布局
- 将各个节点绘制到屏幕上
注意:当 DOM 或者 CSSOM 发生变化的时候(JavaScript可以通过 DOM API 和 CSSOM API 对它们进行操作,从而改变页面视觉效果或内容)浏览器就需要再次执行上面的步骤。而且,这里每一个过程都在Web页面的解析一节中做过详细阐述,具体每个过程做了些什么,可以阅读 Web 页面的解析一节。
什么是优化 CRP
优化CRP 就是尽早尽快加载解析与首屏相关的 HTML、CSS 和 JavaScript,即 最大限度缩短执行 CRP 所有过程耗费的总时间。这样就能说快将内容渲染到屏幕上,此外还能缩短首次渲染后屏幕刷新的时间,即为交互内容实现更高的刷新率。也就是常说的,尽量减少白屏、灰屏时间和减少用户可交互时间。
分析 CRP 性能
欲要优化 CRP,就要了解 CRP 几个步骤中存在的陷阱 。在开始之前,我们先定义一下用来描述 CRP 的术语:
- 关键资源:可能阻塞网页渲染的资源
- CRP长度:获取所有关键资源所需的往返次数或总时间
- 关键字节:实现网页首次渲染所需的总字节数,它是所有关键资源传送文件大小的总和
在开始深入分析 CRP 性能之前,需要先花点时间了解 CRP 长度,因为搞清楚了他更有利于我们后面的内容的理解。
CRP 长度 是指:
获取所有阻塞关键资源所需的往返次数。比如样式文件,JS文件;其中图片不属于关键资源,因为图片不会阻塞浏览器的渲染。
关于 CRP 中有几个关键的时间点:
domLoading
:整个 CRP 的开始时间点domInteractive
:浏览器刚好构建完 DOM 的时间点domContentLoaded
:DOM 构建完,且没有任何样式会阻塞脚本允许的时间点。意思就是当没有脚本文件执行的时候,DOM 构建完成时就到达这个时间点(没有脚本不会存阻塞)。当有脚本文件要执行时,CSSOM 会阻塞脚本文件执行,此时要等到CSSOM完成才会到这个时间点。因此,当存在脚本文件的时候,一般domContentLoaded
会往后推移许多domCompelete
:表示所有资源已经下载完成,包括图片、字体等,这个时间点将触onload
事件
如下图所示:
接下来通过一些简单地示例来阐述如何对 CPR 进行分析。先从最简单的示例开始。
<!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>
上面这个HTML文档是个最基础的HTML文档,<body>
中只有一个 <p>
、<span>
、<div>
和 <img>
(网页内容只有一行文本和一张图片),没有任何的 CSS 和 JavaScript。使用 Chrome DevTools 打开 “Network” 选项,并检查生成的资源瀑布:
如上图所示,HTML 文件下载花费了大约 400ms
(不同环境不同时候测试结果可能会有所差异)。
上图描述了 basic_dom_nostyle.html
文件从发起网格请求到得到响应以及完成下载的时间。该文件自身下载量很小(大约 6.3kB
),我们只需要单次往返便可获取整个文件。因此,获取 HTML 文档大约花了 380ms
,其中等待的时间就花了差不多 370ms
。
当HTML内容可用后,浏览器会解析字节,将它转换成令牌,然后构建 DOM 树。为了方便起见,DevTools 会在底部报告 DOMContentLoaded
事件的时间(399ms
),该时间与蓝色垂直线相符。 HTML 下载结束与蓝色垂直线(DOMContentLoaded
)之间的间隔是浏览器构建 DOM 树所花费的时间。
请注意,我们的“趣照”并未阻止 DOMContentLoaded
事件。这证明, 我们构建渲染树甚至绘制网页时无需等待页面上的每个资产,即 并非所有资源都对快照提供首次绘制具有关键作用。事实上,当我们谈论起 CRP 时,通常谈论的是 HTML、CSS 和 JavaScript。 图像不会阻止页面的首次渲染,不过,我们也应该尽力确保系统尽快绘制图像!
即便如此,系统还是会阻止图像上的 load
事件(也称 onload
),上面示例中,DevTools 显示的 load
事件时间大约在 616ms
时发生。回想一下,onload
事件标记的点是网页所需的 所有资源 均已下载并经过处理的点,这是加载微调框可以在浏览器中停止微调的点(由瀑布中的红色垂直线标记)。
这也是 CRP 性能模式中最简单的一种(页面只有 HTML 标记,没有 CSS 和 JavaScript)。要渲染此类网页,浏览器需要 发起请求,等待 HTML 文档到达,对其进行解析,构建 DOM,最后将其渲染到屏幕上:
T0与T1之间的时间捕获的是网络和服务器处理时间。在最理想的情况下(如果 HTML 文件较小),只需要一次网格往返便可获取整个文档。由于 TCP 传输协议工作方式的缘故,较大文件可能要更多次往返。 因此,在最理想的情况下,上述网页具有单次往返最少 CRP。
- 关键资源:1个(html)
- 关键路径资源大小:5KB(html)
- CRP长度:1次(获取html文件最少网格往返)
接下来,我们在上面的示例基础上引入一个外部资源,即在</head>
前引入一个外部的style.css
文件:
<!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>
在这个示例中,从“Network” 瀑布流中可以看到,添加外部 CSS (style.css
) 文件额外增加了一个瀑布请求,它也是在 HTML 完成下载之后发出的请求(几乎和图片同时发出)。请注意,现在 DOMContentLoaded
和 Load
之间的差小多了。这是因为,与纯 HTML 示例不同,我们还需要获取并解析 CSS 文件才能构建 CSSOM, 要想构建渲染树, DOM 和 CSSOM 缺一不可。
引入外部 CSS 样式文件资源之后,CRP 性能模式和只有 HTML (无 CSS 和 JavaScript) 略有差异。我们同样需要一次网络往返来获取 HTML 文档,然后检索到的标记告诉我们还需要 CSS 文件;这意味着,浏览器需要返回服务器并获取 CSS,然后才能在屏幕上渲染网页。因此,这个页面至少需要两次往返才能显示出来。CSS 文件同样可能需要多次往返,因此重点在于“最少“。
- 关键资源:2个(html + CSS)
- 关键路径资源大小:9KB(html占5KB, CSS占4Kb)
- CRP长度:2次或更多次往返
我们同时需要 HTML 和 CSS 来构建渲染树。所以, HTML 和 CSS 都是关键资源,CSS 仅在浏览器获取 HTML 文档后才会获取,因此关键路径长度至少两次往返。两项资源相加共计 9KB
的关键字节。
如果我们这个外部引入的 style.css
(以<link rel="stylesheet" />
)换成 内联 <style>
:
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
body { font-size: 16px }
p { font-weight: bold }
span { color: red }
p span { display: none }
img { float: right }
</style>
</head>
<body>
<p>Hello <span>web performance</span> students!</p>
<div><img src="awesome-photo.jpg"></div>
</body>
</html>
从实测的结果来看,它和只有 HTML 文档(没有外部 CSS 和 JavaScript)表现是相似的:
从某种意义来说:
内联 CSS 优于外联 CSS,至少在 CRP 中 少一个关键资源,少一个 CRP 长度!
接下来,再给 HTML 添加一个 JavaScript 的外部资源(这个时候,外部资源有 CSS 也有 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>
如上图所示,外部资源(CSS 和 JavaScripit)几乎是同时发出请求。现在 DOMContentLoaded
事件和 Load
事件之间时间差已经很小了:
- 与纯 HTML 示例不同,它还需要获取并解析 CSS 文件才能构建 CSSOM, 要想构建渲染树, DOM 和 CSSOM 缺一不可
- 由于网页上还有一个阻止 JavaScript 文件的解析器,系统会在下载并解析 CSS 文件之前阻止
DOMContentLoaded
事件,这是因为 JavaScript 可能会查询 CSSOM,所以必须在下载 CSS 文件之前将其阻止,然后才能执行 JavaScript
也就是说,示例中的外部 JavaScript 脚本文件app.js
既是 网页上的外部 JavaScript 资产,又是一种解析器阻止(即关键)资源。为了执行app.js
文件中的脚本代码,我们还需要进行阻止并等待 CSSOM,这是因为 JavaScript 可能会操作 DOM 或 CSSOM(或同时操作 DOM 和 CSSOM),因此在下载 style.css
并构建 CSSOM 之前,浏览器将会暂停。
因此,这个示例具有以下 CRP 特性:
- 关键资源:3个(html + CSS + JavaScript)
- 关键路径资源大小:11KB(html占5KB, CSS占4KB,JavaScript 占2KB)
- CRP长度:2次或更多次往返
现在,我们拥有了三项关键资源,关键字节共计 11KB,但 CRP 长度仍是两次往返,因为我们可以同时传递 CSS 和 JavaScript。 了解 CRP 的特性意味能够确定哪些是关键资源,此外还能了解浏览器如何安排资源的获取时间。
实际上,有的页面加入 的 JavaScript 不必具有阻止作用,网页中的一些分析代码和其他代码不需要阻止网页的渲染。这个时候,我们可以在 <script>
标签加上 async
来解除对解析器的阻止(即异步加载外部资源 JS):
<!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>
解析 HTML 之后不久即会触发 DOMContentLoaded
事件;浏览器已得知不要阻止 JavaScript,并且由于没有其他阻止解析器的脚本, CSSOM构建也同步进行了。
- 关键资源:2个(html + CSS)
- 关键路径资源大小:9KB(html占5KB, CSS占4KB)
- CRP长度:2次或更多次往返(获取html文件最少1次,CSS最少1次网格往返)
另外异步加载 JS 还具有以下几个优点:
- JS 脚本不再阻止解析器,也不再是 CRP 的关键资源
- 由于没有其他关键 JS 脚本,CSS 也不需要阻止
DOMContentLoaded
事件 DOMContentLoaded
事件触发的越早,其他应用逻辑开始执行的时间就越早
如果用内联 JS 脚本来替代外联 JS 脚本呢?
<!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>
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>
上图中的 app.js
是同步加载的,下图 app.js
在 <script>
标签上使用了 async
属性,采用异步加载:
即使我们直接将 JS 脚内联到 HTML 文件中,浏览器仍然无法构建 CSSOM 之前执行脚本。即 内联 JavaScript 脚本也会阻止解析器。虽然 JS 脚本内联到 HTML 文件中同样会阻止解析器,但减少了一个关键资源的请求,但 DOMContentnLoaded
和 Load
的时间实际上没有多大变化。这与 JS 脚本是内联还是外部的并无关系,因为 渲染引擎在解析 HTML 时,只要碰到 <script>
标签,就会进行阻止(解析会停止),直到 CSSOM 构建完毕,才会继续往下解析。注意,JS脚本换外联,并且是异步加载,效果就好多了! 解析HTML之后不久就触发了DOMContentLoaded
事件,而且渲染引擎也将知道 不要阻止 JS 脚本,并且由于没有其他阻止解析器的 JS脚本, CSSOM构建也可同步进行了。
我们再来看另外一种情景,就是 CSS 和 JS 脚都以内联的方式放置在 HTML中:
<!DOCTYPE html>
<html>
<head>
<title>Critical Path: Measure Inlined</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
p { font-weight: bold }
span { color: red }
p span { display: none }
img { float: right }
</style>
</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>
事实上他们并没有太大的差异,不同的是少了一个外部关键资源,但这种方式会使 HTML 页面显著增大。我们多次提到,当 HTML 解析过程中只要遇到了<script>
标签,它就会暂停 DOM 构建,将控制给 JS 引擎,只有 JS 引擎运行完毕,渲染引擎再从中断的地方恢复 DOM 构建。也就是说,内联的 JS 脚本也会阻塞页面的渲染。
最后,如果 CSS 指定了服务类型(特定的媒体设备),比如 CSS 只用于打印设备(media
的值为 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>
此时,浏览器在非打印状态仍然会下载 style.css
,但该 CSS 只用于打印,浏览器不必阻止它便可渲染页面。所以,只要DOM构建完毕,浏览器便具有了渲染网页所需的足够信息。这里JS 脚本是异步加载的,也不会阻止解析器。因此:
- 关键资源:1个(html )
- 关键路径资源大小:5KB(html占5KB)
- CRP长度:1次(获取html文件最少1次)
优化 CRP
CRP 决定了 首屏渲染速度。
网页性能的基本原则就是:先衡量,再优化。这个基本原则同样适用于 CRP 的优化下,也就是说,我们需要对 CRP 进行优化,就需要知道先对 CRP 进行分析,这也正是我们为什么花一节内容来聊 CRP 怎么分析的。通过 分析 CRP 性能,我们可以用一张图来描述 一般的 CRP 流程:
上图有两个灰色的方块,这两个方块是导致 CRP 时间变长的罪魁祸首!
而我们所讨论的 ”优化 CRP“,在很大程度上指了解和优化HTML、CSS 和 JavaScript 之间的依赖关系谱。通过渲染引擎解析一个页面我们知道浏览器的渲染过程:
我们知道 JavaScript 和 CSS 文件是会阻塞首次渲染的,而且阻塞网页首次渲染的资源又是 CRP 中的关键资源。也就是说有三个影响 CRP 的核心因素:
- 关键资源数量:关键资源数量越多,CRP时间就越长,首次渲染就越慢
- 关键资源大小:通常情况下,所有关键资源的内容越小,其整个资源的下载时间就越短,那么阻塞渲染的时间也就越短
- 请求关键资源的RTT(Round Trip Time)数: 即 CRP 长度,获取所有关键资源所需的往返次数或总时间
RTT (Round Trip Time)是网络中一个重要的性能指标,表示从发送端发送数据开始,到发送端收到来自接收端的确认,总共经历的时长。
也就是说,优化页面的关键渲染路径(Critical Rendering Path)三件事:
- 减少关键资源请求数: 减小使用阻塞的资源(CSS 和 JS),注意,并非所有资源是关键资源,尤其是 CSS 和 JS(比如使用媒体查询的 CSS,使用异步的 JS 就不关键了)
- 减少关键资源大小:使用各种手段,比如减少、压缩和缓存关键资源,也就是减小通过网格发送的数据量。数据量越小,浏览器就越能获取数据,并开始处理数据和渲染页面
- 缩短关键渲染路径长度: 减少关键资源数量及其关键资源大小
在具体优化 CRP 时可以按下面的常规步骤进行:
- 对 CRP 进行分析和特性描述,记录 关键资源数量、关键资源大小 和 CRP 长度
- 最大限度减少关键资源的数量:删除它们,延迟它们的下载,将它们标记为异步等
- 优化关键资源字节数以缩短下载时间(往返次数),减少 CRP 长度
- 优化其余关键资源的加载顺序,需要尽早下载所有关键资源,以缩短 CRP 长度
使用 Lighthouse 或 Navigatio Timing API 检测关键请求链
我们需要一些工具帮助我们检测 CRP 中一些重要指标,比如关键资源数量、关键资源大小 和 CRP 长度等。比如在Chrome中使用 Lighthouse插件,Node 版本的 Lighthouse:
// 安装lighthouse
» npm i -g lighthouse
» lighthouse https://jhs.m.taobao.com/ --locale=zh-CN --preset=desktop --disable-network-throttling=true --disable-storage-reset=true --view
可以得到一份详细报告,在这份报告中可以看到 ”关键请求“ 相关的信息:
除了使用 Lightouse 检测工具之外还可以使用 **Navigation Timing API**来捕获并记录任何页面的真实 CRP 性能。
我们也可以使用性能监控规范中相关 API 来对页面真实用户场景的性能监控:
通过相应工具或技术手段有了 CRP 分析结果之后,就可以针对性的进行优化。
CRP 优化策略
在 CRP 中,HTML、CSS 和 JavaScript 都有可能是 CRP 中的关键资源。对于这些资源我们都应该用上 减小(Minify) 、**压缩(Compress)**和 **缓存(Cache)**等手段。
对于页面的 HTML,我们可以:
- 压缩 HTML:将注释、空格和空行从生产文件中删除。可以使用 HTML Minifier(或 Experimenting with HTML Minifier)等工具删除所有不必要的空格,注释和中断行将减小 HTML 文件的大小,加快网站的页面加载时间,并显著减少用户的下载时间。
- 编写有效的可读的 DOM:
- 用小写字母书写,每个标签都应该是小写的,所以请不要在HTML标签中使用任何大写字母
- 关闭自我封闭的标签
- 避免过渡使用注释(建议使用相应工具清除注释)
- 组织好DOM,尽量只创建绝对必要的元素
- 减少 DOM 元素的数量,因为页面上有过多的 DOM 节点会减慢最初的页面加载时间、减慢渲染性能,也可能导致大量的内存使用。因此请监控页面上存在的DOM元素的数量,确保你的页面没有:
- 没有超过 1500 个 DOM 节点
- DOM节点的嵌套没有超过 32级
- 父节点没有 60 个以上的子节点
- 确保在使用 JavaScript 之前加载 CSS,在引用 JavaScript 之前引用 CSS 可以实现更好的并行下载,从而加快浏览器的渲染事度(即确保
<style>
或<link rel="stylesheet">
标签元素始终位于<script>
之前),更建议<script>
标签统一位于在</body>
结束标签前 - 确保CSS在
</head>
中,便于浏览器尽早发现样式资源,尽早执行加载 - 最小化
<iframe>
数量,最好尽量避免使用<iframe>
这些对于 HTML 来说非常关键,HTML 作为渲染的关键资源。
对于 CSS ,同样需要做一些优化:
- 压缩 CSS:所有 CSS 文件都需要被压缩,从生产文件中删除注释,空格和空行。缩小 CSS 文件后,内容加载速度更快,并且将更少的数据发送到客户端,所以在生产中缩小 CSS 文件是非常重要的,这对用户是有益的,就像任何企业想要降低带宽成本和降低资源。可以使用 cssnano、style-minify对CSS进行压缩
- 非阻塞:CSS 文件需要非阻塞引入,以防止 DOM 花费更多时间才能渲染完成。CSS 文件可以阻止页面加载并延迟页面渲染,使用
preload
实际上可以在浏览器开始显示页面内容之前加载 CSS 文件 - CSS类(class)的长度:类名的长度会对 HTML 和 CSS 文件产生轻微的影响 (在某些场景下存有争议)
- 删除不用的CSS:删除未使用的 CSS ,这样做可以减小 CSS 文件的大小,提高资源的加载速度,可用的工具有 PurifyCSS、PurgeCSS
- 关键CSS(Critical):将页面 CSS 分为关键 CSS (Critical CSS)和 非关键CSS(No-Critical CSS),关键CSS 通过
<style>
方式内联到页面中(尽可能压缩后引用),可以使用 critical工具来完成(详细参阅 关键CSS) - 使用媒体查询:符合媒体查询的样式才会阻塞页面的渲染,当然所有样式的下载不会被阻塞,只是优先级会调低。
- 避免使用
@import
引入CSS:被@import
引入的 CSS 需要依赖包含的 CSS 被下载与解析完毕才能被发现,增加了关键路径中的往返次数。 - 分析样式表的复杂性:分析样式表有助于发现有问题的,冗余 和重复的 CSS 选择器。分析出 CSS 中冗余和重复 CSS选择器,可以删除这些代码,加速 CSS 文件读取和加载。可以使用 TestMyCSS、analyze-css、Project Wallace和 CSS Stats来帮助你分析和更正CSS代码
最后来看 JavaScript:
- 压缩JavaScript:所有JS文件都要被压缩,生产环境中删除注释、空格和空行。可用工具有 uglify-js
- 不内嵌JavaScript:避免在
<body>
中间嵌入多个 JS 脚本代码,将 JS 代码重新集中到外部文件中,放在</body>
之前。最好使用async
或defer
引入 JS,避免阻塞 DOM 解析 - 延迟非阻塞JavaScript 加载:使用
async
或defer
来异步加载 JS 文件,延迟任何非必要的 JS脚本(即对构建首次渲染的可见内容无关紧要的脚本) - 检查依赖项目大小限制:确保使用最优的外部库,大多数情况下,可以使用更轻的库来实现相同的功能
- 使用 Tree Shaking 技术减少 JavaScript 大小:通过构建工具分析 JavaScript 代码并移除生产环境中用不到的 JS 模块或方法
- 使用 Code Splitting 分包加载 JavaScript:通过分包加载,减少首次加载所需时间
- Vendor Splitting 根据库文件拆分模块,例如 React 或 Lodash 单独打包成一个文件
- Entry Point Splitting 根据入口拆分模块,例如通过多页应用入口或者单页应用路由进行拆分
- Dynamic Splitting 根据动态加载拆分模块,使用动态加载语法
import()
,实现模块按需加载
- 避免运行时间长的 JavaScript:运用时间长的 JavaScript 会阻止浏览器构建 DOM、CSSOM 以及渲染网页,所以任何首次渲染无关紧要的初始化逻辑和功能都应该延后执行。如果需要运行较长的初始化序列,请考虑将其拆分为若干阶段,以便浏览器可以间隔处理其他事件
- 避免同步服务器调用:使用
navigator.sendBeacon()
方法来限制XMLHttpRequests
在unload
处理程序中发送的数据。因为很多浏览器都对此类请求有同步要求,所以可能减慢网页转换速度
也可以根据 CRP 理论,从三个方面去优化网页:
- 尽量减少网页首次渲染资源
- 减少关键路径长度,减少请求次数
- 减少关键资源大小
尽量减少网页首次渲染的资源:拆分首屏幕和非首屏
我们可以使用 Critical工具在工程中帮助我们拆分首屏幕和非首屏。这样做的目的是划分出 关键资源,我们定义除底部 TabBar 以上的部分为首屏内容:
这部分内容用户会最先看到,我们的优化措施就是尽量让首屏幕内容尽快展示。
对于非首屏内容采用延迟加载的方式处理。JS、CSS 异步加载。而且对于 CSS 而言,我们可以将其拆分关键 CSS 和非关键 CSS,其中关键CSS以 <style>
方式内联到</head>
中,非关键 CSS 可以异步加载。同样的,对于非首屏的要用到的JavaScript脚本(即对构建首次渲染的可见内容无关紧要的脚本),也应该异步加载。除此之外,HTML、CSS 和 JavaScript 的代码都应该进行减小、压缩和缓存处理。
这里有一个 14kb
数字的故事:
HTTP 的传输层协议是TCP,TCP协议有一个慢启动的过程,即它在第一次传递数据时,只能同时传递
14kb
的数据块,所以当数据超过14kb
时,TCP 协议传递数据实际上是多次往返。如果能够将渲染所需的资源控制在14kb
之内,那么就能 TCP 协议启动时,一次完成数据的传递。
如果技术和业务允许,我们应该尽可能的让首屏渲染所需的资源的文件大小不超过 14kb
。
减少关键路径长度 和 减少请求次数
对于这块的优化,我们可以采取下面相关的措施:
- 首屏样式和 JS 脚本内联 (注,内联的JS脚本需要放在
</body>
前,因为在解析HTML时,碰到<script>
标签就会停止 HTML的解析,直到脚本运行完才会继续解析 HTML) - 合并 JS 文件到一个 JS文件中,并尽可能减小文件大小(TCP只能同时传递 14kb 的数据块,如果大于这个数,TCP协议传递数据实际上是多次往返, 这样就会增加 CRP 长度
减少关键资源大小
对于首屏资源,我们可以按类别做一些优化处理:
- HTML: 使用 html-minifier 工具精简 HTML 内容,去除不必要的空格和换行。另外确保页面 DOM数,确保你的页面没有:
- 没有超过 1500 个 DOM 节点
- DOM节点的嵌套没有超过 32级
- 父节点没有 60 个以上的子节点
- JavaScript:基于 Webpack 这样的构建工具,对其进行 Treeshaking,使用 Webpack 对 JS 进行 Treeshaking依赖 ES2015(ES6)模块系统中的静态结构特性,因此这部分的优化需要对 JS 进行 ES6 改造,除此之外别忘了删除无用JS模块和代码,以及对 JS 进行压缩
- CSS: 使用 PurifyCSS 和 CSSNano 这样的工具对 CSS 进行优化,可以使用 PurifyCSS 删除冗余未用的CSS,使用 CSSNano对 CSS 进行压缩
另外我们还可以根据性能报告,删除不必要的资源。在打包过程中可以使用 Webpack 的 Uglify JS 插件来达到这个目的。另外,最小化JS脚本和CSS也是一个优化点,可以到一些相应方案来解决。另一个有用的方案是启用文本压缩(gzip压缩),现在大部分 CDN 都默认支持 gzip压缩。
这里附上一个计算下载资源的时间公式示意图
64kb
转换成字节单位:65536b
。一个资源包大小为 1460b
,所以得到 64kb
需要 45
个包。
帮助浏览器尽早提供关键资源
并非是所有通过网格发送到浏览器的字节都具有相同程度的重要性,浏览器也很清楚这一点。许多浏览器都清楚他们首先应该获取什么资源,所以有些时候浏览器会在加载脚本和图片之前去加载 CSS样式。
有些有用的东西,我们这些开发者更加清楚,我们可以告诉浏览器对于我们来说真正重要的是什么。针对于这方面的优化,我们可以使用资源提示,比如像:
<link rel="preload" as="script" href="https://a.xxx.com/xxx/PcCommon.js">
<link rel="preconnect" as="script" href="https://a.xxx.com/xxx/TabsPc.js">
<link rel="prefetch" as="script" href="https://a.xxx.com/xxx/TabsPc.js">
这些新增的能力可以帮助浏览器在正确的时间获取正确的东西,并且他们比使用脚本完成的一些自定义加载,基于逻辑的方法更加有效。
其他资源和 CRP 的关系
除了 HTML、CSS 和 JavaScript 之外,页面上还会有其他资源,比如图片,字体等。但图片不是阻塞渲染的资源,它的痛点主要在于质量和资源大小的权衡,以及请求数量带来的性能消耗。字体在网络加载慢的情况下,用户可能会感受到字体或者图形的变化。其实浏览器在渲染树构建完成之后,会指示需要哪些字体在页面上渲染指定文本,然后分派字体请求,浏览器执行布局并将内容绘制到屏幕上,如果字体尚不可用,浏览器可能不会渲染任何文本像素,待字体可用之后,再绘制文本像素,当然,不同浏览器之间实际行为有所差异。(详细参阅 图片和字体优化)。
关键 CSS 优化
渲染引擎在渲染页面的过程中,CSS 和 JS 都会阻塞页面的渲染。在 PageSpeed Insights 和 Lighthouse 等性能检测工具都会有类似下面这样的优化建议:
如果以下资源未下载完成,您的页面上的任何内容都不会被渲染。尝试延迟或异步加载阻塞资源,或直接在 HTML 中内联嵌入这些资源的关键部分!
什么是关键 CSS
对 CSS 文件的请求可以显著增加网页渲染所需的时间。原因是,默认情况之下浏览器将延迟页面渲染,直到它完成加载、解析和执行所有在页面中引用的CSS文件。这样做是因为它需要计算页面的布局。
也就是说,如果页面加载一个很大的 CSS 文件,并且需要一段时间才能完成下载,我们的用户将在浏览器开始渲染页面之前需要等待整个文件被下载下来。而且这个 CSS 文件也将是 CRP 的关键资源,而 CRP 将决定着页面首屏幕渲染的时间(CRP 需要的时间越长,首屏渲染所需时间就越长)。根据这一原则,如果我们找到最小的阻塞 CSS 集合,可以更快的提高首屏渲染的速度。这个最小的阻塞 CSS 集合被称为 关键 CSS,即 关键 CSS 是首屏渲染所需的 CSS 最小集合。
如上图所示,页面中的关键部分只是 用户在首次加载页面时可以看到的内容(首屏内容)。这意味着我们只需要加载首屏所需的 CSS,对于非首屏所需的 CSS,我们可以考虑异步加载。也就是说,整个页面的 CSS 可以分为两个部分:
// critical.css (首屏渲染需要的关键CSS)
.nav {}
// no-critical.css (非首屏渲染所需要的CSS)
.modal {}
对于 第一个文件 critical.css
,我们仅提取首屏渲染所需的最小 CSS 集合,然后将其内联到 HTML 中; 对于第二个文件 no-critical.css
可以异步加载,以免阻塞页面。
<! DOCTYPE html>
<html>
<head>
<style>
/* critical.css */
</style>
<script>
loadCSS('no-critical.css')
</script>
</head>
<body>
</body>
</html>
也就是说,关键 CSS 优化有两个最为关键的步骤:
- 如何区分页面中 关键 CSS 和非关键 CSS (非手动区分)
- 如何让页面在首屏渲染之前加载 关键 CSS,之后加载非关键 CSS
在生产环境中使用 关键 CSS
手动分割页面中关键 CSS 和 非关键 CSS 几乎不切实际,甚至将是一场噩梦。庆幸的是,我们可以自动化来分割。就是使用 Addy Osmani 的 Critical工具:
Critical 工具允许你自动提取和内联关键 CSS 到 HTML 的 Node.js 包。
Critical 识别关键 CSS 方式如下,指定屏幕尺寸并使用 PhantomJS加载页面,提取在渲染页面中用到的所有 CSS 规则。
以下为对项目的设置:
const critical = require("critical");
critical.generate({
/* Webpack打包输出的路径 */
base: path.join(path.resolve(__dirname), 'dist/'),
src: 'index.html',
dest: 'index.html',
inline: true,
extract: true,
/* iPhone6的尺寸,你可以按需要修改 */
width: 375,
height: 565,
/* 确保调用打包后的JS文件 */
penthouse: {
blockJSRequests: false,
}
});
执行时,会将 Webpack 打包输出文件中 HTML 更新为:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Bootstrap Critical</title>
<style type="text/css">
/* 关键CSS通过内部样式表方式引入 */
body {
font-family: Helvetica Neue,Helvetica,Arial,sans-serif;
font-size: 14px;
line-height: 1.42857;
color: #333;
background-color: #fff;
}
...
</style>
<link href="/style.96106fab.css" rel="preload" as="style" onload="this.rel='stylesheet'">
<noscript>
<link href="/style.96106fab.css" rel="stylesheet">
</noscript>
<script>
/*用来加载非关键CSS的脚本*/
</script>
</head>
<body>
<!-- 这里是App的内容 -->
<script type="text/javascript" src="/build_main.js"></script>
</body>
</html>
它还将输出一个新的 CSS 文件,例如 style.96106fab.css
(文件自动 Hash 命名)。这个 CSS 文件与原始样式表相同,只是不包含关键 CSS。
正如上面示例所示,关键 CSS 已经嵌入到 </head>
中。这是最佳的,因为页面不必从服务器加载它。对于非关键的 CSS 使用了<link rel="stylesheet">
来加载。rel="preload"
会通知浏览器开始获取非关键 CSS 以供之后用(preload
不阻塞渲染,无论资源是否加载完成,浏览器都会接着绘制页面)。<link>
标签中的onload
属性允许我们在非关键 CSS 加载完成时运行 JS 脚本。 Critical 模块可以自动将此脚本嵌入到文档中,这种方式提供了将非关键 CSS 加载到页面中的跨浏览器兼容方法:
<link href="/style.96106fab.css" rel="preload" as="style" onload="this.rel='stylesheet'">
另外在 Webpack 构建工程中,可以使用 HTML Critical Webpack Plugin插件,该插件对 Critical 进行了封装。它将在 HTML Webpack Plugin 输出文件后运行。
const HtmlCriticalPlugin = require("html-critical-webpack-plugin");
module.export = {
...
plugins: [
new HtmlWebpackPlugin({ ... }),
new ExtractTextPlugin({ ... }),
new HtmlCriticalPlugin({
base: path.join(path.resolve(__dirname), 'dist/'),
src: 'index.html',
dest: 'index.html',
inline: true,
minify: true,
extract: true,
width: 375,
height: 565,
penthouse: {
blockJSRequests: false,
}
})
]
};
这样做也存有一个缺陷:如果内联了大量的CSS,就会延迟 HTML 文档的其他部分的传输。如果所有的东西都是优先的,那么就没有什么是优先的。内联也有一些缺点,因为它使浏览器无法缓存 CSS,以便在以后的页面加载中重复使用,所以最好少用它。
另外为了最大限度地减少首屏渲染的往返次数(减少 CRP 长度),目标是将首屏内容保持在 14kb
以下(压缩)。
新的 TCP 连接不能立即使用客户端和服务器之间的全部可用带宽,它们都要经过慢速启动,以避免连接中的数据超过它所能承载的负荷。在这个过程中,服务器用少量的数据开始传输,如果在完美的条件下到达客户端,则在下一个往返中加倍传输量。对于大多数服务器来说,10个数据包或大约14kb 是第一次往返中可以传输的最大数量。