CSS 对于呈现页面至关重要 – 在找到,下载和解析所有 CSS 之前,浏览器不会开始呈现 – 因此我们必须尽可能快地将其加载到用户的设备上。关键路径上的任何延迟都会影响我们的“开始渲染”并让用户看到空白屏幕。
什么是大问题?
从广义上讲,这就是 CSS 对性能至关重要的原因:
浏览器在构建渲染树之前无法渲染页面;
渲染树是 DOM 和 CSSOM 的组合结果;
DOM 是 HTML 加上需要对其进行操作的任何阻塞 JavaScript;
CSSOM 是针对 DOM 应用的所有 CSS 规则;
使用 async 和 defer 属性很容易使 JavaScript 无阻塞;
CSS 不容易异步;
所以要记住的一个好的经验法则是,您的页面会在你最慢的样式表加载完成之后才展示。
考虑到这一点,我们需要尽快构建 DOM 和 CSSOM。在大多数情况下,构建 DOM 相对较快:您的第一个 HTML 响应是 DOM。但是,由于 CSS 几乎总是 HTML 的子资源,因此构建 CSSOM 通常需要更长的时间。
在这篇文章中,我想看看 CSS 如何证明是网络上的一个重大瓶颈(本身和其他资源)以及我们如何缓解它,从而缩短关键路径并缩短开始渲染的时间。
使用关键 CSS
如果你有能力,减少 Start Render 时间的最有效方法之一就是使用 Critical CSS 模式:识别 Start Render 所需的所有样式(通常是首屏所需的样式),将它们内联到文档的 <head> 中的 <style> 标记中,并从这里异步加载剩余的样式表。
虽然这种策略是有效的,但并不简单:高度动态的网站很难从中提取样式,流程需要自动化,我们必须对折叠率甚至是什么做出假设,很难捕获边缘情况和工具 仍处于相对初期阶段。如果您正在使用大型或遗留代码库,事情会变得更加困难 ……
拆分媒体类型
如果实现关键 CSS 非常棘手 – 它可能只是一种选择,我们将主要的 CSS 文件拆分为其各自的媒体查询。这样做的实际结果是浏览器会 ……
以非常高的优先级下载当前上下文所需的任何 CSS(中等,屏幕大小,分辨率,方向等),阻止关键路径;
以非常低的优先级下载当前上下文不需要的任何 CSS,完全脱离关键路径。
基本上,浏览器有效地延迟了不需要渲染当前视图的任何 CSS。
<link rel=”stylesheet” href=”all.css” />
如果我们将所有 CSS 捆绑到一个文件中,那么它会这样子加载:
如果我们可以将单个全渲染阻塞文件拆分为各自的媒体查询:
<link rel=”stylesheet” href=”all.css” media=”all” />
<link rel=”stylesheet” href=”small.css” media=”(min-width: 20em)” />
<link rel=”stylesheet” href=”medium.css” media=”(min-width: 64em)” />
<link rel=”stylesheet” href=”large.css” media=”(min-width: 90em)” />
<link rel=”stylesheet” href=”extra-large.css” media=”(min-width: 120em)” />
<link rel=”stylesheet” href=”print.css” media=”print” />
然后我们看到网络以不同方式处理文件:
浏览器仍将下载所有 CSS 文件,但它只会阻止渲染完成当前上下文所需的文件。
避免在 CSS 文件中使用 @import
我们可以做的下一件事就是帮助 Start Render 更加简单。避免在 CSS 文件中使用 @import。
@import,根据它的工作原理,很慢。对于 Start Render 性能来说真的非常糟糕。这是因为我们正在关键路径上积极创建更多循环路径:
下载 HTML;
HTML 请求 CSS;(这是我们希望能够构建渲染树的地方,但是;)
CSS 请求更多 CSS;
构建渲染树。
以下 HTML:
<link rel=”stylesheet” href=”all.css” />
包含在 all.css 中 @import
@import url(imported.css);
我们最终得到这样的瀑布图:
通过简单地将其展平为两个 <link rel =“stylesheet”/> 和去掉 @imports:
<link rel=”stylesheet” href=”all.css” />
<link rel=”stylesheet” href=”imported.css” />
我们得到一个更健康的瀑布图:
请注意 HTML 中的 @import
要完全理解本节,我们首先需要了解浏览器的预装载扫描程序:所有主流浏览器都实现了通常称为预装载扫描程序的辅助惰性解析器。浏览器的主要解析器负责构建 DOM,CSSOM,运行 JavaScript 等,并且随着文档的不同部分阻止它而不断停止和启动。Preload Scanner 可以安全地跳过主解析器并扫描 HTML 的其余部分,以发现对其他子资源(例如 CSS 文件,JS,图像)的引用。一旦发现它们,Preload Scanner 就会开始下载它们,以便主要解析器接收它们并在以后执行 / 应用它们。Preload Scanner 的推出使网页性能提高了大约 19%,所有这些都不需要开发人员参与。这对用户来说是个好消息!
我们作为开发人员需要警惕的一件事是无意中隐藏了 Preload Scanner 中可能发生的事情。稍后会详细介绍。
本节介绍 WebKit 和 Blink 的 Preload Scanner 中的错误,以及 Firefox 和 IE / Edge 的 Preload Scanner 中的低效率。
Firefox 和 IE / Edge:将 @import 放在 HTML 中的 JS 和 CSS 之前
在 Firefox 和 IE / Edge 中,Preload Scanner 似乎没有使用 <script src =“”> 或 <link rel =“stylesheet”/> 之后定义的任何 @import。
这意味着这个 HTML:
<script src=”app.js”></script>
<style>
@import url(app.css);
</style>
将产生这个瀑布图:
由于无效预装载扫描程序导致 Firefox 失去并行化(N.B. 在 IE / Edge 中出现相同的瀑布。)
这个问题的直接解决方案是交换 <script> 或 <link rel =“stylesheet”/> 和 <style> 块。但是,当我们更改依赖顺序时,这可能会破坏事物(想想他们之间的关联)。
这个问题的首选解决方案是完全避免使用 @import 并使用第二个 <link rel =“stylesheet”/>:
<link rel=”stylesheet” href=”style.css” />
<link rel=”stylesheet” href=”app.css” />
瀑布图如下:
两个 <link rel =“stylesheet”/> s 让我们回到并行化。(N.B. IE / Edge 中出现相同的瀑布。)
Blink 和 WebKit:用 HTML 格式引用 @import URL
仅当您的 @import URL 缺少引号(“)时,WebKit 和 Blink 的行为与 Firefox 和 IE / Edge 完全相同。这意味着 WebKit 和 Blink 中的 Preload Scanner 存在错误。
简单地将 @import 包装在引号中将解决问题,您无需重新排序任何内容。不过,和以前一样,我的建议是完全避免使用 @import,而是选择第二个 <link rel =“stylesheet”/>。
之前
<link rel=”stylesheet” href=”style.css” />
<style>
@import url(app.css);
</style>
瀑布图:
我们的 @ import 网址中缺少引号会破坏 Chrome 的预装扫描程序(N.B. 在 Opera 和 Safari 中会出现相同的瀑布。)
修改之后:
<link rel=”stylesheet” href=”style.css” />
<style>
@import url(“app.css”);
</style>
在我们的 @ import 网址中添加引号可修复 Chrome 的 Preload Scanner(N.B. 在 Opera 和 Safari 中也会出现相同的瀑布。)
这绝对是 WebKit / Blink 中的一个错误 – 缺少引号不应该隐藏 Preload Scanner 中的 @imported 样式表。
不要在 Async 脚本之前放置 <link rel =“stylesheet”/>
上一节讨论了如何通过其他资源减慢 CSS,本节将讨论 CSS 如何无意中延迟下载资源的下载,主要是使用异步加载代码段插入的 JavaScript,如下所示:
<script>
var script = document.createElement(‘script’);
script.src = “analytics.js”;
document.getElementsByTagName(‘head’)[0].appendChild(script);
</script>
在所有浏览器中都存在一种有意和预期的迷人行为,但我从未遇到过一个了解它的开发人员。当您考虑它可以带来的巨大性能影响时,这是非常令人惊讶的:
如果有任何当前 CSS 在加载,浏览器将不会执行 <script>。
<link rel=”stylesheet” href=”slow-loading-stylesheet.css” />
<script>
console.log(“I will not run until slow-loading-stylesheet.css is downloaded.”);
</script>
这是设计的。这是故意的。当前正在下载任何 CSS 时,HTML 中的任何同步 <script> 都不会执行。这是一个简单的防御策略来解决 <script> 可能会询问页面样式的边缘情况:如果脚本在 CSS 到达并被解析之前询问页面的颜色,那么 JavaScript 给我们的答案 可能是不正确或陈旧的。为了缓解这种情况,浏览器在构造 CSSOM 之前不会执行 <script>。
这样做的结果是,CSS 下载时间的任何延迟都会对你的异步片段产生连锁反应。用一个例子可以很好地说明这一点。
如果我们在异步片段前放置 <link rel =“stylesheet”/>,则在下载和解析该 CSS 文件之前它不会运行。
<link rel=”stylesheet” href=”app.css” />
<script>
var script = document.createElement(‘script’);
script.src = “analytics.js”;
document.getElementsByTagName(‘head’)[0].appendChild(script);
</script>
根据这个顺序,我们可以清楚地看到 JavaScript 文件甚至在构建 CSSOM 之前甚至没有开始下载。我们完全失去了任何并行化:
在异步代码段之前使用样式表可以撤消我们并行化的机会。有趣的是,Preload Scanner 希望提前获得对 analytics.js 的引用,但是我们无意中隐藏了它:“analytics.js”是一个字符串,并且在 << 之前不会成为可标记的 src 属性 script> 元素存在于 DOM 中。这是我早些时候说的,当我稍后再说这个时。
第三方供应商提供这样的异步代码片段以更安全地加载脚本是很常见的。开发人员对这些第三方持怀疑态度,并在页面后面放置异步片段也是很常见的。虽然这是出于最好的意图 – 我不想在我自己的资产之前放置第三方 <script>!– 通常可能是净损失。事实上,谷歌分析甚至告诉我们该做什么,他们是对的:
将此代码作为第一项复制并粘贴到您要跟踪的每个网页的 <HEAD> 中。
所以我的建议是:
如果您的 <script> … </ script> 块不依赖于 CSS,请将它们放在样式表上方。
以下是我们转向此模式时会发生的代码:
<script>
var script = document.createElement(‘script’);
script.src = “analytics.js”;
document.getElementsByTagName(‘head’)[0].appendChild(script);
</script>
<link rel=”stylesheet” href=”app.css” />
交换样式表和异步代码片段可以重新获得并行化。
现在您可以看到我们已经完全重新获得了并行化,并且页面加载速度提高了近 2 倍。
在 CSS 之前放置任何非 CSSOM 查询 JavaScript; 在 CSS 之后放置任何 CSSOM 查询 JavaScript
更进一步,除了异步加载片段之外,我们应该如何更普适地加载 CSS 和 JavaScript?为了解决这个问题,我提出了以下问题并从那里开始工作:
如果:
在 CSSOM 构造上阻止 CSS 后定义的同步 JS;
同步 JS 阻止 DOM 构造
那么 – 假设没有相互依赖 – 哪个更快 / 更喜欢?
Script -> style;style -> script?
答案是:
如果文件不相互依赖,那么您应该将阻塞脚本置于阻塞样式之上 – 没有必要将 JavaScript 执行延迟到 JavaScript 实际上不依赖的 CSS。
(Preload Scanner 确保即使在脚本上阻止了 DOM 构造,CSS 仍然会并行下载。)
如果你的一些 JavaScript 做了但有些不依赖于 CSS,那么加载同步 JavaScript 和 CSS 的绝对最佳顺序是将 JavaScript 分成两部分并将其加载到 CSS 的任何一侧:
<!– This JavaScript executes as soon as it has arrived. –>
<script src=”i-need-to-block-dom-but-DONT-need-to-query-cssom.js”></script>
<link rel=”stylesheet” href=”app.css” />
<!– This JavaScript executes as soon as the CSSOM is built. –>
<script src=”i-need-to-block-dom-but-DO-need-to-query-cssom.js”></script>
使用这种加载模式,我们可以按最佳顺序进行下载和执行。我为下面的截图中的微小细节道歉,但希望你能看到代表 JavaScript 执行的小粉红色标记。entry(1)是计划在其他文件到达和 / 或执行时执行某些 JavaScript 的 HTML; entry(2)执行它到达的那一刻; entry(3)是 CSS,所以不执行任何 JavaScript; 在 CSS 完成之前,entry(4)实际上不会执行。
注:您必须根据自己的特定用例测试此模式:根据您之前的 CSS JavaScript 文件与 CSS 本身之间的文件大小和执行成本是否存在巨大差异,可能会有不同的结果。测试,测试,测试。
将 <link rel =“stylesheet”/> 放在 <body> 中
这个最终策略是一个相对较新的策略,对感知性能和渐进式渲染有很大好处。它也非常友好。
在 HTTP / 1.1 中,我们将所有样式连接到一个主要包中是很典型的。我们称之为 app.css:
<!DOCTYPE html>
<html>
<head>
<link rel=”stylesheet” href=”app.css” />
</head>
<body>
<header class=”site-header”>
<nav class=”site-nav”>…</nav>
</header>
<main class=”content”>
<section class=”content-primary”>
<h1>…</h1>
<div class=”date-picker”>…</div>
</section>
<aside class=”content-secondary”>
<div class=”ads”>…</div>
</aside>
</main>
<footer class=”site-footer”>
</footer>
</body>
这带来三个关键的低效率:
任何给定的页面只会使用 app.css 中的一小部分样式:我们几乎肯定会下载比我们需要的更多的 CSS。
我们受限于一种效率低下的缓存策略:例如,对仅在一个页面上使用的日期选择器上当前所选日期的背景颜色进行更改将需要我们缓存整个 app.css。
整个 app.css 阻止渲染:如果当前页面只需要 17%的 app.css 并不重要,我们仍然需要等待其他 83%才能开始渲染。
使用 HTTP / 2,我们可以开始解决点(1)和(2):
<!DOCTYPE html>
<html>
<head>
<link rel=”stylesheet” href=”core.css” />
<link rel=”stylesheet” href=”site-header.css” />
<link rel=”stylesheet” href=”site-nav.css” />
<link rel=”stylesheet” href=”content.css” />
<link rel=”stylesheet” href=”content-primary.css” />
<link rel=”stylesheet” href=”date-picker.css” />
<link rel=”stylesheet” href=”content-secondary.css” />
<link rel=”stylesheet” href=”ads.css” />
<link rel=”stylesheet” href=”site-footer.css” />
</head>
<body>
<header class=”site-header”>
<nav class=”site-nav”>…</nav>
</header>
<main class=”content”>
<section class=”content-primary”>
<h1>…</h1>
<div class=”date-picker”>…</div>
</section>
<aside class=”content-secondary”>
<div class=”ads”>…</div>
</aside>
</main>
<footer class=”site-footer”>
</footer>
</body>
现在我们正在解决冗余问题,因为我们能够加载更适合页面的 CSS,而不是不加选择地下载所有内容。这减少了关键路径上阻塞 CSS 的大小。
我们还可以采用更有意思的缓存策略,只缓存破坏需要它的文件,并保持其余部分不受影响。
我们还没有解决的问题是它仍然阻止渲染 – 我们仍然只有最慢的样式表。这意味着如果无论出于何种原因,site-footer.css 需要很长时间才能下载,浏览器无法开始渲染.site-header。
但是,由于 Chrome 最近发生了变化(我相信版本 69),以及 Firefox 和 IE / Edge 中已经存在的行为,<link rel =“stylesheet”/> 只会阻止后续内容的呈现,而不是 整页。这意味着我们现在能够像这样构建我们的页面:
<!DOCTYPE html>
<html>
<head>
<link rel=”stylesheet” href=”core.css” />
</head>
<body>
<link rel=”stylesheet” href=”site-header.css” />
<header class=”site-header”>
<link rel=”stylesheet” href=”site-nav.css” />
<nav class=”site-nav”>…</nav>
</header>
<link rel=”stylesheet” href=”content.css” />
<main class=”content”>
<link rel=”stylesheet” href=”content-primary.css” />
<section class=”content-primary”>
<h1>…</h1>
<link rel=”stylesheet” href=”date-picker.css” />
<div class=”date-picker”>…</div>
</section>
<link rel=”stylesheet” href=”content-secondary.css” />
<aside class=”content-secondary”>
<link rel=”stylesheet” href=”ads.css” />
<div class=”ads”>…</div>
</aside>
</main>
<link rel=”stylesheet” href=”site-footer.css” />
<footer class=”site-footer”>
</footer>
</body>
这样做的实际结果是,我们现在能够逐步呈现我们的页面,在页面可用时有效地将页面输送样式添加到页面中。
在目前不支持这种新行为的浏览器中,我们不会遇到性能下降:我们会回到原来的行为,我们只有最慢的 CSS 文件加载完成才会展示页面。
总结
本文中有很多要消化的内容。它最终超越了我最初打算写的帖子。尝试总结加载 CSS 的最佳网络性能实践:
Lazyload Start Start Render 不需要的任何 CSS:
拆分关键 CSS;
或将您的 CSS 拆分为媒体查询。
避免 @import:
在你的 HTML 中;
特别是在 CSS 中;
并提防 Preload Scanner 的奇怪之处。
警惕同步 CSS 和 JavaScript 命令:
在 CSSOM 完成之前,CSS 之后定义的 JavaScript 将无法运行
所以如果你的 JavaScript 不依赖于你的 CSS,在 CSS 之前加载它;
如果它取决于你的 CSS,在 CSS 之后加载它。
在 DOM 需要时加载 CSS,这将取消阻止“开始渲染”并允许渐进式渲染
我上面概述的所有内容都遵循规范或已知 / 预期的行为,但是,一如既往,自己测试一切。虽然这在理论上都是正确的,但在实践中事情总是有所不同。套用中国的一句老话,实践出真知啊。