了解什么是浏览器预加载扫描器、它如何帮助提升性能,以及如何避免干扰它。
优化网页速度时,有一个容易被忽视的方面,那就是对浏览器内部机制的了解。浏览器会进行某些优化,以我们作为开发者无法做到的方式提升性能,但前提是这些优化不会被无意中阻碍。
浏览器预加载扫描器是一项需要了解的内部浏览器优化。本文将介绍预加载扫描器的工作原理,更重要的是,如何避免妨碍其工作。
什么是预加载扫描器?
每个浏览器都有一个主 HTML 解析器,用于将原始标记词法分析并将其处理为对象模型。这一切都会愉快地继续,直到解析器在发现阻塞资源时暂停,例如使用 <link> 元素加载的样式表,或使用不带 async 或 defer 属性的 <script> 元素加载的脚本。
<link> 元素,这会阻止浏览器解析文档的其余部分,甚至阻止浏览器呈现任何内容,直到下载并解析 CSS。
对于 CSS 文件,系统会阻止渲染,以防止出现无样式内容闪烁 (FOUC),即在应用样式之前,用户会短暂看到网页的无样式版本。
当浏览器遇到没有 defer 或 async 属性的 <script> 元素时,也会阻止网页的解析和渲染。
这是因为,在主 HTML 解析器仍在执行其工作时,浏览器无法确定任何给定脚本是否会修改 DOM。因此,通常的做法是在文档末尾加载 JavaScript,这样解析和渲染受阻的影响就会变得微乎其微。
这些都是浏览器应该同时阻止解析和渲染的充分理由。然而,阻止其中任何一个重要步骤都是不可取的,因为它们可能会延迟发现其他重要资源,从而拖慢整个过程。幸运的是,浏览器会通过名为“预加载扫描器”的辅助 HTML 解析器,尽力缓解这些问题。
<body> 元素中的图片标记,但预加载扫描器可以提前查看原始标记,找到该图片资源,并在主 HTML 解析器解除阻塞之前开始加载该资源。
预加载扫描器的作用是推测性的,这意味着它会检查原始标记,以便在主 HTML 解析器发现资源之前,伺机获取资源。
如何判断预加载扫描器何时在工作
预加载扫描器之所以存在,是因为渲染和解析被阻塞。如果这两个性能问题从未存在过,预加载扫描器就不会非常有用。要确定网页是否受益于预加载扫描程序,关键在于这些阻塞现象。为此,您可以为请求引入人为延迟,以找出预加载扫描器在何处工作。
以此页面为例,其中包含基本文本和图片,并使用了样式表。由于 CSS 文件会同时阻止渲染和解析,因此您通过代理服务为样式表引入了 2 秒的人为延迟。这种延迟让您更容易在网络瀑布图中看到预加载扫描器的工作位置。
如瀑布图所示,即使渲染和文档解析被阻塞,预加载扫描程序也会发现 <img> 元素。如果没有这项优化,浏览器就无法在阻塞期间伺机提取内容,并且会有更多资源请求是连续的,而不是并发的。
在了解了上述简单示例后,我们来看看一些可能导致预加载扫描器失效的实际模式,以及如何解决这些问题。
注入了 async 个脚本
假设您的 <head> 中有 HTML,其中包含一些内嵌 JavaScript,如下所示:
<script>
const scriptEl = document.createElement('script');
scriptEl.src = '/yall.min.js';
document.head.appendChild(scriptEl);
</script>
注入的脚本默认情况下为 async,因此在注入此脚本时,其行为就像应用了 async 属性一样。这意味着它会尽快运行,而不会阻止渲染。听起来很理想,对吧?不过,如果您假设此内嵌 <script> 位于加载外部 CSS 文件的 <link> 元素之后,则会得到不理想的结果:
async 脚本。预加载扫描器无法在渲染阻塞阶段发现该脚本,因为它是在客户端注入的。
我们来详细了解一下这里发生了什么:
- 在 0 秒时,系统会请求主文档。
- 在 1.4 秒时,导航请求的第一个字节到达。
- 在 2.0 秒时,系统会请求 CSS 和图片。
- 由于解析器在加载样式表时被阻塞,而注入
async脚本的内嵌 JavaScript 在 2.6 秒时出现在该样式表之后,因此该脚本提供的功能无法尽快可用。
这并非最佳做法,因为只有在样式表下载完成后,系统才会发出脚本请求。这会延迟脚本的运行。相比之下,由于 <img> 元素可在服务器提供的标记中被检测到,因此预加载扫描器会检测到该元素。
那么,如果您使用带有 async 属性的常规 <script> 标记,而不是将脚本注入 DOM,会发生什么情况?
<script src="/yall.min.js" async></script>
结果如下:
async <script> 元素。预加载扫描程序会在渲染阻塞阶段发现该脚本,并与 CSS 同时加载。
有人可能会建议使用 rel=preload 来解决这些问题。这当然可行,但可能会产生一些副作用。毕竟,如果可以通过不将 <script> 元素注入 DOM 来避免问题,为什么还要使用 rel=preload 来解决问题呢?
async 脚本,但 async 脚本已预加载,以确保能更快地被发现。
预加载在这里“解决”了问题,但它引入了一个新问题:前两个演示中的 async 脚本(尽管在 <head> 中加载)以“低”优先级加载,而样式表以“最高”优先级加载。在最后一个演示中,async 脚本已预加载,样式表仍以“最高”优先级加载,但脚本的优先级已提升为“高”。
当资源的优先级提高时,浏览器会为其分配更多带宽。这意味着,即使样式表具有最高优先级,脚本的优先级提高也可能会导致带宽争用。在连接速度较慢或资源相当大的情况下,这可能是一个因素。
答案很简单:如果启动期间需要脚本,请勿将其注入 DOM,以免破坏预加载扫描器。根据需要,尝试不同的 <script> 元素放置位置以及 defer 和 async 等属性。
使用 JavaScript 延迟加载
延迟加载是一种节省数据流量的好方法,通常应用于图片。不过,有时延迟加载会错误地应用于“首屏”图片。
这会给预加载扫描器带来潜在的资源可检测性问题,并可能不必要地延迟发现图片引用、下载、解码和呈现图片所需的时间。以下面的图片标记为例:
<img data-src="/sand-wasp.jpg" alt="Sand Wasp" width="384" height="255">
在由 JavaScript 提供支持的延迟加载器中,使用 data- 前缀是一种常见模式。当图片滚动到视口中时,延迟加载器会去除 data- 前缀,这意味着在前面的示例中,data-src 会变为 src。此更新会提示浏览器提取资源。
只有在启动期间将此模式应用于视口中的图片时,才会出现问题。由于预加载扫描器不会像读取 src(或 srcset)属性那样读取 data-src 属性,因此无法提前发现图片引用。更糟糕的是,图片会延迟加载,直到延迟加载器 JavaScript 下载、编译和执行之后。
根据图片的大小(可能取决于视口的大小),它可能是 Largest Contentful Paint (LCP) 的候选元素。如果预加载扫描器无法提前推测性地提取图片资源(可能是在网页的样式表阻止渲染期间),LCP 就会受到影响。
解决方案是更改图片标记:
<img src="/sand-wasp.jpg" alt="Sand Wasp" width="384" height="255">
对于启动期间位于视口中的图片,这是最佳模式,因为预加载扫描器会更快地发现和提取图片资源。
在这个简化的示例中,在连接速度较慢的情况下,LCP 缩短了 100 毫秒。这看起来可能不是很大的改进,但考虑到该解决方案只是一个快速标记修复,并且大多数网页都比这组示例更复杂,因此这一改进还是相当可观的。这意味着 LCP 候选元素可能需要与许多其他资源争夺带宽,因此此类优化变得越来越重要。
CSS 背景图片
请注意,浏览器预加载扫描程序会扫描标记。它不会扫描其他资源类型,例如可能涉及提取 background-image 属性所引用图片的 CSS。
与 HTML 一样,浏览器会将 CSS 处理成自己的对象模型,称为 CSSOM。如果在构建 CSSOM 时发现外部资源,系统会在发现时请求这些资源,而不是由预加载扫描器请求。
假设您网页的 LCP 候选元素是具有 CSS background-image 属性的元素。资源加载时会发生以下情况:
background-image 属性的元素(第 3 行)。只有在 CSS 解析器找到该图片后,系统才会开始提取该图片。
在这种情况下,预加载扫描器并非被击败,而是根本没有参与。即便如此,如果网页上的 LCP 候选元素来自 background-image CSS 属性,您仍需要预加载该图片:
<!-- Make sure this is in the <head> below any
stylesheets, so as not to block them from loading -->
<link rel="preload" as="image" href="lcp-image.jpg">
rel=preload 提示虽然很小,但有助于浏览器比平时更快地发现图片:
background-image 属性的元素(第 3 行)。rel=preload 提示可帮助浏览器比不使用提示时提前大约 250 毫秒发现图片。
借助 rel=preload 提示,系统可以更快地发现 LCP 候选元素,从而缩短 LCP 时间。虽然该提示有助于解决此问题,但更好的选择可能是评估是否必须从 CSS 加载 LCP 候选图片。借助 <img> 标记,您可以更好地控制如何加载适合视口的图片,同时允许预加载扫描器发现该图片。
内嵌过多资源
内嵌是指将资源放置在 HTML 内部。您可以使用 base64 编码在 <style> 元素中内嵌样式表,在 <script> 元素中内嵌脚本,以及内嵌几乎任何其他资源。
内嵌资源可能比下载资源更快,因为系统不会针对资源发出单独的请求。它就在文档中,可即时加载。不过,这种方法也存在明显的缺点:
- 如果您不缓存 HTML(如果 HTML 响应是动态的,则无法缓存),则内嵌资源永远不会被缓存。这会影响性能,因为内嵌资源无法重复使用。
- 即使您可以缓存 HTML,内嵌资源也不会在文档之间共享。与可在整个源站中缓存和重复使用的外部文件相比,这会降低缓存效率。
- 如果您内嵌过多内容,则会延迟预加载扫描器发现文档中后续资源的时间,因为下载这些额外的内嵌内容需要更长时间。
以此页面为例。在某些情况下,LCP 候选元素是页面顶部的图片,而 CSS 位于由 <link> 元素加载的单独文件中。该网页还使用了四种网页字体,这些字体是作为单独的文件从 CSS 资源中请求的。
<img> 元素加载的图片,但预加载扫描器会发现该图片,因为网页所需的 CSS 和字体会在单独的资源中加载,这不会延迟预加载扫描器执行其工作。
现在,如果 CSS 和所有字体都以内嵌方式作为 base64 资源,会发生什么情况?
<img> 元素加载的图片,但由于 CSS 及其四个字体资源内嵌在 `` 中,预加载扫描器在这些资源完全下载之前无法发现该图片。
在此示例中,内嵌的影响会对 LCP 产生负面影响,并且通常会对性能产生负面影响。不内嵌任何内容的网页版本会在大约 3.5 秒内绘制 LCP 图片。内嵌所有内容的网页在 7 秒多之后才绘制 LCP 图片。
这里涉及的不仅仅是预加载扫描器。内嵌字体并不是一个好策略,因为 base64 是一种低效的二进制资源格式。另一个影响因素是,除非 CSSOM 确定有必要,否则不会下载外部字体资源。当这些字体以内嵌方式作为 base64 编码时,无论当前网页是否需要,它们都会被下载。
预加载能否改善这种情况?好的。您可以预加载 LCP 图片并缩短 LCP 时间,但使用内嵌资源扩充可能无法缓存的 HTML 会对性能产生其他负面影响。First Contentful Paint (FCP) 也会受到此模式的影响。在未内嵌任何内容的网页版本中,FCP 大约为 2.7 秒。在所有内容都内嵌的版本中,FCP 大约为 5.8 秒。
将内容内嵌到 HTML 中时要非常小心,尤其是使用 Base64 编码的资源。一般来说,不建议这样做,除非资源非常小。尽可能少地内嵌,因为内嵌过多会带来风险。
使用客户端 JavaScript 呈现标记
毫无疑问,JavaScript 肯定会影响网页速度。开发者不仅依赖它来提供互动性,而且还倾向于依赖它来提供内容本身。这在某些方面带来了更好的开发者体验,但对开发者有利并不总是意味着对用户有利。
有一种模式可能会导致预加载扫描器失效,那就是使用客户端 JavaScript 呈现标记:
当标记载荷包含在浏览器中的 JavaScript 中并完全由其呈现时,该标记中的任何资源对预加载扫描器来说都是不可见的。这会延迟重要资源的发现,从而影响 LCP。在这些示例中,与不需要 JavaScript 即可显示的等效服务器渲染体验相比,LCP 图片的请求明显延迟。
这有点偏离了本文的重点,但在客户端上呈现标记的影响远不止于破坏预加载扫描器。首先,如果引入 JavaScript 来支持不需要它的体验,会增加不必要的处理时间,从而影响 Interaction to Next Paint (INP)。与服务器发送相同数量的标记相比,在客户端上呈现大量标记更有可能生成长任务。除了 JavaScript 涉及的额外处理之外,原因还在于浏览器会从服务器流式传输标记,并以往往会限制长任务的方式分块呈现。另一方面,客户端呈现的标记会作为单个单体式任务进行处理,这可能会影响网页的 INP。
此场景的补救措施取决于以下问题的答案:是否有理由说明网页的标记不能由服务器提供,而必须在客户端上呈现?如果答案为“否”,则应尽可能考虑使用服务器端渲染 (SSR) 或静态生成的标记,因为这有助于预加载扫描器提前发现并伺机提取重要资源。
如果您的网页确实需要 JavaScript 将功能附加到网页标记的某些部分,您仍然可以使用 SSR 来实现,方法是使用原生 JavaScript 或水合,从而兼顾两者的优势。
让预加载扫描器助您一臂之力
预加载扫描器是一种非常有效的浏览器优化功能,有助于在启动期间更快地加载网页。通过避免使用会破坏其提前发现重要资源的能力的模式,您不仅可以简化开发工作,还可以打造更好的用户体验,从而在许多指标(包括一些 Web Vitals)中取得更好的结果。
最后,让我们回顾一下本文的核心要点:
- 浏览器预加载扫描程序是一个辅助 HTML 解析器,如果主解析器被阻塞,它会提前扫描,以便伺机发现可以更快提取的资源。
- 预加载扫描器无法发现服务器在初始导航请求中提供的标记中不存在的资源。可能导致预加载扫描器失效的情况包括(但不限于):
- 使用 JavaScript 将资源注入 DOM,无论是脚本、图片、样式表,还是任何其他最好放在服务器初始标记载荷中的内容。
- 使用 JavaScript 解决方案延迟加载首屏图片或 iframe。
- 使用 JavaScript 在客户端上呈现可能包含对文档子资源引用的标记。
- 预加载扫描器仅扫描 HTML。它不会检查其他资源(尤其是 CSS)的内容,这些资源可能包含对重要资源(包括 LCP 候选资源)的引用。
如果出于某种原因,您无法避免采用会对预加载扫描器提升加载性能的能力产生负面影响的模式,请考虑使用 rel=preload 资源提示。如果您确实使用了 rel=preload,请在实验室工具中进行测试,确保它能达到预期效果。最后,请勿预加载过多资源,因为如果您将所有内容都设为优先,那么就没有什么内容是优先的。