前端开发者学堂 - fedev.cn

消除阻塞页面渲染的资源

发布于 大漠

在 Web 页面渲染过程中,有些资源文件(比如 HTML、CSS 和 JavaScript)会阻塞页面的渲染。当浏览器遇到渲染阻塞资源时,它就会停止下载其余的资源,直到这些关键文件得到处理(关键资源)。在此期间,整个渲染过程被搁置。另一方面,非渲染阻塞资源并不会延迟页面的渲染。浏览器可以在最初的页面渲染之后在后台安全地下载它们。

然则,并不是所有被浏览器视为阻塞渲染的资源都是首屏渲染所必需的;这完全取决于页面的特征。你可以使用一些技术手段(最佳实践),将这些非关键的渲染阻塞资源变成非渲染的阻塞资源。此外,你也可以减少那些仍然关键的,无法消除的阻塞渲染的资源数量或大小。了解阻塞渲染资源将使你能够解决常见的 Web 性能问题。

什么是关键的渲染路径

浏览器通过关键的渲染路径(CRP)将 HTML、CSS 和 JavaScript 这些文件转换为用户所看到的页面。会经历:

  • 下载HTML
  • 读取 HTML,并同时
    • 构建 DOM 树
    • 遇到 <link rel="stylesheet"> 标签,并下载 CSS
  • 读取 CSS,并构建 CSSOM
  • 将 DOM 和 CSSOM 合并为一个渲染树
  • 使用渲染树,计算布局(每个元素的大小和位置)
  • 绘制,或将像素渲染到页面

这个过程越短,关键渲染时间就越短,页面首屏幕渲染就越短。浏览器在完成这些步骤之前,用户将看到一个空白的白色页面。

什么是阻塞渲染的资源

阻碍渲染的资源是指在 关键渲染路径(CRP)上被“按下暂停”的文件(该文件也被称为关键资源)。它们打断了一个或多个步骤(关键渲染路径中的步骤)。

从技术上,HTML 是阻断渲染的,因为你需要它来构建 DOM 树。如果没有 HTML,我们甚至不会一个可渲染的页面。然而,HTML通常不是我们问题的根源:

  • CSS 是阻塞渲染的:浏览器在创建 CSSOM 之前需要它,而 CSSOM 会阻止所有的后续步骤。一旦浏览器遇到 CSS 样式表<link rel="stylesheet"><style> 标签,它就必须下载并解析其内容。然后,它必须在继续进行其余的渲染之前创建 CSSOM。在 CSSOM 和 DOM 都被创建之前,渲染树不能继续。
  • JavaScript 可能会阻塞渲染:当浏览器遇到一个要同步运行的 JavaScript 脚本时,它会停止 DOM 的创建,直到该脚本运行完毕。即 同步的 JavaScript(没有asyncdefer)在下载和执行 JavaScript 的过程中都会阻塞 HTML 解析器。

此外,如果 CSS 出现在 JavaScript 脚本之前,那么在 CSSOM 被创建之前,JavaScript 将不会被执行。这是因为 JavaScript 有可能操作 DOM 或 CSSOM。

注意,图片和字体并不阻塞页面的渲染。它们可能是由于渲染被阻塞或其他性能问题而导致渲染缓慢。

阻塞渲染资源:CSS

CSS 加载不会阻塞 DOM 树的解析

由浏览器渲染引擎解析 Web 页面的过程中可以获知:DOM解析和CSS解析是两个并行的进程,因此 CSS 加载不会阻塞 DOM 树的解析!

CSS 加载会阻塞 DOM 树的渲染

渲染树是依赖于 DOM 树和 CSSOM 树的,所以无论 DOM 树是否已经完成,它都必须等到 CSSOM 构建完成,即 CSS 加载完成(或失败)后,才能开始渲染。因此, CSS 加载是会阻塞 DOM 树的渲染。

<head>
    <script>
        document.addEventListener('DOMContentLoaded', () => {
            var p = document.querySelector('p')
            console.log(p)
        })
    </script>
    <link rel="stylesheet" href="./static/style.css?sleep=3000">
</head>

<body>
    <p>hello world</p>
</body>

CSS 的加载并没有 阻塞 DOM 树的解析,<p> 标签是正常解析的,但 <p> 标签加载完后,页面是迟迟没有渲染的,是因为 CSS 还没有请求完成,在 CSS 请求完成后,“hello world” 文本才被渲染出来,所以 CSS 会阻塞页面渲染。

CSS 加载会阻塞其后的 JS 执行

JS 脚本的加载、解析与执行会阻塞 DOM 的构建,也就是说,在构建 DOM 树时,HTML 解析器若遇到了 JS 脚本,那么HTML解析器会暂停 DOM 的构建,将控制权移交给 JS 引擎,等 JS 引擎运行完毕后,浏览器再从中断的地方恢复 HTML 的解析,继续构建 DOM树。

<html>
    <head>    
        <link href="theme.css" rel="stylesheet">
    </head>
    <body>    
        <div>hello world</div>    
        <script>        
            console.log('hello world')    
        </script>    
        <div>hello world</div>
    </body>
</html>

它的执行流程:

