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

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

优化网页速度时,有一个容易被忽视的方面是需要了解一些浏览器内部机制。浏览器会以开发者无法实现的方式进行某些优化来提升性能,但前提是这些优化不会无意中阻碍。

我们有一项内部浏览器优化需要了解,那就是浏览器预加载扫描器。这篇博文将介绍预加载扫描器的工作原理,更重要的是,介绍如何避免妨碍它。

什么是预加载扫描器?

每个浏览器都有一个主 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 秒的人为延迟。
图4:通过模拟 3G 连接在移动设备上的 Chrome 上运行的网页 WebPageTest 网络瀑布图。即使样式表在开始加载之前通过代理人为延迟了 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> 元素之后,则会得到不太理想的结果:

此 WebPageTest 图表显示了注入脚本时预加载扫描失败。
图5:通过模拟 3G 连接在移动设备上的 Chrome 上运行的网页的 WebPageTest 网络瀑布图。该页面包含一个样式表和注入的 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>

结果如下:

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

有人可能会试图认为可以使用 rel=preload 解决这些问题。这样做当然可以,但可能会有一些副作用。毕竟,为什么使用 rel=preload 来修复可以通过不将 <script> 元素注入 DOM 来避免的问题呢?

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

预加载“修复程序”这里的问题,但这引入了一个新问题:前两个演示中的 async 脚本(尽管在 <head> 中加载)在“低”级别加载而样式表以“最高”加载优先级。在上一个预加载 async 脚本的演示中,样式表仍以“最高”加载但脚本的优先级已提升为“高”。

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

这里的答案很简单:如果在启动过程中需要脚本,不要通过将预加载扫描器注入 DOM 来使预加载扫描器失效。根据需要对 <script> 元素放置位置以及 deferasync 等属性进行实验。

使用 JavaScript 延迟加载

延迟加载是一种非常有效的数据节省方法,通常用于图片。但是,延迟加载有时会被错误地应用于“首屏”的图片。

这会带来与预加载扫描器相关的资源可检测性潜在问题,并且可能会不必要地延迟发现图片引用、下载、解码以及呈现图片引用所需的时间。以以下图片标记为例:

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

使用 data- 前缀是 JavaScript 赋能的延迟加载器中的一种常见模式。当图片滚动到视口中时,延迟加载器会去除 data- 前缀,也就是说,在前面的示例中,data-src 会变为 src。此更新会提示浏览器提取资源。

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

WebPageTest 网络瀑布图,显示了在启动期间位于视口中的延迟加载图片为何会发生延迟,这是因为浏览器预加载扫描器找不到图片资源,并且仅在延迟加载正常工作所需的 JavaScript 加载时才会加载。图片被发现的时间远远晚于应该找到的时间。
图8:通过模拟 3G 连接在移动设备上的 Chrome 上运行的网页的 WebPageTest 网络瀑布图。即使图片资源在启动期间在视口中可见,也进行了不必要的延迟加载。这会使预加载扫描器失效,并导致不必要的延迟。

它可能是 Largest Contentful Paint (LCP) 的候选元素,具体取决于图片的大小(具体取决于视口的大小)。当预加载扫描器无法推测提前提取图片资源时(可能在网页的样式表块呈现期间),LCP 会受到影响。

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

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

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

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

在这个简化示例中,在网络连接缓慢的情况下,LCP 缩短了 100 毫秒。这似乎不是一个巨大的改进,但当您认为此解决方案是一种快速的标记修正方法,并且大多数网页比这组示例更复杂时,就属于这种情况。这意味着,LCP 候选项可能需要与许多其他资源争夺带宽,因此像这样的优化变得越来越重要。

CSS 背景图片

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

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

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

WebPageTest 网络瀑布图,显示了使用背景图片属性从 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 网络瀑布图显示 CSS 背景图片(即 LCP 候选)的加载速度更快,因为使用了 rel=preload 提示。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:通过模拟 3G 连接在移动设备上的 Chrome 上运行的网页的 WebPageTest 网络瀑布图。网页的 LCP 候选项是从 <img> 元素加载的图片,但会被预加载扫描器发现,因为网页加载所需的 CSS 和字体位于不同的资源中,这样不会延迟预加载扫描器正常工作。

