skip to content
usubeni fantasy logo Usubeni Fantasy

前端性能优化教程——优化资源加载

/ 21 min read

在上一讲中,我们探讨了关键渲染路径的一些理论基础,包括渲染阻塞和解析阻塞资源是如何使网页的首次渲染延迟的。现在,既然你对这些理论有了一定的了解,接下来我们将教你一些技巧,用以优化关键渲染路径。

在网页加载过程中,会引用 HTML 里的多种资源,这些资源通过 CSS 定义了网页的外观和布局,通过 JavaScript 实现了网页的交互功能。本节课将介绍一些重要的概念,让你了解这些资源是如何影响网页加载时间的。

渲染阻塞

正如我们在上一个部分讨论的那样,CSS 被认为是一种渲染阻塞资源。这是因为在 CSS 对象模型(CSSOM)构建完成之前,它会阻止浏览器显示任何内容。浏览器这样做是为了避免所谓的“未添加样式的内容闪现”(FOUC),这种现象从用户体验角度来看,是我们不愿意看到的。

这个视频中,我们可以短暂地观察到 FOUC 现象,此时页面上没有任何样式。之后,一旦页面的 CSS 文件从网络上完全加载完毕,所有的样式就会被应用上,这样无样式的页面版本就会立刻被有样式的版本取代。

通常情况下,我们不太会遇到 FOUC 现象,但理解其背后的原理非常重要,这有助于我们明白为什么浏览器在 CSS 文件下载并应用到页面之前,会暂停页面的渲染过程。虽然渲染过程的暂停并非总是不受欢迎的,但通过优化 CSS 文件,我们应当尽量缩短这一过程的时间。

解析器阻塞

阻塞解析器的资源能够暂停 HTML 文档的解析过程,比如一个缺少 asyncdefer 属性的 <script> 标签。遇到 <script> 标签时,浏览器必须先运行这段脚本,然后才能继续处理剩下的 HTML 内容。这样的设计是有意为之,目的是为了在 DOM 结构还在形成过程中,允许脚本进行修改或访问。

<!-- This is a parser-blocking script: -->
<script src="/script.js"></script>

在网页中,如果我们直接引用外部的 JavaScript 文件,而不使用 asyncdefer,那么在浏览器加载这个文件、解析并运行它的整个过程中,网页的其余内容就会暂停加载。这种情况不仅发生在外部脚本上,如果我们将 JavaScript 代码直接写在网页中(即内联 JavaScript),在这段代码被处理完成之前,网页的加载也会同样暂停。

NOTE

在浏览器执行一个阻塞解析的 <script> 之前,它必须等待所有影响页面渲染的 CSS 资源被完全加载并解析。这种设计是有其目的的,因为这样的脚本有可能需要访问那些在阻塞渲染的样式表中定义的样式信息(比如,通过调用 element.getComputedStyle() 方法)。

预加载扫描器

预加载扫描器是浏览器的一项优化技术,它通过一个辅助的 HTML 解析器来扫描原始的 HTML 响应内容,以此来查找并预先加载那些主 HTML 解析器可能会晚些才发现的资源。比如说,这种扫描器能使浏览器提前开始下载 <img> 标签中指定的资源,哪怕 HTML 解析器在加载和处理 CSS、JavaScript 等资源时遇到了阻碍。

想要充分利用 预加载扫描器的优势,关键资源应当直接包含在服务器发送的 HTML 代码中。然而,有些资源加载方式是预加载扫描器无法识别的:

  • 使用 CSS 的 background-image 属性加载的图片。这类图片的引用是写在 CSS 文件里的,预加载扫描器搜寻不到它们。
  • 通过 JavaScript 将 <script> 标签代码动态插入到 DOM 中或使用动态 import() 方法加载的脚本。
  • 通过 JavaScript 在客户端生成的 HTML 代码。这些代码被嵌套在 JavaScript 文件中的字符串里,因此预加载扫描器也检测不到。
  • CSS 中的 @import 命令。

这些资源加载模式属于较晚被发现的类型,所以它们无法享受预加载扫描器的优势。应当尽可能地避开这类加载方式。但如果实在无法规避,你或许可以通过使用 preload 提示来减少资源探测的延时。

NOTE

preload 资源提示的内容将在下一部分的资源提示模块中进行讲解。

CSS

CSS 负责定义网页的外观和布局方式。正如之前提到的,CSS 会阻塞页面渲染,所以对 CSS 进行优化能够显著提高网页的加载速度。

最小化

通过压缩 CSS 文件,我们可以缩减 CSS 资源的体积,从而加快下载速度。具体做法是,删除源 CSS 文件中的空格和其他不可见字符,并将处理后的内容保存到一个新的、经过优化的文件中:

/* Unminified CSS: */
/* Heading 1 */
h1 {
font-size: 2em;
color: #000000;
}
/* Heading 2 */
h2 {
font-size: 1.5em;
color: #000000;
}
/* Minified CSS: */
h1,h2{color:#000}h1{font-size:2em}h2{font-size:1.5em}

NOTE

高级 CSS 压缩工具还可能实施更多优化手段,比如把重复的规则合并到若干选择器里。但是,这种进阶的 CSS 优化手段带有一定的风险,可能并不适合所有的 CSS 编码风格或设计体系,也可能在这些体系中难以有效扩展。

CSS 压缩的基本目的是通过优化提升网站的首次内容绘制(FCP)速度,有时候还能改善最大内容绘制(LCP)速度。一些打包工具就能在你的产品构建过程中自动完成这种优化。

删除未使用 CSS

在网页显示任何内容之前,浏览器必须先下载并处理所有的样式表。这个处理过程不仅包括页面上实际用到的样式,还包括那些未被使用的样式。如果你使用了某种打包工具把所有的 CSS 资源打包成一个文件,这就意味着用户可能下载了远超过显示当前页面实际需要的 CSS 量。

要查找当前页面中未被使用的 CSS,可以利用 Chrome 开发者工具(DevTools)中的“覆盖率(Coverage)”功能。

去除掉不用的 CSS 不仅能缩短下载时间,还能让浏览器构建渲染树的过程更高效,因为这样浏览器就少了很多 CSS 规则需要处理。

IMPORTANT

根据你的网站架构,可能做不到彻底清除掉所有未使用的 CSS —— 而且这也不是你应该追求的目标。应该着眼于显著的改进:如果你发现 CSS 文件中有很大一部分在当前页面没有用到,这些代码可能在别的页面有用(那么你就应该把它们挪到另一个文件里去),或者如果这些 CSS 代码在你的项目里已经完全没用了,那就可以直接删掉了。

避免使用 @import

即使看起来很方便,但你应该避免在 CSS 中使用 @import

/* Don't do this: */
@import url("style.css");

就像 HTML 中 <link> 元素的作用一样,CSS 的 @import 声明也能让我们在一个样式表内部引入外部的 CSS 资源。但是,这两种引入方式有一个关键的不同:HTML 的 <link> 元素作为 HTML 响应的一部分,能够比通过 @import 声明下载的 CSS 文件更早被浏览器识别。

原因在于,浏览器必须先下载包含 @import 声明的 CSS 文件,才能识别出里面的 @import 声明。这种方式形成了一条请求链,对于 CSS 来说,这会延迟页面的首次渲染时间。还有一个问题是,使用 @import 声明加载的样式表无法被预加载扫描器检测到,因此它们会成为被晚期发现、阻碍页面渲染的资源。

<!-- Do this instead: -->
<link rel="stylesheet" href="style.css" />

在大部分情况下,我们可以采用 <link rel="stylesheet"> 元素来代替 @import 命令。不同于 @import 命令那样顺序下载样式表,<link> 元素使得样式表能够同时下载,这样做可以缩短网页的整体加载时间。

NOTE

如果你需要用到 @import,比如用于定义层叠层或引入第三方样式表,可以通过为导入的样式表使用预加载(preload)指令来缓解加载延迟。另一方面,CSS 预处理器如 SASS 或 LESS 常常采用 @import 语法,这是为了改善开发者体验,使得源文件能够更加分离和模块化。但是,当 CSS 预处理器处理 @import 声明时,它实际上会把所有引用的文件打包成一个单一的样式表,这样就避免了在原生 CSS 中使用 @import 所可能导致的连续请求的问题。

行内关键 CSS

将关键的 CSS 样式直接嵌入到网页的 <head> 部分,可以避免加载外部 CSS 文件所需的网络请求,从而减少页面首次内容绘制(FCP)的时间。当这种做法被正确实施时,对于那些浏览器缓存尚未建立的用户,可以显著提升网页的初始加载速度。剩余的 CSS 样式可以通过异步方式加载,或者添加到 <body> 标签的底部。

KeyTerm

关键 CSS 是指加载页面时,在用户屏幕内可见部分(通常称为“首屏”)必须加载的样式。首屏之外其他部分的样式则会在其他 CSS 文件异步加载后应用。

<head>
<title>Page Title</title>
<!-- ... -->
<style>
h1,
h2 {
color: #000;
}
h1 {
font-size: 2em;
}
h2 {
font-size: 1.5em;
}
</style>
</head>
<body>
<!-- Other page markup... -->
<link rel="stylesheet" href="non-critical.css" />
</body>

IMPORTANT

在网站开发过程中,提取并保持关键样式的重要性不容忽视,但这项任务充满挑战。我们需要面对几个问题:哪些样式被视为“关键”且必须优先加载?我们应该关注哪些屏幕尺寸和设备类型?能否通过自动化工具简化这个过程?如果页面的非关键样式还没加载完,用户就开始滚动页面,会发生什么情况?如果用户遭遇到 FOUC,他们的体验会受到怎样的影响?所有这些问题都值得在考虑是否在你的网站中实施关键 CSS 时仔细思考。尽管采用关键 CSS 可能会面临不少技术和实施难题,但在某些情况下,为了性能的显著提升,探索并实施它可能是值得的。

但这样做也有不利之处,将大量的 CSS 直接写进 HTML 会让初始页面的数据量增加。由于 HTML 文件往往不能被长期缓存,甚至无法缓存,这就导致了那些直接嵌入的 CSS 对于其他可能会用到同样 CSS 样式表的页面并不会存储在缓存中。因此,你需要通过测试和评估你的网页性能来确保这种做法是否真的值得。

CSS demo

注意:demo 托管在 glitch,可能需要科学上网

https://learn-performance-css.glitch.me/

JavaScript

JavaScript 是网络互动性的主要驱动力,但它也有副作用。如果网页加载过多的 JavaScript,可能会导致页面加载时反应迟钝,甚至可能出现响应问题,从而拖慢用户操作的速度——这些情况都会让用户感到不满。

阻塞渲染的 JavaScript

当我们在网页中添加 <script> 标签而不使用 deferasync 属性时,浏览器会停下来,直到它下载、理解并运行这段代码才会继续处理网页的其余部分。如果我们直接在网页中写 JavaScript 代码(即所谓的内联脚本),也会让浏览器暂停,等到这段直接写在网页中的代码被完全执行后才会继续。

async vs defer

使用 asyncdefer 属性,我们可以让外部的 JavaScript 文件在不干扰网页内容加载的情况下被加载。而将脚本标记为 type="module"(无论是外链的还是页面内直接写的脚本),也能实现自动延后加载的效果。但是,理解 asyncdefer 在功能上的微妙差别是很关键的。

通过 async 属性加载的脚本,一旦下载完成就会立刻被浏览器执行,而通过 defer 加载的脚本会等到整个页面内容完全加载解析后才运行,这通常与浏览器触发 DOMContentLoaded 事件的时机一致。另外,使用 async 加载的脚本可能不按照原本的顺序执行,而 defer 确保了脚本会依照它们在页面代码中的排列顺序依次执行。

NOTE

标记为 type="module" 的脚本默认情况下会延后加载,这意味着它们会等页面其他内容加载完毕后再执行。另一方面,如果我们通过 JavaScript 动态添加 <script> 元素到页面中,这些脚本会像设置了 async 属性的脚本一样工作,即它们会在下载完成后立即执行,而不管页面加载到哪个阶段。

客户端渲染

一般我们应尽量避免使用 JavaScript 来渲染任何重要的网页内容或者是网页的 LCP 元素。这种方式我们称之为客户端渲染,这是单页面应用程序(SPA)常用的一种技术。

使用 JavaScript 渲染的标记无法被预加载扫描器识别,因为这些标记中的资源对预加载扫描器来说是无法发现的。这可能导致一些关键资源的下载被延迟,例如 LCP 图片。浏览器只有在 JavaScript 脚本执行后,将元素添加至 DOM 中,才开始下载 LCP 图片。换句话说,脚本必须被发现、下载并解析后才能执行。这种情况被称为关键请求链,我们应当尽可能避免。

另外,使用 JavaScript 渲染的标签比直接从服务器获取标签更可能形成长任务。如果大量使用客户端渲染 HTML,将对交互延迟造成负面影响。尤其当一个页面的 DOM 结构较大,相关的 JavaScript 脚本在更改 DOM 时,可能会触发大量的渲染任务,

最小化

就像压缩 CSS 一样,最小化 JavaScript 能加速其下载速度,这样浏览器就能更迅速地开始解析和编译 JavaScript 代码。

不过,JavaScript 的压缩不仅仅停留在移除空白、制表符和注释这个层面,它还包括了缩短代码中的符号名称,这个过程有时候被称作“丑化(uglification)”。要理解这种差异,不妨看看下面的 JavaScript 原始代码:

// Unuglified JavaScript source code:
export function injectScript() {
const scriptElement = document.createElement("script");
scriptElement.src = "/js/scripts.js";
scriptElement.type = "module";
document.body.appendChild(scriptElement);
}

丑化结果如下:

// Uglified JavaScript production code:
export function injectScript(){const t=document.createElement("script");t.src="/js/scripts.js",t.type="module",document.body.appendChild(t)}

在上面的代码示例中,我们看到原本容易理解的变量名 scriptElement 被缩短成了 t。这种做法一旦应用到大量脚本上,能显著减少文件大小,同时不损害网站正式运行所需 JavaScript 的功能。

使用打包工具处理网站代码时,代码的“丑化”通常会在生成正式版本时自动进行。如 Terser 这样的丑化工具配置灵活,能让你根据需要调整压缩强度,以达到最佳的压缩效果。不过,这些工具的默认配置往往已经能够很好地平衡文件大小和功能保留之间的关系。

JavaScript demo

注意:demo 托管在 glitch,可能需要科学上网

https://learn-performance-javascript.glitch.me/

文章信息

评论组件加载中……