此时 CSS 也阻塞 DOM 树的生成

这是因为 JS 脚本不只是可以改变 DOM,它还可以更改 CSSOM(更改样式)。而不完整的 CSSOM 是无法使用的, JS 中想访问 CSSOM 并是更改它,那么在执行 JS 时,必须要能拿到完整的 CSSOM。

这就会导致一个现象,如果浏览器尚未完成 CSSOM 的下载和构建,而我们却想在此运行 JS 脚本,那么浏览器将延迟 JS 脚本执行和 DOM 构建,直至其完成 CSSOM 的下载和构建。也就是说,在这种情况下,浏览器会先下载和构建 CSSOM,然后执行 JS 脚本,最后在继续构建 DOM。

阻塞渲染资源: JavaScript

默认情况下,JS 脚本会阻止 DOM、其后 CSSOM的构建,也就延迟了页面首次渲染

<head>
    <script>
        document.addEventListener('DOMContentLoaded', () => {
            var p = document.querySelector('p')
            console.log(p)
        })
    </script>
    <link rel="stylesheet" href="./static/style.css?sleep=3000">
    <script src="./static/index.js"></script>
</head>

<body>
    <p>hello world</p>
</body>

HTML 文件中引用了外部资源 CSS 和 JS 文件,HTML会同时发起这两个文件的下载请求,不管 CSS 文件还是 JS 文件谁先到达,都要先等到 CSS 文件下载完成并生成 CSSOM,然后再执行 JS 脚本,最后再继续构建 DOM,构建布局树,绘制页面。

不论是内联还是外链 JS 都会阻塞后续 DOM 解析,DOMContentLoaded 事件会被延迟,后续的 DOM 渲染也会被阻塞。这意味着,JS 脚本的执行过程中插入的元素会先于后续的 HTML 展现,即使JS 脚本是外链资源也是如此。由于 JS 脚本只会阻塞后续的 DOM,前面的 DOM 在解析完成后会被立即渲染给用户。

为什么阻塞渲染资源对性能影响很大

阻塞渲染资源会使绘制速度变慢,导致最大内容的绘制(LCP)变慢,也会致使首屏渲染变慢。

据相关研究描述,一个网站如果加载速度的时间超过 2s ,就会以指数级的速度失去访问者。例如,加载时间为 2s 的网站有 6% 的跳失率。这意味着每 100 人访问你的网站,就会失去其中 6 人(超过 2s 让用户会感到不耐烦,不愿意花时间等)。如果网站加载时间在 3s 左右,跳失率会攀升至 11% 。而到了 5s 的时候,跳出率达到了 38% 。这意味着有三分之的用户因为网站加载慢而流失。因此,消除阻塞渲染资源,提高网站性能就变得非常的重要。但消除阻塞渲染资源只是为了提高网站性能的一部分,还有其他很多事情需要做。然而,消除阻塞渲染资源是一个比较容易的,通过一些小的调整就可以迅速解决。

如何测试网站是否存在阻塞渲染的资源

我们的网站上都有阻塞渲染的资源。如果这些阻塞渲染资源过多时就会严重影响 Web 性能。当这种情况发生时,可以使用一些工具来帮我检测出来,比如 LighthousePageSpeed Insights等工具。

当使用 Lighthouse 测试一个页面时,如果页面存有 阻塞渲染资源 存在,那么在检测报告的“机会(Opportunities)”选项中会列出所有阻塞渲染资源的 URL:

Lighthouse 会标记出两种阻塞渲染资源的类型,即 CSS 文件 和 JS 脚本文件的 URL。

<script> 标签:

  • 放在 </head> 标签中的 <script> 标签
  • 没有设置 asyncdefermodule 属性的 <script> 标签

<link rel="stylesheet"> 标签:

  • 没有设置 disabled 属性(当这个属性存在时,浏览器不会下载样式表)
  • 不匹配的媒体查询

聚划算首页为例,其 <head> 中有六个 <script> 标签:

  • 三个引入外部 JS 的 <script> ,并且其中一个设置了 async 、一个设置了 defer 属性,另一个既没有设置 asyncdefer 属性,也没有设置 module 属性
  • 三个内联的 JS 的 <script>
  • 三个内联的 CSS 的 <style>
  • 没有外联 CSS和 <link>

如果使用 WebPageTest 测试的话,它将在一个真实的移动设备上运行性能测试。一旦测试完成,点击测试结果中的瀑布图。每个阻塞渲染资源都会有一个橙色关闭按钮图标的标记:

如何消除阻塞渲染的资源

我们的目标不是要消除所有的阻塞渲染的资源,而是要降低它们对性能的影响。接下来我们分别从 CSS 和 JS 两部分看如何消除阻塞渲染的资源。

如何消除阻塞渲染的 CSS

浏览器渲染引擎在渲染一个 Web 页面时, DOM的解析成本并不高(DOM操作成本非常昂贵),但 CSSOM 的解析是很昂贵的。而 CSS 本身就是 阻塞渲染的一种资源,在能够请求,接收和处理所有 CSS 样式之前,浏览器不会开始渲染任何页面内容,即阻塞了页面的渲染。如果我们希望 CSS 不被成为一种阻塞页面渲染的资源,就需要通过一些手段来对其进行优化或者规避。