如果 CSS 和所有字体都内嵌为 base64 资源,会发生什么情况呢?

网页的 WebPageTest 网络瀑布图,其中包含一个外部 CSS 文件,该文件引用了四种字体。预加载扫描器在发现 LCP 映像方面出现显著延迟。
图13:通过模拟 3G 连接在移动设备上的 Chrome 上运行的网页的 WebPageTest 网络瀑布图。网页的 LCP 候选项是从 <img> 元素加载的图片,但对 `` 中的 CSS 及其四个字体资源进行内嵌会延迟预加载扫描器发现图片,直到这些资源完全下载为止。

内嵌的影响会给本例中的 LCP 以及总体性能带来负面影响。不内嵌任何内容的网页版本大约需要 3.5 秒才能绘制 LCP 图片。内嵌所有内容的网页只有超过 7 秒才会绘制 LCP 图片。

这里除了预加载扫描器之外,还有更多功能。内嵌字体不是一个好策略,因为 base64 对二进制资源而言是一种低效的格式。另一个影响因素是,除非 CSSOM 确定需要,否则不会下载外部字体资源。当这些字体作为 base64 内嵌时,无论当前页面是否需要它们,它们都会下载下来。

预加载是否可以改善这一点?好的。您可以预加载 LCP 图片并缩短 LCP 用时,但使用内嵌资源使可能无法缓存的 HTML 变得臃肿,还会降低性能。首次内容绘制 (FCP) 也会受到这种模式的影响。在未内嵌任何内容的页面版本中,FCP 大约为 2.7 秒。在所有内容都内嵌的版本中,FCP 大约为 5.8 秒。

将内容(尤其是使用 base64 编码的资源)内嵌到 HTML 中时要格外小心。一般来说,我们不建议这样做,但对于非常小的资源除外。尽可能少地内嵌,因为过多内联会造成问题。

使用客户端 JavaScript 呈现标记

这毋庸置疑:JavaScript 肯定会影响网页速度。开发者不仅依靠 Google Play 提供互动性,还倾向于依靠 Google Cloud 来提供内容本身。这在某种程度上可以带来更好的开发者体验:但为开发者带来的好处并不总能为用户带来好处。

有一种模式可以让预加载扫描器失效,那就是使用客户端 JavaScript 呈现标记:

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

当标记有效负载包含在浏览器中并完全由 JavaScript 呈现时,该标记中的任何资源实际上对预加载扫描器不可见。这会延迟重要资源的发现,这无疑会影响 LCP。在这些示例中,与不需要显示 JavaScript 的等效服务器渲染体验相比,对 LCP 图片的请求明显延迟。

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

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

如果您的网页确实需要使用 JavaScript 将功能附加到网页标记的某些部分,您仍然可以使用 SSR(使用 vanilla JavaScript 或 hydration)来做到两全其美。

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

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

总结一下,您可以从本帖子中学到以下内容:

  • 浏览器预加载扫描器是一个辅助 HTML 解析器,如果它被屏蔽,则会先于主要解析器进行扫描,以适时发现它可以更快地提取的资源。
  • 预加载扫描器无法发现在初始导航请求中由服务器提供的标记中不存在的资源。可以击败预加载扫描器的方式可能包括(但不限于):
    • 使用 JavaScript 将资源注入 DOM 中,可以是脚本、图片、样式表,或其他任何在来自服务器的初始标记有效负载中表现更好的资源。
    • 使用 JavaScript 解决方案延迟加载可见区域上方的图片或 iframe。
    • 在客户端上呈现可能包含使用 JavaScript 的文档子资源引用的标记。
  • 预加载扫描器只会扫描 HTML。它不会检查其他资源(尤其是 CSS)的内容,其中可能包含对重要资源(包括 LCP 候选资源)的引用。

如果出于任何原因,您无法避免对预加载扫描器加速加载性能的能力产生负面影响的模式,请考虑使用 rel=preload 资源提示。如果您确实使用了 rel=preload,请在实验室工具中进行测试,以确保它可以产生预期效果。最后,不要预加载过多资源,因为当您确定各项内容的优先顺序时,什么都不会预加载。

资源

主打图片来自 UnspinMohammad Rahmani