不要与浏览器的预加载扫描程序冲突

了解浏览器预加载扫描器是什么、它如何提升性能,以及如何避免干扰它。

优化网页速度时,有一个容易被忽视的方面是需要了解一些浏览器内部机制。浏览器会执行某些优化来提升性能,而我们开发者无法做到这一点,但前提是这些优化不会被无意中破坏。

需要了解的一项内部浏览器优化是浏览器预加载扫描器。本文将介绍预加载扫描器的运作方式,更重要的是,介绍如何避免妨碍其工作。

什么是预加载扫描器?

每个浏览器都有一个主要的 HTML 解析器,用于对原始标记进行分词,并将其处理为对象模型。所有这些都将顺利进行,直到解析器在找到阻塞资源(例如使用 <link> 元素加载的样式表,或使用没有 asyncdefer 属性的 <script> 元素加载的脚本)时暂停。

HTML 解析器示意图。
图 1:说明如何屏蔽浏览器的主要 HTML 解析器的示意图。在这种情况下,解析器会遇到外部 CSS 文件的 <link> 元素,该元素会阻止浏览器解析文档的其余部分,甚至阻止浏览器渲染任何文档内容,直到 CSS 被下载并解析为止。

对于 CSS 文件,系统会阻止渲染,以防止出现未设置样式的网页内容闪烁 (FOUC) 问题,即在为网页应用样式之前,网页的未设置样式版本会短暂显示。

web.dev 首页的未设置样式状态(左)和设置样式状态(右)。
图 2:FOUC 的模拟示例。左侧是未应用样式的 web.dev 首页。右侧是应用了样式的同一页面。如果浏览器在下载和处理样式表时不阻塞渲染,则未设置样式的状态可能会瞬间出现。

此外,如果浏览器遇到没有 deferasync 属性的 <script> 元素,也会阻止解析和呈现网页。

原因在于,在主要 HTML 解析器仍在执行其工作时,浏览器无法确定任何给定脚本是否会修改 DOM。因此,通常的做法是在文档末尾加载 JavaScript,以便阻塞解析和呈现的影响变得微不足道。

这些都是浏览器同时阻止解析和呈现的原因。不过,阻止这两个重要步骤都不利,因为这可能会延迟发现其他重要资源,从而导致节目延迟。幸运的是,浏览器会尽力通过一个名为预加载扫描器的辅助 HTML 解析器来缓解这些问题。

主 HTML 解析器(左)和预加载扫描器(右)的示意图,后者是次要 HTML 解析器。
图 3:此图展示了预加载扫描器如何与主 HTML 解析器并行工作以推测性加载资源。在这里,主 HTML 解析器会被阻塞,因为它需要先加载和处理 CSS,然后才能开始处理 <body> 元素中的图片标记,但预加载扫描器可以在解除主 HTML 解析器的阻塞之前,在原始标记中查找该图片资源并开始加载它。

预加载扫描器的作用是推测性的,这意味着它会检查原始标记,以便在主要 HTML 解析器发现这些资源之前,找到可机会性提取的资源。

如何判断预加载扫描器何时在运行

预加载扫描器之所以存在,是因为渲染和解析被阻塞。如果这两个性能问题从未出现过,预加载扫描器就不会很有用。确定网页能否从预加载扫描程序中受益的关键在于这些阻塞现象。为此,您可以为请求引入人工延迟,以了解预加载扫描器的工作位置。

此页面为例,该页面包含基本文本和图片以及样式表。由于 CSS 文件会阻塞渲染和解析,因此您可以通过代理服务为样式表引入 2 秒的人工延迟。这样一来,您就可以更轻松地在网络瀑布流中查看预加载扫描器的工作位置。

WebPageTest 网络瀑布图展示了对样式表强加的 2 秒人为延迟。
图 4WebPageTest 网络广告瀑布流图,显示在移动设备上通过模拟的 3G 连接在 Chrome 中运行的网页。即使样式表在开始加载之前通过代理人为期 2 秒人为延迟,预加载扫描器也会发现位于标记载荷后面的图片。

如瀑布流图所示,即使在渲染和文档解析被阻塞时,预加载扫描程序也会发现 <img> 元素。如果没有此优化,浏览器将无法在阻塞期间机会性地提取内容,并且更多资源请求将是连续的,而不是并发的。