使用关键 CSS

缩短首屏渲染时间最有效的方法之一是使用 “关键CSS”,它会根据分配的优先级,对页面引入的 CSS 根据页面首屏和非首屏进行分割,即将页面CSS分割成关键CSS 和非关键 CSS。然后将关键 CSS 以 <style> 方式内联到 <head> 内,并且异步加载非关键 CSS。

上图所示,折叠之上是首屏加载的内容(浏览器滚动之前在页面加载时看到的所有内容)。

虽然这个策略是有效的,但由于存在大量的设备和屏幕尺寸,什么是折叠上方的内容并没有一个相对标准的定义。除了设备终端的多样式(不同设备屏幕尺寸都不一样)之外,现代Web页面的内容也是高度动态的,这样一来更是加大了页面关键 CSS 和非关键 CSS 的分割。换句话说,我们需要自动化工具来对页面进行分析,帮助我们分割出关键 CSS 和 非关键CSS,并且将关键CSS以 <style> (内联CSS)方式自动嵌入到 <head> ,而且是结过压缩的 CSS,同时将非关键CSS 以异步的方式来加载。

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width,initial-scale=1">
        <title>Bootstrap Critical</title>
        <!-- 关键 CSS 内联方式引入 -->
        <style type="text/css">
            /* 尽可能将压缩之后的关键 CSS 放在这里 */
        </style>
        <!-- 非关键 CSS 异步加载 -->
        <link href="/no-critical.css" rel="preload" as="style" onload="this.rel='stylesheet'">
    </head>
    <body>
        <!-- 这里是App的内容 -->
        <script type="text/javascript" src="/build_main.js"></script>
    </body>
</html>

这里有几个小细节尽可能的做到:

  • 关键 CSS 压缩之后内联放到 HTML,非关键CSS 异步加载
  • 内联的 关键CSS 的 <style> 标签放在非关键 CSS 异步加载的 <link> 标签之前
  • 关键CSS的 <style> 尽可能的在 <head> 标签中内往前放置
  • 关键 CSS 和 非关键CSS 尽可能的放置在 <script> 标签之前

CSS 的异步加载相对而言是较为复杂的,后面我们会专门来看 CSS 的异步加载

其实这些工作都可以交给自动化工具来帮助我处理。目前处理关键CSS较好的自动化工具主要有:

  • Critical:该工具可以提取、最小化和内联 关键CSS,并且可以作为 NPM 模块使用,也可以和 Gulp(直接)或 Grunt(作为一个插件)一起使用,也可以在 Webpack 构建工程中使用 HTML Critical Webpack 插件。使用 Critical 不需要指定样式表,Critical 会自动检测,而且还支持多个屏幕分辨率关键 CSS的提取
  • criticalCSS:是另一个 NPM 模块,也可以作为一个 CLI 使用,可以提取首屏的所需的关键 CSS,但它不能将提取出来的关键 CSS 进行压缩和内联到 <head> 标签中。不过它可以让你强制包括那些实际上不属于关键 CSS 的规则,并让你对包括 @font-face 声明进行更精细的控制
  • Penthouse:如果你的网站或应用有大量的样式或被动态注入 DOM的样式,那么 Penthouse 是一个更好的选择。它在引擎下使用了 Puppeteer,甚至还有一个在线版本。Penthouse 不会自动检测样式表,因此你必须指定你想要生成关键 CSS的 HTML 和 CSS 文件。

有关于关键 CSS 更多的介绍,可以阅读 关键渲染路径(CRP)中的关键 CSS 优化

使用媒体类型分割 CSS

CSS 可以使用媒体查询将样式应用在特定条件下。媒体查询对于响应式 Web 设计非常重要,可以帮助我们优化关键渲染路径。浏览器会阻塞渲染,直到它解析完全部的样式,但不会阻塞渲染它认为不会使用的样式,例如打印样式表。通过基于媒体查询将 CSS 分成多个文件,可以防止在下载未使用的 CSS 期间阻塞渲染。为了创建非阻塞 CSS 链接,将不会立即使用的样式移到单独的文件中,将 <link> 添加到 HTML,并添加媒体查询属性:

<link rel="stylesheet" href="styles.css"> <!-- 阻塞 -->
<link rel="stylesheet" href="print.css" media="print"> <!-- 非阻塞 -->
<link rel="stylesheet" href="mobile.css" media="screen and (max-width: 480px)"> <!-- 在大屏幕上非阻塞 -->

默认情况下,浏览器假设每个指定的样式表都是阻塞渲染的。通过 <link> 上的 media 属性附加媒体查询,告诉浏览器何时应用样式表。当浏览器看到一个它知道只会用于特定场景的样式表时,它仍然会下载样式,但不会阻塞渲染。通过将 CSS 分成多个文件,主要的阻塞文件(如上面示例中的 styles.css)的大小变得更小,从而减少了渲染被阻塞的时间。

通过使用媒体查询,我们可以根据特定场景(比如显式或打印),也可以根据动态情况(比如屏幕方向变化、尺寸调整事件等)定制外观。 声明你的样式表时,请密切注意媒体类型和媒体查询,因为它们将严重影响关键渲染路径的性能

