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

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

优化网页速度时有一个忽视的因素,那就是对浏览器内部原理有所了解。浏览器会进行特定的优化来提高性能,这是开发者无法做到的,但前提是这些优化不会无意中受到阻碍。

需要了解的一项内部浏览器优化措施是浏览器预加载扫描程序。这篇博文将介绍预加载扫描程序的运作方式,更重要的是,您应如何避免受到干扰。

什么是预加载扫描程序?

每个浏览器都有一个主要的 HTML 解析器,用于对原始标记进行标记化处理并将其处理为对象模型。这一切都很愉快,直到解析器在发现阻塞性资源(例如加载了 <link> 元素的样式表,或加载了 <script> 元素但没有 asyncdefer 属性的脚本)时暂停。

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 网络瀑布流图。虽然样式表在开始加载前人为地通过代理延迟两秒,但预加载扫描器会发现标记载荷中靠后的图片。

如瀑布所示,即使呈现和文档解析被阻止,预加载扫描器也会发现 <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:在移动设备上通过模拟 3G 连接在 Chrome 中运行的网页的 WebPageTest 网络瀑布流图表。该网页包含一个样式表和一个注入的 async 脚本。预加载扫描程序在呈现阻塞阶段无法发现该脚本,因为该脚本已注入到客户端。

我们来详细了解一下发生的情况:

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

这样效果欠佳,因为只有在样式表下载完毕后,才会发出脚本请求。这会延迟脚本尽快运行。相比之下,由于可以在服务器提供的标记中找到 <img> 元素,因此预加载扫描器会发现该元素。

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

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

结果如下:

WebPageTest 网络瀑布流显示了如何使用 HTML 脚本元素加载的异步脚本,即使浏览器的主要 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 脚本的演示中,样式表仍以“Highest”优先级加载,但脚本的优先级已提升为“High”。

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

这里的答案很简单:如果在启动期间需要脚本,不要通过将其注入 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:在移动设备上通过模拟 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:在移动设备上通过模拟 3G 连接在 Chrome 中运行的网页的 WebPageTest 网络瀑布流图表。网页的 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:在移动设备上通过模拟 3G 连接在 Chrome 中运行的网页的 WebPageTest 网络瀑布流图表。网页的 LCP 候选元素是一个具有 CSS background-image 属性的元素(第 3 行)。rel=preload 提示有助于浏览器发现图片的时间比没有该提示快 250 毫秒左右。

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

内嵌的资源过多

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

内嵌资源比下载资源更快,因为系统不会针对相应资源发出单独的请求。它就在文档中,可以即时加载。不过,这样做也存在一些显著的缺点:

  • 如果您没有缓存 HTML,并且在 HTML 响应为动态时无法缓存,则内联资源永远不会被缓存。这会影响性能,因为内联资源不可重复使用。
  • 即使您可以缓存 HTML,内联资源也不会在文档之间共享。与可在整个源中缓存和重复使用的外部文件相比,这会降低缓存效率。
  • 如果内嵌内容过多,则会导致预加载扫描器延迟在文档中发现资源,因为下载额外的内嵌内容会花费更长的时间。

此页面为例。在某些情况下,LCP 候选元素是位于页面顶部的图片,而 CSS 则位于由 <link> 元素加载的单独文件中。该页面还使用了四种网页字体,它们作为与 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 变得膨胀,会产生其他负面影响。First Contentful Paint (FCP) 也会受到此模式的影响。在未内嵌任何内容的网页版本中,FCP 约为 2.7 秒。在内联所有内容的版本中,FCP 约为 5.8 秒。

在将内容内嵌到 HTML 中时要格外小心,尤其是以 base64 编码的资源。一般不建议这样做,除非资源非常少。尽可能少内嵌,因为内联过多会起火。

使用客户端 JavaScript 呈现标记

毫无疑问,JavaScript 确实会影响网页速度。开发者不仅依赖应用来提供互动性,还倾向于依赖应用本身来提供内容。这在一定程度上可以带来更好的开发者体验;但对开发者的好处并不总是能给用户带来好处。

一种可能使预加载扫描程序失效的模式是使用客户端 JavaScript 呈现标记:

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

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

这有点偏离本文的重点,但在客户端上渲染标记的影响远不止于打败预加载扫描程序。首先,引入 JavaScript 来支持不需要的体验会引入不必要的处理时间,从而影响与下一次绘制的交互 (INP)

此外,与服务器发送的相同数量的标记相比,在客户端上渲染大量标记更有可能生成长任务。原因就是,除了 JavaScript 涉及的额外处理之外,浏览器还会从服务器流式传输标记,并对渲染进行大块渲染,以避免耗时较长的任务。另一方面,客户端呈现的标记作为单个整体任务进行处理,除了 INP 之外,这可能会影响页面响应能力指标,例如总阻塞时间 (TBT)First Input Delay (FID)

针对这种情况的补救措施取决于以下问题的答案:是否提供了网页标记无法由服务器提供、无法在客户端上呈现的原因?如果答案是“否”,则应尽可能考虑服务器端渲染 (SSR) 或静态生成的标记,因为这将有助于预加载扫描程序提前发现重要资源并适时地提取资源。

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

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

预加载扫描程序是一项非常有效的浏览器优化功能,有助于加快网页在启动期间的加载速度。避免让它无法提前发现重要资源的模式,您不仅可以为自己简化开发工作,还可以打造更好的用户体验,在许多指标(包括一些网页指标)上提供更理想的结果。

简而言之,以下是您希望从本帖中获得的信息:

  • 浏览器预加载扫描程序是一种辅助 HTML 解析器,如果它被阻止,则会优先扫描主要 HTML 解析器,以便适时发现可以更快地提取的资源。
  • 预加载扫描器无法发现初始导航请求服务器提供的标记中不存在的资源。预加载扫描程序失效的方式可能包括(但不限于):
    • 使用 JavaScript 将资源注入到 DOM 中,不管是脚本、图片、样式表,还是任何可能更适合从服务器的初始标记有效负载的资源。
    • 使用 JavaScript 解决方案延迟加载首屏图片或 iframe。
    • 在客户端上渲染可能包含对使用 JavaScript 的文档子资源引用的标记。
  • 预加载扫描程序仅扫描 HTML。它不会检查其他资源(尤其是 CSS)的内容,而这些资源可能会引用重要资源,包括 LCP 候选资源。

如果出于任何原因,您无法避免某种模式会对预加载扫描器的加载速度产生负面影响,请考虑使用 rel=preload 资源提示。如果您确实会使用 rel=preload,请在实验室工具中进行测试,以确保它可以产生所需的效果。最后,不要预加载太多资源,因为当您优先加载所有资源时,什么都不会有。

资源

主打图片来自 Unsplash 用户,由 Mohammad Rahmani 制作。