介绍完这个玩具示例后,我们来看看一些可绕过预加载扫描器的真实模式,以及可以采取哪些措施来解决这些问题。

注入的 async 脚本

假设您的 <head> 中包含一些内嵌 JavaScript 的 HTML,如下所示:

<script>
  const scriptEl = document.createElement('script');
  scriptEl.src = '/yall.min.js';

  document.head.appendChild(scriptEl);
</script>

默认情况下,注入的脚本为 async,因此在注入此脚本时,其行为就像应用了 async 属性一样。这意味着,它会尽快运行,并且不会阻塞渲染。听起来不错,对吧?不过,如果您假定此内嵌 <script> 位于用于加载外部 CSS 文件的 <link> 元素之后,则会得到不太理想的结果:

此 WebPageTest 图表显示了在注入脚本时预加载扫描被破解的情况。
图 5:WebPageTest 网络广告瀑布流图,显示在移动设备上通过模拟的 3G 连接在 Chrome 中运行的网页。该网页包含一个样式表和一个注入的 async 脚本。预加载扫描程序无法在阻塞渲染阶段发现该脚本,因为该脚本会注入到客户端。

我们来详细了解一下发生了什么:

  1. 在 0 秒时,系统会请求主文档。
  2. 1.4 秒时,导航请求的第一个字节到达。
  3. 在 2.0 秒时,系统会请求 CSS 和图片。
  4. 由于解析器被阻止加载样式表,并且用于注入 async 脚本的内嵌 JavaScript 在 2.6 秒后才会跟随该样式表,因此该脚本提供的功能无法尽快提供。

这不太理想,因为只有在样式表下载完毕后,才会请求脚本。这会延迟脚本尽快运行。相比之下,由于 <img> 元素可在服务器提供的标记中发现,因此它会被预加载扫描器发现。

那么,如果您使用带有 async 属性的常规 <script> 标记(而不是将脚本注入 DOM),会发生什么情况?

<script src="/yall.min.js" async></script>

结果如下:

WebPageTest 网络瀑布图,显示了即使浏览器的主要 HTML 解析器在下载和处理样式表时被阻塞,浏览器预加载扫描程序仍可发现使用 HTML 脚本元素加载的异步脚本。
图 6:WebPageTest 网络广告瀑布流图,显示在移动设备上通过模拟的 3G 连接在 Chrome 中运行的网页。该页面包含一个样式表和一个 async <script> 元素。预加载扫描程序会在阻塞渲染阶段发现脚本,并与 CSS 同时加载该脚本。

您可能会建议使用 rel=preload 来解决这些问题。这种方法肯定可行,但可能会产生一些副作用。毕竟,为什么要使用 rel=preload 来解决一个可以通过<script> 元素注入 DOM 来避免的问题?

WebPageTest 广告瀑布流,显示了如何使用 rel=preload 资源提示来促进发现异步注入的脚本,但这种方式可能会产生意外的副作用。
图 7:WebPageTest 网络广告瀑布流图,显示在移动设备上通过模拟的 3G 连接在 Chrome 中运行的网页。该网页包含一个样式表和一个注入的 async 脚本,但 async 脚本已预加载,以确保系统能更快发现它。

预加载可以“解决”此问题,但会引入一个新问题:前两个演示中的 async 脚本(尽管是在 <head> 中加载的)以“低”优先级加载,而样式表以“最高”优先级加载。在预加载 async 脚本的上一个演示中,样式表仍以“最高”优先级加载,但脚本的优先级已提升为“高”。

提高资源的优先级后,浏览器会为其分配更多带宽。这意味着,即使样式表具有最高优先级,脚本的优先级提高也可能会导致带宽争用。这可能是连接速度缓慢或资源非常大的情况下的一个因素。

答案很简单:如果在启动期间需要脚本,请勿通过将其注入 DOM 来规避预加载扫描器。根据需要,对 <script> 元素放置以及 deferasync 等属性进行实验。

使用 JavaScript 延迟加载

延迟加载是一种非常有效的数据节省方法,通常用于图片。不过,有时系统会错误地将延迟加载应用于“可见区域上方”的图片。