如果你觉得在项目中 “关键CSS” 实施要当棘手,那么使用媒体查询和媒体类型来分割主CSS将是另一个选择。这样做,浏览器会将:

  • 以很高的优先级下载当前环境(媒体类型、屏幕尺寸、分辨率、方向等)所需的 CSS,从而减少关键路径渲染的时间
  • 以很低的优先级下载当前环境不需要的任何 CSS,但不会阻塞页面渲染,完全脱离“关键路径渲染”

基本上,任何不需要用来渲染当前视图的 CSS 都被浏览器有效的懒加载了。

<link rel="stylesheet" href="all.css" />

如果我们把所有 CSS 都放在一个文件中(比如 all.css),那么网络就会像下面这样处理:

如果我们根据媒体类型或媒体查询等相关条件,把这个单一的 all.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 文件,但当前上下文不需要的 CSS 文件被分配了一个最低的优先级。同时只有环境相匹配的 CSS 才会阻塞页面的渲染。

我们平时写代码的时候,CSS 媒体查询相关的规则都会在同一个 CSS 文件中书写,不怎么会人肉去去根据不同的媒体类型或条件把 CSS 样式拆分在不同文件中。其实这部分工作,我们可以交给 PostCSS 插件去做,PostCSS 的 postccss-extract-media-query 插件会提取所有的 @media 规则并将它们保存为单独的文件。比如,在一个 style.css 中编写 CSS:

/*style.css*/
.foo { 
    color: red 
}

@media screen and (min-width: 1024px) {
    .foo { 
        color: green 
    }
}

.bar { 
    font-size: 1rem 
}

@media screen and (min-width: 1024px) {
    .bar { 
        font-size: 2rem 
    }
}

PostCSS 的 postccss-extract-media-query 插件会将 style.css 拆分为 style.cssstyle-desktop.css

/* style.css */
.foo { 
    color: red 
}

.bar { 
    font-size: 1rem 
}

/* style-desktop.css */

@media screen and (min-width: 1024px) {
    .foo { 
        color: green 
    }
    
    .bar { 
        font-size: 2rem 
    }
}

这种性能优化技术也被称为代码拆分。虽然代码拆分通常是用于 JavaScript 的优化中,但你也可以为较大的 CSS 样式文件进行拆分,只在需要时加载每个文件,以缩短关键渲染路径的时间,减少初始页面加载时间。这样做虽然可以减少阻塞渲染的 CSS,但会增加 HTPP 的请求。

在 CSS 中避免 @import

尽管 @import 规则可以让 HTML 变得更简洁,也允许你把所有的 CSS 依赖关系放在同一个地方,但从性能上来说,它不是一个好的选择。@import 规则可以让在样式表中引入其他 CSS样式文件,但从其工作方式来看,会导致浏览器处理你的 CSS 文件速度变慢,对于关键渲染路径或者说首屏渲染来说是非常不利的。因为他会在关键渲染路径上增加更多的往返(即关键路径的深度变长):

  • 下载 HTML
  • HTML 请求 CSS
    • 这里我们希望能够构建渲染树的地方,但是发现 CSS 文件中引入新的 CSS 文件(@import 方式导入别的 CSS 文件)
  • CSS 请求更多的 CSS
  • 构建渲染树

比如下面示例。在 HTML 中 使用 <link> 引入了一个 all.css 文件:

<link rel="stylesheet" href="all.css" />

而且 all.css 样式文件中使用 @import 引入了一个 imported.css 文件:

/* all.css */
@import url(imported.css);

网格请求的瀑布图如下:

从止面的瀑布图中不难发现,all.cssimported.css 并不是并行加载,imported.css 要等 all.css 加载完之后才会开始发送请求,等待服务器响应,然后再下载。这明显的增加了 关键路径渲染的长度。

如果我们把 all.css 中的 imported.css 都改成 <link> 标签的引用(在CSS文件中不出现任何 @import 规则):

<link rel="stylesheet" href="all.css" />
<link rel="stylesheet" href="imported.css" />

网络的瀑布图变成:

注意,接下来是 @import 的个案,即 无法删除 CSS 中 @import 引入的 CSS 文件(即,你无法操作CSS文件,并且该文件中用了 @import 规则导入了别的 CSS 文件 )。在这种情况之下,并不强制删除 CSS 中的 @import (比如上面示例中 all.css 里的 @import url(imported.css) 不需要删除),可以安全的保留它,只不过同时在 HTML 中用 <link> 再次引入 imported.css 文件。这样做,浏览器会从 HTML 中下载 imported.css ,并且会跳过 all.css 文件中的 @import 引入的 imported.css ,即不会产生任何重复下载。

小心 HTML 中的 @import

这里将会涉及 WebkitBlink的 预加载扫描器(Preload Scanner 中的错误)以及 Firefox 和 Edge的预加载扫描器(Preload Scanner)中的低效率。详细可参考 Chromium 的修复

