页面关键路径渲染详解
关键路径渲染
浏览器不会等待全部资源都下载完后才进行渲染,而是采用渐进式的渲染方式,本文就介绍一下这种渐进式的渲染方式。
当浏览器获取到用于呈现网页的资源后,通常就会开始渲染网页。那么究竟是在什么时候就会开始渲染?
如果浏览器在只需要一些 HTML 时(但在它尚未添加任何 CSS 或必要的 JavaScript 之前)尽快呈现,网页就会立即看起来损坏,并且会在进行最终呈现时发生显著变化。这种体验比最初显示空白屏幕一段时间,直到浏览器具有初始渲染所需的更多资源,从而提供更好的用户体验,这种体验会更糟糕。
了解关键渲染路径可确保我们不会过度地阻止初始网页渲染,但同时,也需要从关键渲染路径中移除非首次渲染所需的资源,这些都能帮助提高网页性能。
关键渲染路径涉及以下步骤:
- 通过 HTML 构建文档对象模型 (DOM)。
- 通过 CSS 构建 CSS 对象模型 (CSSOM)。
- 应用任何会更改 DOM 或 CSSOM 的 JavaScript。
- 通过 DOM 和 CSSOM 构建出渲染树(render tree)。
- 在页面上执行样式和布局操作,看看哪些元素适合显示。
- 在内存中绘制元素的像素。
- 如果有任何像素重叠,则合成像素。
- 以物理方式将所有生成的像素绘制到屏幕上。
只有在完成所有这些步骤后,用户才会在屏幕上看到内容。
这个渲染过程会发生多次。首次渲染时会调用此流程,随着更多会影响网页渲染的资源被加载、解析后,浏览器将会重新运行此流程(也可能只是其中的一部分流程),以更新页面的内容。关键渲染路径侧重于首次渲染的流程,并依赖于执行抽此渲染所需的关键资源。
关键渲染路径上的资源
浏览器需要等待一些关键资源下载完毕,然后才能完成初始渲染。这些资源包括:
- 部分 HTML
<head>
标签中阻塞渲染的 CSS 文件。<head>
标签中的阻塞渲染的 JavaScript 文件。
浏览器是通过字节流的方式读取 HTML,一旦浏览器获取到了 HTML 的任何部分,就会开始进行处理。之后,浏览器就可以决定是否可以先渲染页面,再接收剩余的 HTML。
在首次渲染的过程中,以下几种情况不会妨碍渲染:
- HTML 未解析完,浏览器首次渲染只需要部分 HTML 即可,不需要等待全部 HTML 解析完。
- 字体样式,不会等待字体样式的加载。
- 图片,不会等待图片资源的加载。
- 不在
<head>
标签内(比如在 body 中<script>
标签)的非阻塞渲染的 JavaScript。 - 不在
<head>
标签内的 css(比如在 body 中通过<style>
标签中的 @import 语法导入的),或者在<head>
标签内但是 media 值与当前设备类型不一致(比如在web上运行的设置成在打印机上,<link href="/style.css" rel="stylesheet" media="print">
)。
浏览器通常将字体和图片视为要在重新渲染时填充的页面内容,因此这些资源并不会妨碍页面的首次渲染。
不过,这可能意味着,首次渲染中页面会出现空白区域,而文本被隐藏并等待字体样式的加载,或直到有图片资源加载完为止。更糟糕的是,当某些类型的内容没有预留足够的空间时(尤其是当 HTML 中未提供图片尺寸时),网页布局可能会在这些内容加载完成后发生变化。这种页面布局更改可以通过累计布局偏移 (CLS) 指标来测量。
<head>
标签是处理关键渲染路径的关键。
内容介绍过,下一部分会进行详细介绍。
优化 <head>
标签中的内容是提升网页性能的一个关键方面。不过,目前若想了解关键渲染路径,我们只需要知道 <head>
标签包含有关页面及其资源的元数据,但是不包含用户可以看到的实际内容。可见内容只存在于 <head>
标签后面的 <body>
标签中。
浏览器在渲染任何内容之前,需要同时具备渲染的内容以及有关如何渲染该内容的元数据。
不过,并非 <head>
标签中引用的所有资源都是首次渲染时所必需的,因此浏览器只会等待首次渲染需要的那些资源下载、解析完后即可渲染。
为了确定哪些资源处于关键渲染路径中,我们需要了解阻塞渲染型资源(render-blocking)和阻塞解析型资源(parser-blocking)。
阻塞渲染型资源
有些资源被认为非常关键,以至于浏览器需要暂停网页渲染,直到这些关键资源被处理完毕才能进行渲染。CSS 默认属于此类别。
当浏览器看到 CSS(无论是 <style>
标签中的内嵌 CSS,还是由 <link rel=stylesheet href="...">
指定的外部引用的资源)时,浏览器在完成对该 CSS 的下载和处理之前,都不会进行渲染。
注意:尽管 CSS 默认会阻塞渲染,但也可以通过更改 <link>
元素的 media 属性来指定与当前设备条件不匹配的值,将其转换为不阻塞渲染的资源:<link rel=stylesheet href="..." media=print onload="this.media='all'">
。 以允许非关键 CSS 以不阻塞渲染的方式加载。由于 media= ‘print’ 的存在,当前下载的这个 css 样式只会应用于打印机上,web 页面不会受到影响,当 CSS 下载完成后就会触发 onload 事件,执行 this.media=‘all’ ,导致样式重新渲染。
当一个资源阻塞渲染时并不一定意味着它会阻止浏览器执行任何其他操作。浏览器会尽可能地提高效率,因此,当浏览器发现需要下载某项 CSS 资源时,虽然它在请求该 CSS 资源会暂停渲染,但仍然会继续处理其余 HTML 并寻找其他工作。
阻塞渲染的资源(如 CSS)用于在发现这些资源时阻碍网页的渲染。这就意味着,某些 CSS 是否会阻止内容呈现,取决于浏览器是否发现了此类 CSS。一些浏览器(最初 Firefox,现在还有 Chrome 浏览器)只会阻止渲染阻塞渲染的资源下方的内容。那也就是说对于阻塞渲染的关键路径,我们通常关注的是 <head>
标签中阻塞渲染的资源,因为它们会有效阻止整个网页的渲染。
前面只讲到了 css 资源才会妨碍渲染,但是 Chrome 105 中,新增了 blocking=“render” 属性,开发者可以在 script、link、style 标签中通过设置 blocking=“render” 属性显式指定当前这个资源会阻碍页面的渲染,直到资源处理完毕后才能继续渲染,但是期间仍允许解析器继续处理文档。
阻塞解析型资源
阻塞解析型资源是指那些会阻碍浏览器通过继续解析 HTML 来寻找要执行的其他工作的资源。默认情况下,JavaScript 都会阻塞解析(除非明确在 script 标签上标记为 async 或 defer),因为 JavaScript 代码可能会在执行时更改 DOM 或 CSSOM,在了解所请求 JavaScript 资源对网页 HTML 会造成的全部影响之前,浏览器就不可能继续处理其他资源。因此,同步 JavaScript 会阻止解析器。
由于解析器无法跳过阻塞解析型资源,只能等待这些资源被加载好才能解析,之后才能进行渲染,实际上阻塞解析型资源也是阻塞渲染型的一种。
浏览器在等待期间是可以渲染到目前为止所收到的任何 HTML,但在涉及关键渲染路径的情况下,<head>
标签中任何阻塞解析型资源都意味着网页渲染被阻碍了。
阻塞解析型资源可能会消耗巨大的性能成本,甚至比阻塞渲染的成本还要大。因此,对于那些需要在浏览器的渲染机制启动前就下载好的资源可以使用 rel=preload 进行标识,尽管名称中包含"load"一词,但它并不加载和执行脚本,而只是安排脚本以更高的优先级进行下载和缓存。
<link rel="preload" href="/style.css" as="style">
<link rel="preload" href="/main.js" as="script">
将使用值为 preload 的 rel 属性,这会将 <link>
标签转变成任何我们想要的资源的预加载器。
- href:资源路径
- as:资源类型
这里只简单介绍一下rel=preload,详情可以跳转查看MDN 里的介绍。
争议点
长期以来,关键渲染路径一直关注的是首次渲染。然后,之后出现的一些指标(TTFB、LCP 等,后续会写文章讲解),开始让开发者质疑关键渲染路径的结束点应该是首次像素渲染还是之后内容更丰富一点的渲染。
一些人认为所谓的关键渲染路径是 LCP(Largest Contentful Paint)或者 FCP( First Contentful Paint)这些指标,因为这才代表是有意义的内容被渲染出来。这也就意味着在关键渲染路径可能包含非阻塞型的资源,因为这些资源对于计算 LCP、FCP 是必须的。
不管这些争议点谁对谁错,我们只需要记住一点,了解这些会阻碍解析、渲染的因素非常重要,能让我们具体分析出哪些资源是需要优化的。
优化资源加载
前面讲解了哪些资源会造成渲染阻塞及解析阻塞,现在我们来看看这些资源是怎么影响渲染以及怎么去优化。
阻塞渲染
前面提到了 CSS 默认属于阻塞渲染型资源,会阻止浏览器渲染任何内容,直到 CSS 对象模型 (CSSOM) 构建完成,这主要是为了防止 FOUC。
FOUC(Flash of unstyled content,无样式内容闪烁),在首次渲染时没有应用样式,在样式文件加载好后重新渲染,导致在用户端会经历一次页面闪烁的效果。
阻塞解析
阻碍解析型资源(例如没有携带 defer、async 标识的 <script>
标签)会中断浏览器的 HTML 解析器。当解析器遇到一个 <script>
标签时,浏览器需要先评估并执行脚本,然后才能继续解析其余 HTML。这是设计使然,因为脚本可能在 DOM 仍在构建期间修改或访问 DOM
某个标签在构建完成之后就可以通过 document.querySelector 等方式访问这个DOM,不需要等待全部 HTML 解析完成。
<!-- 阻碍解析的 script -->
<script src="/script.js"></script>
<script>
// 内联的 js 同样会阻塞解析
// 需要经过解析,执行
</script>
注意:阻塞解析的
<script>
必须要等待所有阻止内容渲染的 CSS 资源的解析,当这些 css 资源被解析完后浏览器才能执行这些 script。这也是精心设计的,因为 js 可以通过 element.getComputedStyle() 这样的方法访问到阻塞渲染的 css 资源里定义的样式,因此必须要等 css 先加载、解析完,才会解析 js。
CSS 优化
CSS 决定了网页的呈现方式和布局。但它是一种阻碍渲染的资源,因此优化 CSS 可能会带来对整体网页加载时间的影响。
压缩
CSS 压缩可减小 CSS 资源的文件大小,使得下载速度更快。这样压缩主要是通过移除 css 源文件的内容(例如空格、注释和其他不可见字符)来实现的。
/* 未压缩的样式 */
/* Heading 1 */
h1 {
font-size: 2em;
color: #000000;
}
/* Heading 2 */
h2 {
font-size: 1.5em;
color: #000000;
}
/* 压缩后的样式 */
h1,h2{color:#000}h1{font-size:2em}h2{font-size:1.5em}
一些高级 CSS 压缩工具可能会采用额外的优化,例如将冗余规则合并为多个选择器。但是这些优化可能存在风险,不一定兼容全部浏览器。
从最基本的形式来看,CSS 压缩是一项有效的优化, 可以降低网页的 FCP,在某些情况下甚至可能降低 LCP。
删除未使用的 CSS
在渲染任何内容之前,浏览器都需要下载并解析所有样式表。这也就意味着样式表中可能存在当前页面不需要的样式。如果我们使用的打包器直接合并了所有 CSS 单个文件,那么用户在打开某个页面时就会下载非常多无用的 CSS。
若要发现当前网页未使用的 CSS,可以使用开发者工具中的 Coverage 进行测量。
移除未使用的 CSS 不仅可以减少下载量,还可以优化渲染树(render tree)的构建流程,因为浏览器需要 处理的 CSS 规则更少了。
避免使用 CSS @import 声明
虽然看起来很方便,但我们应避免在 CSS 中使用 @import 声明:
<!-- 避免使用@import 导入 -->
<style>
@import url('/style.css');
</style>
与 <link>
标签在 HTML 中的工作方式类似,@import 语法也可以让我们导入外部 CSS 资源。这两种方法之间的主要区别在于,<link>
标签是 HTML 中的一部分,因此被解析器发现的速度会比通过 @import 导入的更快。
原因在于,若要解析 @import 语法,那就必须先下载包含这个 @import 语法的 CSS 文件,这样就会产生所谓的"请求链"。在使用 CSS 的情况下,这会延迟网页首次渲染所需的时间,另一个缺点是使用 @import 声明加载的样式没办法使用预加载扫描器(preload),因此会成为后期发现的阻塞渲染型资源。
<!-- 通过 link 标签导入 -->
<link rel="stylesheet" href="style.css">
在大多数情况下,我们可以使用 <link rel="stylesheet">
替换掉 @import 的写法。<link>
标签可以同时下载多个样式表,减少总体加载时间,而 @import 语法依赖于包含这个语法的样式表,这样形成 ”请求链“,即这是一种“连续”下载的样式表。
<!-- 可以同时下载这三个样式文件 -->
<link rel="stylesheet" href="/main.css">
<link rel="stylesheet" href="/style.css">
<link rel="stylesheet" href="/app.css">
从上图可以看到,这三个样式请求几乎都是在同一时间发送的。
<!-- 假设 main.css 中存在 @import,就只能通过连续下载 -->
<link rel="stylesheet" href="main.css">
/* main.css */
@import url('/style.css');
/* style.css */
@import url('/app.css');
/* app.css */
body {
color: #000000;
}
从上图可以看到,这三个样式文件都是链式请求。
内联关键的 CSS
下载 CSS 文件所需的时间可能会增加网页的 FCP。在 <head>
标签中的通过 style
标签内联关键样式就可以消除了对 CSS 资源的请求耗时。其余的 CSS 可以通过异步方式(也就是 media=“print”)或在 <body>
标签末尾添加。
关键 CSS 是指渲染在初始视口内可见的内容。最初的视口概念有时也称为“首屏”。
<head>
<title>Page Title</title>
<!-- 关键样式 -->
<style>h1,h2{color:#000}h1{font-size:2em}h2{font-size:1.5em}</style>
</head>
<body>
<h1>h1</h1>
<h2>h2</h2>
<div>
...
</div>
<!-- 剩余的样式 -->
<link rel="stylesheet" href="non-critical.css">
</body>
提取和维护关键样式可能很难。应该添加哪些样式?应选择哪个或哪些视口目标?流程可以自动完成吗?如果用户向下滚动页面,会发生什么情况?如果用户遇到了 FOUC,要怎么解决?这些都是值得开发者思考的问题。
内联关键 CSS 有一个缺点,这些内联的 css 都是 HTML 资源中的一部分,而 HTML 资源不像 js、css 资源在浏览器的默认缓存行为中可以缓存很长时间。这也意味着如果内联的关键 css 内容非常多,会导致html的下载耗时较久。这些都是要考虑的问题。
Javascript 优化
Web 的大部分交互方式都是 JavaScript,传递过多 JavaScript 可能会导致我们的网页在加载期间响应缓慢,影响用户体验。
阻塞渲染的 JavaScript
加载不带 defer 或 async 标识的 <script>
标签时, 浏览器将阻止解析和渲染,直到脚本完成下载、解析、执行完成。同样,内联脚本也会阻止 HTML 的解析,直到脚本完成解析并执行完成。
async 与 defer
async 和 defer 允许在不阻塞 HTML 解析的情况下加载外部脚本,而 type=“module” 标识的模块脚本(包括内嵌脚本)可以理解为默认携带了 defer 标识的。不过,async 和 defer 也有一些区别的:
使用 async 标识的脚本会在下载后立即进行解析并执行(这也就意味着它会阻碍 html 的解析)。使用 defer 标识的脚本哪怕下载完了,也需要等到 HTML 解析完成后才执行。
此外,async 脚本可能会不按顺序执行,而 defer 脚本将按照它们在标记中出现的顺序执行。
使用 type=“module” 标识的 script 默认是 defer,但是依旧可以通过 async 标识来阻碍 html 的解析。
客户端渲染(Client Side Rendering, SSR)
通常来说,不应该通过 JavaScript 来渲染任何关键内容或者是页面中最大的元素(这个元素会被用来计算 LCP 的值)。通过 JavaScript 来渲染内容的方法被称为客户端渲染,广泛应用于单页应用 (SPA)。像我们熟知的 vue、react 等可以创建 SPA 应用。
通过 JavaScript 生成的标签不会被 preload 扫描器识别,因此这些资源都不能进行预加载,导致某些关键资源的下载被延迟,造成一些性能指标的结果偏大(比如最大元素是一张图片,此时 LCP 的结果偏大)。浏览器在下载这个图片之前首先得等到 JavaScript 下载、解析、执行完之后才能生成一个 img 标签。
除此之外,通过 JavaScript 生成标签通常来说都会生成一个长任务。这些长任务会影响用户体验,如果在页面节点非常多得情况下,通过 JavaScript 修改这些节点是就会造成耗时较久的重渲染工作。
压缩
与前面讲的 CSS 类型,压缩 JavaScript 同样可以减小文件大小,加载下载速度,使得浏览器可以更快的进行 js 的解析、编译工作。
与 CSS 压缩不同的是,Javascript 不单止会删除一些无用的内容(比如空格,换行符及注释等),还是使用一些标识替换掉源文件中的一些变量名(这个叫做丑化,uglification)。
比如:
// 未压缩的代码
export function injectScript () {
const scriptElement = document.createElement('script');
scriptElement.src = '/js/scripts.js';
scriptElement.type = 'module';
document.body.appendChild(scriptElement);
}
// 压缩后的代码
export function injectScript(){const t=document.createElement("script");t.src="/js/scripts.js",t.type="module",document.body.appendChild(t)}
从上面的代码中可以看到源代码中的 scriptElement 这个变量名被简写为 t。
资源优先级
经过全面的学习我们知道了 CSS 与 Javascript 等资源会影响网页加载速度,我们可以通过异步加载或者改动标签位置来优化页面渲染。这一节我们来更进一步学习如何优化资源的加载。
资源提示通过告知浏览器如何加载以及资源的优先级(例如执行早期 DNS 查找、提前连接到服务器,甚至 在浏览器通常发现资源之前获取资源)使得页面具有更好的性能。
资源提示主要有五种:preconnect、dns-prefetch、preload、prefetch、fetchpriority
除此之外,fetch 方法支持设置优先级,不过不在本文的范围内,可以自行百度学习相关内容
preconnect
这个提示主要用于建立跨域的资源访问。比如,我们可能把一些图片或者其他资源放到CDN或者另一个资源服务器中,可以通过 preconnect 提示。
<link rel="preconnect" href="https://xxx">
通过配置 preconnect,我们可以指示浏览器需要尽快建立那个连接,最好是在 html 开始解析之前。
如果我们的页面存在非常多的跨域资源请求,可以在某些特别重要的资源里添加上 preconnect 标识。
Google Fonts 是 preconnect 的常见用例。
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
crossorigin 属性用于指明某个资源是否必须使用跨域资源共享 (CORS) 进行提取。如果请求的这个资源跨域了,需要添加 crossorigin 标识。如果我们省略 crossorigin 属性, 浏览器在下载这个资源时会打开新的连接,并不回重复使用通过 preconnect 提示提前打开的连接。
dns-prefetch
虽然可以通过 preconnect 提前与服务器建立连接,但是有可能会导致同一时间建立太多次跨域服务器连接。因此可以使用 dns-prefetch 提示。
从这个名字也可以看出,dns-prefetch不回直接与跨域服务器建立连接,而是提前执行 DNS 查找。
<link rel="dns-prefetch" href="https://fonts.googleapis.com">
<link rel="dns-prefetch" href="https://fonts.gstatic.com">
preload
preload 前面也介绍过,就是用于提前发起资源请求。
<link rel="preload" href="/lcp-image.jpg" as="image">
preload 提示应该只用于那些被延迟发现的关键资源。 最常见的用例是字体文件,以及通过 @import 提取的 CSS 文件或 background-image 等资源,而这些资源可能是 Largest Contentful Paint (LCP) 的候选项。
与 preconnect 类似,preload 提示也要求使用 crossorigin 属性。如果我们没有添加 crossorigin 属性(或者给非 CORS 请求添加了 crossorigin),都会造成浏览器下载两次,浪费了原本可能花在其他资源上的成本。
<link rel="preload" href="/font.woff2" as="font" crossorigin>
如果没有携带 as 属性,同样也会造成浏览器下载两次。
prefetch
prefetch 指令用于发起针对可能用于切换页面后,新页面所需要的资源:
<link rel="prefetch" href="/next-page.css" as="style">
该提示基本上遵循与 preload 提示相同的格式,与 preload 不同的是,prefetch 在很大程度上都是推测性的, 我们只是提前请求一个可能会被访问到的资源。
上面的代码指示浏览器在空闲的时候下载 next-page.css 这个资源。
fetchpriority
这个属性不同于前面提到的4种,它除了可以在 link 标签中使用外,还可以在 img、script 标签中使用。
<div class="gallery">
<div class="poster">
<img src="img/poster-1.jpg" fetchpriority="high">
</div>
<div class="thumbnails">
<img src="img/thumbnail-2.jpg" fetchpriority="low">
<img src="img/thumbnail-3.jpg" fetchpriority="low">
<img src="img/thumbnail-4.jpg" fetchpriority="low">
</div>
</div>
默认情况下,图片资源的优先级都是比较低的。但是在经过浏览器 layout 计算后,如果发现图片位于初始视口内,则会将优先级提高到 high 优先级。在前面的 HTML 代码段中,fetchpriority 用于指示浏览器下载较大的 LCP 图片,并使用高优先级。 而不太重要的缩略图则会以较低优先级下载。
现代浏览器分两个阶段加载资源。第一阶段主要是加载关键资源,在所有阻塞脚本都下载、执行完毕。在这个阶段中,低优先级的资源可能会被延迟下载。通过使用 fetchpriority=“high”,我们就可以手动提高资源的优先级,使得浏览器能够在第一阶段就下载该资源。
资源优先级总结:
一般来说,访问域名获取的 HTML、 以及预加载资源时携带 as=“style”,拥有最高优先级(highest)。普通的<script>
、<link>
标签、 使用 preload 的预加载,拥有高优先级(high)。使用了 async/defer 的<script>
、as=“script” 的预加载资源拥有低优先级(low)。使用了<link rel="stylesheet" href="/style.css" media="print" onload="this.media='all'">
这种方式的,和不加 as=“xxx” 的 prefetch 预加载,就相当于异步加载,拥有最低优先级(lowest)。