这会导致资源可检测性方面存在潜在问题(与预加载扫描器相关),并且可能会不必要地延长发现图片引用、下载图片、解码图片和显示图片所需的时间。以以下图片标记为例:

<img data-src="/sand-wasp.jpg" alt="Sand Wasp" width="384" height="255">

在依托 JavaScript 的延迟加载器中,使用 data- 前缀是一种常见模式。当图片滚动到视口中时,延迟加载器会移除 data- 前缀,这意味着在上例中,data-src 会变为 src。此更新会提示浏览器提取资源。

在启动期间应用于视口中的图片之前,这种模式不会出现问题。由于预加载扫描器读取 data-src 属性的方式与读取 src(或 srcset)属性的方式不同,因此系统不会提前发现图片引用。更糟糕的是,图片的加载会延迟到延迟加载器 JavaScript 下载、编译和执行完毕之后。

WebPageTest 网络广告瀑布流图,显示了在启动期间位于视口内的延迟加载图片是如何延迟的,因为浏览器预加载扫描器找不到图片资源,并且仅在延迟加载所需的 JavaScript 加载后才会加载。图片的发现时间远远晚于应有时间。
图 8:WebPageTest 网络广告瀑布流图,显示在移动设备上通过模拟的 3G 连接在 Chrome 中运行的网页。图片资源会不必要地延迟加载,即使它在启动期间在视口中可见也是如此。这会破坏预加载扫描器,并导致不必要的延迟。

根据图片的大小(可能取决于视口的大小),它可能是 Largest Contentful Paint (LCP) 的候选元素。如果预加载扫描器无法提前推测性提取图片资源(可能在网页的样式表阻止呈现时),LCP 就会受到影响。

解决方法是更改图片标记:

<img src="/sand-wasp.jpg" alt="Sand Wasp" width="384" height="255">

对于启动期间位于视口中的图片,这是最佳模式,因为预加载扫描器将更快地发现和提取图片资源。

WebPageTest 网络瀑布图,显示了启动期间视口中图片的加载场景。图片不会延迟加载,这意味着它不依赖于脚本加载,因此预加载扫描器可以更快地发现它。
图 9:WebPageTest 网络广告瀑布流图,显示在模拟的 3G 连接上通过移动设备上的 Chrome 运行的网页。预加载扫描器会在 CSS 和 JavaScript 开始加载之前发现图片资源,从而让浏览器提前加载该资源。

在这个简化示例中,在网络连接缓慢的情况下,LCP 缩短了 100 毫秒。这项改进可能看起来并不显著,但如果您考虑到该解决方案只是快速修复标记,并且大多数网页都比这组示例更复杂,就会发现这项改进很有用。这意味着,LCP 候选项可能必须与许多其他资源争夺带宽,因此像这样的优化变得越来越重要。

CSS 背景图片

请注意,浏览器预加载扫描程序会扫描标记。它不会扫描其他资源类型,例如 CSS,这可能涉及提取 background-image 属性引用的图片。

与 HTML 一样,浏览器会将 CSS 处理成自己的对象模型,称为 CSSOM。如果在构建 CSSOM 时发现外部资源,则会在发现这些资源时请求这些资源,而不是由预加载扫描器请求。

假设您网页的 LCP 候选元素是具有 CSS background-image 属性的元素。资源加载时会发生以下情况:

WebPageTest 网络瀑布图,显示了使用 background-image 属性从 CSS 加载 LCP 候选项的网页。由于 LCP 候选图片的资源类型是浏览器预加载扫描器无法检查的,因此该资源会延迟加载,直到 CSS 下载并处理完毕,从而延迟 LCP 候选图片的绘制时间。
图 10:WebPageTest 网络瀑布图,显示在移动设备上通过模拟 3G 连接在 Chrome 中运行的网页的网络请求情况。网页的 LCP 候选元素是具有 CSS 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 提示很小,但有助于浏览器更快地发现图片:

WebPageTest 网络广告瀑布流图,显示由于使用了 rel=preload 提示,CSS 背景图片(即 LCP 候选项)的加载时间缩短了很多。LCP 时间缩短了约 250 毫秒。
图 11:WebPageTest 网络广告瀑布流图,显示在移动设备上通过模拟的 3G 连接在 Chrome 中运行的网页。网页的 LCP 候选元素是具有 CSS background-image 属性的元素(第 3 行)。与不使用提示相比,rel=preload 提示可帮助浏览器大约提前 250 毫秒发现图片。