要完全理解这部分内容,需要对浏览器预加载扫描器(Preload Scanner)程序有所了解:所有主流浏览器都实现了通常称为预加载扫描器的辅助惰性解析器。浏览器的主要解析器负责构建构建 DOM, CSSOM,运行 JavaScript 等,并且随着文档的不同部分阻止它而不断的停止和启动。预加载扫描器可以安全地跳过主解析器并扫描 HTML 的其余部分,以发现对其他子资源(例如 CSS、JS和图像文件等)的引用。一旦发现它们,预加载扫描器就会开始下载它们,以便主要解析器接收它们,并在以后执行(或应用)它们。预加载扫描器的推出使网页性能提高了大约 19% ,所有这些都不需要开发人员参与。

作为开发人员需要警惕的是无意中隐藏了预加载扫描器中可能发生的事情。

Firefox 和 Edge 将 @import 放在 HTML 中的 JS 和 CSS 之前

在 Firefox 和 Edge 中,预加载扫描器似乎没有获取在 <script><link rel="stylesheet"> 之后定义的任何 @import。比如:

<script src="app.js"></script>

<style>
    @import url(app.css);
</style>

网格请求瀑布流图如下:

在这里可以看到 @import 的样式表在 JavaScript 文件完成下载之前不会开始下载。这个问题也不是 JavaScript 独有的,比如:

<link rel="stylesheet" href="style.css" />

<style>
    @import url(app.css);
</style>

由于没有有效的预加载扫描器,Firefox 失去了并行性(Edge中也类似)。这个问题直接的解决方案是 交换 <script><link rel="stylesheet"><style> 的位置。但直接调整位置,可能会破坏一些东西(比如级联)。最好的解决方案是 不要使用 @import ,而是使用 <link rel="stylesheet">

<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="app.css" />

Webkit 和 Blink:给 HTML 中的 @import 的 URLs 添加引号

在 HTML 中使用 @import 时,它的 url() 没有使用引号(""''),比如 @import url(app.css) 。Webkit 和 Blink 的行为与 Firefox 和 Edge完全相同。这意味着 Webkit 和 Blink 的预扫描器有bug(详细可参考 Chromium 的修复)。

简单地将 @importurl() 引入资源路径用引号括起来就可以解决问题。不过,与前面一样,还是建议要使用 @import ,应该使用 <link rel="stylesheet"><style> 来替代 @import 。下面分别是 @importurl() 未使用引号和使用引号网格请求瀑布图差异:

<link rel="stylesheet" href="style.css" />

<style>
    /* url()函数中参数未使用引号 */
    @import url(app.css);
</style>

<link rel="stylesheet" href="style.css" />

<style>
    /* url()函数中参数使用了引号 */
    @import url("app.css");
</style>

不要在异步代码片段前放置 <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"><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 文件之前,<script> (这段异步代码)不会运行。比如:

<link rel="stylesheet" href="app.css" />

<script>
    var script = document.createElement('script');
    script.src = "analytics.js";
    document.getElementsByTagName('head')[0].appendChild(script);
</script>

根据这个顺序,我们可以清楚地看到,在构建 CSSOM 之前,JavaScript 文件甚至没有开始下载。我们完全失去了并行下载的能力:

有趣的是,预加载扫描器本想提前获得对 analytics.js 引用,但是我们无意中隐藏了它。analytics.js 在脚本代码中是一个字符串,并且在 DOM 中存在 <script> 元素之前不会成为可识别的 src 属性。

第三方供应商提供这样的异步代码片段,从而更安全的加载脚本。开发人员对这些第三方供商持怀疑态度,并尝试在页面后面放置异步代码片段,这种现象比较常见。虽然这种出发点是好的,不想在自己的静态资源之前放置第三方 <script> 。通常这可能是净损失。事实上,Google 分析甚至告诉我们该做什么,他们是对的:

将此代码作为第一项复制并粘贴到你要跟踪的每个网页的 <head> 中。

所以:如果你的 <script> 脚本对 CSS 没有依赖性,就把它们放在样式(<link rel="stylesheet">)表的前面

将上面的示例代码调整成下面这样:

<script>
    var script = document.createElement('script');
    script.src = "analytics.js";
    document.getElementsByTagName('head')[0].appendChild(script);
</script>

<link rel="stylesheet" href="app.css" />

把任何非 CSSOM 查询的 JS 放在 CSS 之前;把任何CSSOM查询的JS放在CSS之后

除了异步加载代码片段之外,我们该如何更普遍地加载 CSS 和 JavaScript?为了解决这个问题,先来思考下面的问题。如果:

  • 在 CSSOM 构造上阻止 CSS 后定义的同步 JS
  • 同步JS阻止DOM 构建

那么,假设没有相互依赖,哪个对性能更好:

  • 先脚本后样式
  • 先样式后脚本

答案是:如果文件之间不相互依赖(CSS和JS),那么应该将阻塞JS脚本置于阻塞CSS样式之上,使用 CSS 延迟 JavaScript 执行是没有意义的,因为 JavaScript 实际上并不依赖 CSS

预加载扫描器可以确保,即使脚本上的 DOM 构建被阻止了,CSS 仍然可以并行下载。

如果 JS脚有一个部依赖 CSS 样式,但有一部分不依赖 CSS,那么同步加载 JS 和 CSS 的绝对最佳顺序是将 JS 一分为二,并在CSS的两侧加载它(即不依赖CSS的JS脚本放在CSS之前,依赖CSS的JS脚本放在CSS之后)

<!-- 这个JS脚本加载完成后立即执行 -->
<script src="i-need-to-block-dom-but-DONT-need-to-query-cssom.js"></script>

<link rel="stylesheet" href="app.css" />

<!-- 这个JS脚本在CSSOM构建完成后立即执行 -->
<script src="i-need-to-block-dom-but-DO-need-to-query-cssom.js"></script>

有了这种加载模式,就能以最理想的顺序进行下载和执行。下图中粉红色的标记代表 JS 执行:

  • 第一项是计划在其他文件到达和(或)执行时执行某些 JS 的 HTML
  • 第二项执行它到达的那一刻
  • 第三项是 CSS,所以不执行任何 JS
  • 第四项在 CSS 完成之前实际上并不执行

注意:你必须根据自己的具体使用情况来测试这个模式,可能会有不同的结果,这取决于你之前的 CSS、JavaScript 文件与 CSS 本身之间的文件大小和执行成本是否存在巨大差异

<link rel="stylesheet">放在<body>

这个策略是一个相对较新的策略,对感知性能和渐进式渲染有很大好处。在 HTTP/1.1 中,我们将所有样式统一放在一个文件中,是非常典型的。

<!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>
</html>