借助 rel=preload 提示,系统会更快发现 LCP 候选项,从而缩短 LCP 时间。虽然该提示有助于解决此问题,但更好的做法可能是评估您的图片 LCP 候选对象是否必须从 CSS 加载。借助 <img> 标记,您可以更好地控制加载适合视口的图片,同时允许预加载扫描器发现该图片。

内嵌的资源过多

内嵌是一种将资源放置在 HTML 中的做法。您可以使用 Base64 编码<style> 元素中内嵌样式表、在 <script> 元素中内嵌脚本,以及内嵌几乎任何其他资源。

内嵌资源的速度可能比下载资源更快,因为系统不会为资源发出单独的请求。它就在文档中,并且会立即加载。不过,这类方法存在明显缺点:

  • 如果您未缓存 HTML(如果 HTML 响应是动态的,您根本无法缓存),则内嵌的资源将永远不会缓存。这会影响性能,因为内嵌的资源不可重复使用。
  • 即使您可以缓存 HTML,但内嵌的资源不会在文档之间共享。与可在整个源中缓存和重复使用的外部文件相比,这会降低缓存效率。
  • 如果您内嵌的内容过多,会延迟预加载扫描器发现文档后续部分的资源,因为下载这些额外的内嵌内容需要更长时间。

此页面为例。在某些情况下,LCP 候选项是页面顶部的图片,CSS 位于由 <link> 元素加载的单独文件中。该网页还使用了四种 Web 字体,这些字体是从 CSS 资源请求的单独文件。

网页的 WebPageTest 网络广告瀑布流图,其中包含一个外部 CSS 文件,该文件中引用了四种字体。预加载扫描器会及时发现 LCP 候选图片。
图 12:WebPageTest 网络广告瀑布流图,显示在移动设备上通过模拟的 3G 连接在 Chrome 中运行的网页。网页的 LCP 候选项是从 <img> 元素加载的图片,但被预加载扫描器发现,因为网页加载所需的 CSS 和字体位于单独的资源中,这不会延迟预加载扫描器执行其工作。

现在,如果 CSS 所有字体都作为 base64 资源内嵌,会出现什么情况?

网页的 WebPageTest 网络广告瀑布流图,其中包含一个外部 CSS 文件,该文件中引用了四种字体。预加载扫描器在发现 LCP 图片时会出现明显延迟。
图 13:WebPageTest 网络广告瀑布流图,显示在模拟的 3G 连接下,在移动设备上的 Chrome 中运行的网页的广告请求。网页的 LCP 候选项是从 <img> 元素加载的图片,但由于在“”中内嵌 CSS 及其四个字体资源,因此预加载扫描器会延迟发现该图片,直到这些资源完全下载为止。

在本例中,内嵌的影响会对 LCP 和整体性能产生负面影响。不内嵌任何内容的网页版本大约需要 3.5 秒才能绘制 LCP 图片。将所有内容内嵌的网页在超过 7 秒后才会绘制 LCP 图片。

这里涉及的不仅仅是预加载扫描器。内嵌字体不是一个好策略,因为 base64 对二进制资源而言是一种低效的格式。另一个影响因素是,除非 CSSOM 确定外部字体资源是必需的,否则系统不会下载这些资源。当这些字体以 base64 格式内嵌时,无论当前网页是否需要,系统都会下载它们。

预加载能否改善此问题?好的。您可以预加载 LCP 图片并缩短 LCP 时间,但使用内嵌资源膨胀可能无法缓存的 HTML 会产生其他负面性能影响。首次内容绘制 (FCP) 也会受到这种模式的影响。在未内嵌任何内容的页面版本中,FCP 大约为 2.7 秒。在所有内容都内嵌的版本中,FCP 大约为 5.8 秒。

请务必谨慎地将内容内嵌到 HTML 中,尤其是 base64 编码的资源。一般来说,我们不建议这样做,但对于非常小的资源除外。尽可能少内嵌,因为内嵌过多会带来风险。

使用客户端 JavaScript 渲染标记