这带来三个关键的低效率:

  • ① 任何给定的页面只会使用 app.css 文件中的一小部分样式(我们几乎可以肯定会下载比我们需要的更多的CSS)
  • ② 我们受限于一种效率低下的缓存策略(例如:对仅在一个页面上使用的日期选择器上所选日期的背景颜色进行更改,那么将需要缓存整个app.css
  • ③ 整个 app.css 会阻塞渲染(即使当前页面只需要 app.css 文件中 17% 的样式规则,我们仍然要等剩余的 83% 样式规则下载完才能开始渲染)

有了 HTTP/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>
</html>

现在我们正在解决冗余问题,因为我们能够加载更适合页面的 CSS,而不是不加选择地下载所有内容,这减少了关键路径上阻塞 CSS 的大小。另外,我们可以采用更有意思的缓存策略,只缓存需要它的文件,并保持其余部分不受影响

我们还没有解决的问题是 它仍然阻塞渲染,我们仍然只有最慢的样式表。这意味着,如果由于某种原因,page-footer.css 需要很长的时间才能下载,浏览器无法开始渲染 .page-header 。然而, 由于 Chrome 浏览器从69 版本做了一些变化(Firefox 和 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>
</html>

这样做的实际结果是,我们现在能够逐步渲染我们的页面,在页面可用时有效地将页面样式添加到页面中。

不建议直接将 <style> 内联样式代码块像 <link rel="stylesheet"> 将CSS 样式根据模块(或组件)加载顺序放置在<body> 标签内。

删除未使用的 CSS

我们多次提到过 “更少的字符会让文件变得更小,这样就会有更快的下载速度”。为此应该尽可能的确保页面所用的 CSS 文件是一个最小化版本(尽可能确保 CSS 文件中没有包含页面中未使用的 CSS 样式规则)。在现代 Web 构建中是有相关工具可以帮助我们自动最小化 CSS。

在 Chrome 浏览器中的 “Coverage” 选项中,我们可以看到页面中所有未使用到的资源(CSS 和 JS):

单独把 CSS 拿出来,我们可以发现,聚划算首页 的 CSS 文件总包是 25861b,差不多有 82.7% 未使用:

图中 红色部分的 CSS 代码也代表非关键 CSS 代码;蓝色(绿色)部分的 CSS 代码代表关键 CSS

我们的目标不一定是页面未使用 CSS 达到 0%。然而,当你看到有很长的红色进度条(上图所示)就表示有很高的未使用占比,这个比例越大,说明初渲染所需的 CSS 就越少。我们就需要使用一些技术手段来帮助我们去除这些未使用的代码,比如前面所说的 内联关键CSS 、异步加载非关键CSS、根据媒体查询分割CSS 。除了上述提到的方法之外,还有一些其他的方法。

工程中移除未使用的 CSS

时至今日,自动化来移动未使用的 CSS是有一些工具可以来帮助我们的,我们可以在自己的工程中配置这些工具,比如:PurgeCSSPurifyCSSUnCSS。在 Atomic CSS 和 CSS-in-JS 中是 Tailwind + PurgeCSS组合(Windi CSS),也是当下较为流行方案。这些工具工作方式都有所不同,产生的结果也有所差异,而且很有可能会给我们带来一些意想不到的结果(比如说,删除一些有用的 CSS,比如说删除了 JS 动态插入进来的 DOM 需要的 CSS等),所以在工程中配置这些工具的时候还是要注意。

另外,据 Jens Oliver Meiert 收集了差不多 200 个内容网站,并抽取了另外 20 个 Web 开发者有关的网站进行比较(使用 CSS Stats 来确定 CSS声明的总数以及独特的声明的数量),发现 “样式表中有差不多 70% 的重复率”。 那么我们需要一些技术手段来规避或帮助开发者减少样式表中的重复率。其中前面提到的 Atomic CSS 可以在一定程序避免样式规则重复率,但它也有一些负面的说法,“会在HTML文档中增加更多的类名(增加字符串)”。除 Atomic CSS 之外,CSSBlocks(Github上有6.3k的星星)也很受欢迎,它受 CSS ModulesBEMAtomic CSS 的启发,成为 CSS 编写和维护的一种最佳实践,也号称是“高性能,可维护的CSS”。

CSS 代码分割

其实前面提到的 关键CSS 和非关键CSS的分离、CSS媒体查询区分CSS、按组件加载 CSS 都是 CSS 代码分割的方式。而在现代 Web 框架构建 (基于不同的JavaScript框架开发,比如集团的 Rax、社区的 React 和 Vue)的 Web 页面,在整个开发过程中有不同的范式(指的是CSS的编写、组织、维护等),比如 普通的CSS维护、CSS方法论、CSS Modules 或 CSS-in-JS等。不管采用的是哪种方式,最终编译打包出来的 CSS方式主要有:

  • 将所有 CSS 打包成一个 .css 文件
  • 按路由来分离 CSS,根据不同的路由,有多个 .css 文件
  • 按组件来分离 CSS,每一个组件都有一个 .css 文件

第一种方式是不太推荐的,主要原因前面有阐述。后面两种方式是较为推荐的,这样做可以帮助我们对 CSS 进行分割,并且能按需加载(动态加载)。而这些代码分离工作都可以依赖自动化工具来帮助我们完成,比如 WebpackParcelSnawpackVite都具备这方面的能力。

Tan Li Hua在他的博文 《CSS Code Splitting》分享了 Shopee 网站上的 CSS 分割的相关经验和踩过的坑。

请勿内嵌较大数据 URI

在 CSS 文件中内嵌数据 URI 时,请务必慎重。你可以选择在 CSS 中使用较小数据 URI,毕竟内嵌较大数据 URI 可能会导致首屏 CSS 变大,进而延缓首屏幕渲染的时间。

请勿内嵌 CSS 属性(行内CSS)

应尽量避免在 HTML 元素(例如 <p style="...">)中写 CSS 规则,因为这经常会导致不必要的代码重复。此外,在默认情况之下,内容安全政策(CSP)会阻止在 HTML 元素中内嵌 CSS。

CSS 预加载

Preload作为一个新的 Web 标准,旨在提高性能和为 Web 开发人员提供更细粒度的加载控制。Preload 使开发能够自定义资源的加载逻辑,且无需忍受基于脚本的资源加载器带来的性能损失。

我们可以使用 preload 进行 CSS 资源的预加载、并且同时具备:高优先级、不阻塞渲染等特性。然后应用程序在合适的时间使用 CSS 资源:

<!-- 通过声明性标记预加载 CSS 资源 -->
<link rel="preload" href="/styles/other.css" as="style">

<!-- 或,通过JavaScript预加载 CSS 资源 -->
<script>
    var res = document.createElement("link");
    res.rel = "preload";
    res.as = "style";
    res.href = "styles/other.css";
    document.head.appendChild(res);
</script>

<!-- 使用HTTP头预加载 -->
Link: <https://example.com/other/styles.css>; rel=preload; as=style

特别是在首屏渲染(关键CSS)中用到一些隐藏资源,比如字体,较大的图片资源等。我们可以内联关键CSS的前面使用 <link> 标签提前预加载这些重要资源:

<!DOCTYPE html>
<html lang="en">
    <head>
        <!-- preload 预加载关键CSS 或首屏 用到的一些隐藏资源,比如图片、字体文件等 -->
        <link rel="preload"  href="fonts/cicle_fina-webfont.woff2" as="font" type="font/woff2" crossorigin>
        <link rel="preload" as="image" imagesrcset="image-400.jpg 400w, image-800.jpg 800w, image-1600.jpg 1600w"  imagesizes="100vw">
        <link rel=preload href=cat.png as=image imagesrcset="cat.png 1x, cat-2x.png 2x">
        <link rel="preload" as="image" href="important.png">
        <!-- 关键 CSS 内联方式引入 -->
        <style type="text/css">
            /* 尽可能将压缩之后的关键 CSS 放在这里 */
        </style>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width,initial-scale=1">
        <title>Bootstrap Critical</title>
        <!-- 非关键 CSS 异步加载 -->
        <link href="/no-critical.css" rel="preload" as="style" onload="this.rel='stylesheet'">
    </head>
    <body>
        <!-- 这里是App的内容 -->
        <script type="text/javascript" src="/build_main.js"></script>
    </body>
</html>

有关于这方面更详细的内容可以阅读《Preload, prefetch and other link tags》一文。

异步加载 CSS

<script> 元素不同,<link> 元素是没有像 asyncdefer 属性可以用来实现异步加载。比如我们早前要实现 异步加载一个 CSS 都是需要依赖 JavaScript 脚本的,比如说 loadCSS 这个脚本库。庆幸的是,最近各主流浏览器已经将 CSS 的加载行为标准化了,所以像 loadCSS 这样的脚本库来处理它们的细微差别可能已经没有必要了。

正如前面加载非关键 CSS示例就用了新的标准行为,通过简单地一行 <link> 标签就可以实现异步加载 CSS。这也是异步加载 CSS 的最简单方法。按照前面的说法,在一个页面中除了首屏幕渲染所需要的 CSS (关键CSS)之外的 CSS都应该像下面这样通过 <link> 标签来异步加载:

<head>
    <link rel="stylesheet" href="no-critical.css" media="print" onload="this.media='all'">
</head>  

简单地分析一下这行代码发生了什么?

首先,<link> 标签的 media 属性指定的值是 print 类型,表示该 <link> 标签加载的 CSS 样式是基于打印设备的,或者换句话说,在用户试图打印页面时会应用该 CSS。而我们希望的 no-critical.css 是要适用于所有的媒体(尤其是屏幕screen),而不仅仅是打印设备,但这样做有一个有用的效果(与当前媒体类型 或媒体条件不符):浏览器会在不延迟页面渲染的情况下异步加载样式表。但这并不是我们想要的全部。我们还希望CSS 文件加载后能真正用到屏幕环境中。为此在 <link> 标签上使用 onload 事件,并且在文件加载完后将 media 的类型改成 all

而且过去一年在使用 <link rel="preload"> (而不是 <link rel="stylesheet">) 来实现与上述类似的模式,分别在加载后切换 rel 属性,而不是 media 。这样使用仍然有好处,但使用预加载时有几个缺点需要考虑。首先浏览器对预加载的支持仍然不是很好,所以你想依赖它来获取和应用跨浏览器的样式表,就需要一个像 loadCSS 这样的脚本库。更重要的是,预加载会在很早的时候以最高的优先级获取文件,可能会降低其他重要资源下载的优先级,而这可能比你实际需要的非关键 CSS 的优先级更高。

庆幸的是,如果你碰巧想要获得 rel="preload" 提供的高优先级下载,可以把这两种模式结合起来使用:

<head>
    <link rel="preload" href="no-critical.css" as="style">
    <link rel="stylesheet" href="no-critical.css" media="print" onload="this.media='all'">
</head>

如果你想提供能容错的 异步 CSS 加载方式,可以阅读:

简单的小结一下优化阻塞渲染 CSS的相关策略:

  • 懒加载任何不需要用于开始渲染的 CSS
    • 关键 CSS
    • 媒体查询或媒体条件分割 CSS
  • 避免在 HTML 和 CSS 中使用 @import,尤其是在 CSS文件中
    • 提防预加载器的奇怪之处
  • 注意同步 CSS 和 JavaScript 的顺序
    • 在 CSSOM 完成之前,CSS 之后的 JavaScript 将无法执行
    • 如果 JavaScript 不依赖 CSS,那么在 CSS 之前加载
    • 如果 JavaScript 依赖 CSS,那么在 CSS 之后加载
  • 在 DOM 需要时加载 CSS,将取消阻塞“开始渲染”,并允许渐进式渲染
  • 删除未使用 CSS
    • 借助工程能力,移除页面中未使用的 CSS
    • 借助工程能力,对CSS进分分割,可以按路由或组件进行分割
  • 请勿内嵌较大的数据 URI
  • 请勿内嵌 CSS 属性(行内 CSS)
  • 使用preload 预加载
    • 预加载非关键 CSS
    • 预加载关键 CSS 中用到的隐藏资源,比如字体,图片等(建议放置在关键CSS <style> 之前)
    • 预加载首屏渲染要用的图片
  • 异步加载非关键CSS
    • <link> 标签指定 media 值为 print ,配合 onload 事件,等CSS加载完之后将 media 值修改成 all
    • 建议和 <link> 标签上使用preload 预加载一起配合使用

如何消除阻塞渲染的 JS

我们已经知道了,当浏览器在解析 HTML 文档时,遇到 <script> 时,会阻止解析器继续操作,直到 CSSOM 构建完毕,JavaScript 才会运行并继续完成 DOM 构建过程,所以我们可以考虑以下方式来消除阻塞渲染的 JS:

  • <script> 中添加 async 属性后,浏览器遇到这个标记时会继续解析 DOM,同时脚本也不会被 CSSOM 阻止,即不会阻止 CRP
  • <script> 中添加 defer 属性后,浏览器遇到这个标记时,JS脚本需要等到文档解析后(DOMContentLoaded事件前)执行,而 async 允许脚本在文档解析时位于后台运行(两者下载的过程不会阻塞 DOM,但执行会)
  • 当 JS 脚本不会修改 DOM 或 CSSOM 时,推荐使用 async

上图来自 HTML 规范中,描述了 <script> 标签设置 moduleasyncdefer 是如何有效交互 JS 脚本。

除此之外,也可以像 消除阻塞渲染 的 CSS 优化策略一样,可以:

  • 最小化 JS 脚本:采用代码分割、Tree-shaking、延迟加载、删除未使用JS脚本和压缩脚本等方式
  • 预加载(preloadprefetch
  • DNS 预解析(dns-prefetch