毫无疑问:JavaScript 确实会影响网页速度。开发者不仅依赖它来提供互动性,还倾向于依赖它来提供内容本身。这在某些方面会带来更好的开发者体验;但开发者获得的好处并不一定会转化为用户的好处。

一种可以欺骗预加载扫描器的模式是使用客户端 JavaScript 呈现标记:

WebPageTest 网络广告瀑布流,显示了一个基本网页,其中的图片和文本完全在客户端使用 JavaScript 呈现。由于标记包含在 JavaScript 中,因此预加载扫描器无法检测到任何资源。由于 JavaScript 框架需要额外的网络和处理时间,因此所有资源都会额外延迟。
图 14:WebPageTest 网络广告瀑布流图,显示了在模拟的 3G 连接上通过 Chrome 在移动设备上运行的客户端呈现的网页。由于内容包含在 JavaScript 中并依赖于框架进行渲染,因此客户端渲染的标记中的图片资源会被预加载扫描器隐藏。等效的服务器渲染体验如图 9 所示。

如果标记载荷包含在浏览器中的 JavaScript 中并完全由 JavaScript 呈现,那么预加载扫描器实际上无法看到该标记中的任何资源。这会延迟发现重要资源,这肯定会影响 LCP。在这些示例中,与不需要显示 JavaScript 的等效服务器呈现体验相比,LCP 图片的请求会出现显著延迟。

这有点偏离本文的重点,但在客户端上呈现标记的影响远远超出了对抗预加载扫描器的效果。一方面,引入 JavaScript 来为不需要 JavaScript 的体验提供支持会带来不必要的处理时间,这可能会影响 Interaction to Next Paint (INP)。与服务器发送相同数量的标记相比,在客户端上渲染极大量的标记更有可能生成长任务。除了 JavaScript 涉及的额外处理之外,造成这种情况的另一个原因是,浏览器会从服务器流式传输标记并将渲染分块化,这往往会限制长时间运行的任务。另一方面,客户端呈现的标记会作为单个单体任务进行处理,这可能会影响网页的 INP。

解决此问题的方法取决于您对以下问题的回答:是否有原因导致您的网页标记无法由服务器提供,而必须在客户端呈现?如果答案为“否”,则应尽可能考虑使用服务器端渲染 (SSR) 或静态生成的标记,因为这有助于预加载扫描器提前发现重要资源并伺机提取。

如果您的网页需要使用 JavaScript 将功能附加到网页标记的某些部分,您仍然可以使用 SSR 来实现这一点,方法是使用原生 JavaScript 或注水,从而将两者的优势结合起来。

帮助预加载扫描器为您提供帮助

预加载扫描器是一项非常有效的浏览器优化功能,可帮助页面在启动期间更快地加载。通过避免会妨碍 Chrome 提前发现重要资源的模式,您不仅可以简化开发工作,还可以打造更好的用户体验,从而提升许多指标(包括一些网页指标)的效果。

总结一下,您可以从这篇文章中获得以下收获:

  • 浏览器预加载扫描程序是一种辅助 HTML 解析器,如果主解析器被阻塞,它会在主解析器之前进行扫描,以便机会性地发现可以更早提取的资源。
  • 预加载扫描器无法发现在初始导航请求中由服务器提供的标记中不存在的资源。可破解预加载扫描器的方法可能包括但不限于:
    • 使用 JavaScript 将资源注入 DOM,这些资源可以是脚本、图片、样式表,也可以是任何其他在服务器的初始标记载荷中更合适的资源。
    • 使用 JavaScript 解决方案延迟加载可见区域上方的图片或 iframe。
    • 在客户端上呈现的标记可能包含使用 JavaScript 对文档子资源的引用。
  • 预加载扫描器仅扫描 HTML。它不会检查其他资源(尤其是 CSS)的内容,这些资源可能包含对重要资源(包括 LCP 候选资源)的引用。

如果您因故无法避免某种模式对预加载扫描器加快加载性能的能力产生负面影响,请考虑使用 rel=preload 资源提示。如果您确实使用 rel=preload,请在实验室工具中进行测试,确保它能达到预期效果。最后,请勿预加载过多资源,因为如果您为所有资源都设置了优先级,那么就没有任何资源是优先级最高的。

资源

主打图片来自 Unsplash,摄影师:Mohammad Rahmani