不要与浏览器预加载扫描器作对

了解什么是浏览器预加载扫描器,它如何帮助提高性能,以及如何避免妨碍它。

优化页面速度的一个常被忽视的方面是了解一些浏览器内部原理。浏览器会进行某些优化以提高性能,而作为开发人员的我们无法做到这一点,但前提是这些优化不会被无意中破坏。

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

什么是预加载扫描器?

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

HTML parser diagram.
图 1: 浏览器主要 HTML 解析器如何被阻塞的图示。在本例中,解析器遇到外部 CSS 文件的 <link> 元素,这会阻止浏览器解析文档的其余部分,甚至渲染任何部分,直到 CSS 下载并解析完成。

对于 CSS 文件,渲染被阻止是为了防止未样式化内容闪烁 (FOUC),即在样式应用于页面之前,可以短暂地看到页面的未样式化版本。

The web.dev home page in an unstyled state (left) and the styled state (right).
图 2: FOUC 的模拟示例。左侧是没有样式的 web.dev 首页。右侧是应用样式后的同一页面。如果浏览器在下载和处理样式表时没有阻止渲染,则可能会发生未样式化状态的闪烁。

当浏览器遇到不带 deferasync 属性的 <script> 元素时,也会阻止页面的解析和渲染。

这样做的原因是浏览器无法确定任何给定脚本是否会在主 HTML 解析器仍在工作时修改 DOM。这就是为什么将 JavaScript 加载到文档末尾已成为一种常见的做法,这样阻塞解析和渲染的影响就会变得微乎其微。

这些都是浏览器应该阻止解析和渲染的充分理由。然而,阻止这些重要步骤中的任何一个都是不可取的,因为它们会通过延迟发现其他重要资源来拖慢进度。值得庆幸的是,浏览器通过称为预加载扫描器的辅助 HTML 解析器尽力缓解这些问题。

A diagram of both the primary HTML parser (left) and the preload scanner (right), which is the secondary HTML parser.
图 3: 图示描述了预加载扫描器如何与主 HTML 解析器并行工作以推测性地加载资源。在此图中,主 HTML 解析器在加载和处理 CSS 时被阻止,然后才能开始处理 <body> 元素中的图像标记,但预加载扫描器可以提前查看原始标记以查找该图像资源,并在主 HTML 解析器解除阻塞之前开始加载它。

预加载扫描器的作用是推测性的,这意味着它检查原始标记以查找机会性地获取的资源,然后再由主 HTML 解析器发现它们。

如何判断预加载扫描器何时工作

预加载扫描器的存在是因为阻塞渲染和解析。如果这两个性能问题从未存在,那么预加载扫描器就不会非常有用。弄清楚网页是否从预加载扫描器中受益的关键取决于这些阻塞现象。为此,您可以为请求引入人为延迟,以找出预加载扫描器的工作位置。

此页面的基本文本和带有样式表的图像为例。由于 CSS 文件会阻止渲染和解析,因此您通过代理服务为样式表引入两秒的人为延迟。这种延迟使得在网络瀑布图中更容易看到预加载扫描器的工作位置。

The WebPageTest network waterfall chart illustrates an artificial delay of 2 seconds imposed on the styleesheet.
图 4: WebPageTest 网络瀑布图,显示了在模拟 3G 连接的移动设备上的 Chrome 上运行的 网页。即使样式表通过代理被人为延迟了两秒钟才开始加载,但位于标记有效负载后部的图像仍被预加载扫描器发现。

正如您在瀑布图中看到的那样,预加载扫描器即使在渲染和文档解析被阻止时也发现了 <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> 元素之后,您将得到次优的结果:

This WebPageTest chart shows the preload scan defeated when a script is injected.
图 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>

这是结果:

A WebPageTest network waterfall depicting how an async script loaded by using the HTML script element is still discoverable by the browser preload scanner, even though the browser's primary HTML parser is blocked while downloading and processing a stylesheet.
图 6: WebPageTest 网络瀑布图,显示了在模拟 3G 连接的移动设备上的 Chrome 上运行的 网页。该页面包含单个样式表和单个 async <script> 元素。预加载扫描器在渲染阻塞阶段发现了脚本,并将其与 CSS 并发加载。

可能会有人试图建议可以使用 rel=preload 来解决这些问题。这当然可以奏效,但可能会带来一些副作用。毕竟,为什么要使用 rel=preload 来修复可以通过<script> 元素注入到 DOM 中来避免的问题呢?

A WebPageTest waterfall showing how the rel=preload resource hint is used to promote discovery of an async injected script—albeit in a way that may have unintended side-effects.
图 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">

使用 data- 前缀是 JavaScript 驱动的延迟加载器中的常见模式。当图像滚动到视口中时,延迟加载器会剥离 data- 前缀,这意味着在前面的示例中,data-src 变为 src。此更新会提示浏览器获取资源。

这种模式在应用于启动期间位于视口中的图像之前没有问题。由于预加载扫描器不会像读取 src(或 srcset)属性那样读取 data-src 属性,因此图像引用不会更早被发现。更糟糕的是,图像的加载会延迟到延迟加载器 JavaScript 下载、编译和执行之后

A WebPageTest network waterfall chart showing how a lazily-loaded image that is in the viewport during startup is necessarily delayed because the browser preload scanner can't find the image resource, and only loads when the JavaScript required for lazy loading to work loads. The image is discovered far later than it should be.
图 8: WebPageTest 网络瀑布图,显示了在模拟 3G 连接的移动设备上的 Chrome 上运行的 网页。图像资源被不必要地延迟加载,即使它在启动期间在视口中可见。这击败了预加载扫描器,并导致了不必要的延迟。

根据图像的大小(可能取决于视口的大小),它可能是最大内容渲染 (LCP) 的候选元素。当预加载扫描器无法提前推测性地获取图像资源时(可能在页面的样式表阻止渲染的点),LCP 会受到影响。

解决方案是更改图像标记:

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

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

A WebPageTest network waterfall chart depicting an loading scenario for an image in the viewport during startup. The image is not lazily loaded, which means it is not dependent on the script to load, meaning the preload scanner can discover it sooner.
图 9: WebPageTest 网络瀑布图,显示了在模拟 3G 连接的移动设备上的 Chrome 上运行的 网页。预加载扫描器在 CSS 和 JavaScript 开始加载之前发现了图像资源,这使浏览器在加载图像方面抢占了先机。

在这个简化的示例中,在慢速连接上 LCP 提高了 100 毫秒。这看起来可能不是一个巨大的改进,但当您考虑到解决方案是一个快速的标记修复,并且大多数网页比这组示例更复杂时,它就是巨大的改进。这意味着 LCP 候选元素可能必须与许多其他资源争夺带宽,因此像这样的优化变得越来越重要。

CSS 背景图像

请记住,浏览器预加载扫描器扫描的是标记。它不扫描其他资源类型,例如 CSS,其中可能涉及获取 background-image 属性引用的图像。

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

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

A WebPageTest network waterfall chart depicting a page with an LCP candidate loaded from CSS using the background-image property. Because the LCP candidate image is in a resource type that the browser preload scanner can't examine, the resource is delayed from loading until the CSS is downloaded and processed, delaying the LCP candidate's paint time.
图 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 提示很小,但它可以帮助浏览器比其他方式更快地发现图像:

A WebPageTest network waterfall chart showing a CSS background image (which is the LCP candidate) loading much sooner due to the use of a rel=preload hint. The LCP time improves by roughly 250 milliseconds.
图 11: WebPageTest 网络瀑布图,显示了在模拟 3G 连接的移动设备上的 Chrome 上运行的 网页。页面的 LCP 候选元素是具有 CSS background-image 属性的元素(第 3 行)。rel=preload 提示帮助浏览器比没有提示时提前大约 250 毫秒发现图像。

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

内联太多资源

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

内联资源可能比下载资源更快,因为不会为资源发出单独的请求。它就在文档中,并且可以立即加载。但是,存在明显的缺点:

  • 如果您没有缓存 HTML(如果 HTML 响应是动态的,您就无法缓存),则内联资源永远不会被缓存。这会影响性能,因为内联资源不可重用。
  • 即使您可以缓存 HTML,内联资源也不会在文档之间共享。与可以在整个来源中缓存和重用的外部文件相比,这降低了缓存效率。
  • 如果您内联太多内容,您将延迟预加载扫描器发现文档后面资源的进度,因为下载额外的内联内容需要更长的时间。

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

A WebPageTest network waterfall chart of page with an external CSS file with four fonts referenced in it. The LCP candidate image is discovered by the preload scanner in due course.
图 12: WebPageTest 网络瀑布图,显示了在模拟 3G 连接的移动设备上的 Chrome 上运行的 网页。页面的 LCP 候选元素是从 <img> 元素加载的图像,但由于 CSS 和页面加载所需的字体在单独的资源中加载,因此预加载扫描器可以发现它,这不会延迟预加载扫描器执行其工作。

现在,如果 CSS所有字体都作为 base64 资源内联会发生什么?

A WebPageTest network waterfall chart of page with an external CSS file with four fonts referenced in it. The preload scanner is delayed significantly from discovering the LCP image .
图 13: WebPageTest 网络瀑布图,显示了在模拟 3G 连接的移动设备上的 Chrome 上运行的 网页。页面的 LCP 候选元素是从 <img> 元素加载的图像,但 CSS 及其四种字体资源在 <head> 中的内联延迟了预加载扫描器发现图像的时间,直到这些资源完全下载。

在此示例中,内联的影响对 LCP 产生了负面影响,并且对整体性能产生了负面影响。不内联任何内容的页面版本在大约 3.5 秒内绘制 LCP 图像。内联所有内容的页面直到 7 秒多一点才绘制 LCP 图像。

这里不仅仅是预加载扫描器在起作用。内联字体不是一个好策略,因为 base64 对于二进制资源来说是一种效率低下的格式。另一个起作用的因素是,除非 CSSOM 确定外部字体资源是必需的,否则不会下载它们。当这些字体作为 base64 内联时,无论当前页面是否需要它们,都会下载它们。

预加载可以改善情况吗?当然可以。您可以预加载 LCP 图像并减少 LCP 时间,但使用内联资源来膨胀您可能无法缓存的 HTML 会产生其他负面的性能后果。首次内容渲染 (FCP) 也受到此模式的影响。在不内联任何内容的页面版本中,FCP 大约为 2.7 秒。在内联所有内容的版本中,FCP 大约为 5.8 秒。

内联到 HTML 中的内容要非常小心,尤其是 base64 编码的资源。一般来说,不建议这样做,除非是非常小的资源。尽可能少地内联,因为内联太多内容是在玩火。

使用客户端 JavaScript 渲染标记

毫无疑问:JavaScript 肯定会影响页面速度。开发人员不仅依靠它来提供交互性,而且还倾向于依赖它来交付内容本身。这在某些方面带来了更好的开发人员体验;但开发人员的优势并不总是转化为用户的优势。

一种可能击败预加载扫描器的模式是使用客户端 JavaScript 渲染标记:

A WebPageTest network waterfall showing a basic page with images and text rendered completely on the client in JavaScript. Because the markup is contained within JavaScript, the preload scanner can't detect any of the resources. All resources are additionally delayed due to the extra network and processing time that JavaScript frameworks require.
图 14: WebPageTest 网络瀑布图,显示了在模拟 3G 连接的移动设备上的 Chrome 上运行的 客户端渲染的网页。由于内容包含在 JavaScript 中并依赖框架进行渲染,因此客户端渲染标记中的图像资源对预加载扫描器是隐藏的。图 9 中描述了等效的服务器渲染体验。

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

这有点偏离了本文的重点,但在客户端渲染标记的影响远远超出了击败预加载扫描器的范围。首先,引入 JavaScript 来驱动不需要它的体验会引入不必要的处理时间,这会影响交互到下次绘制 (INP)。与服务器发送的相同数量的标记相比,在客户端渲染极大量的标记更有可能生成长任务。造成这种情况的原因(除了 JavaScript 涉及的额外处理之外)是浏览器从服务器流式传输标记,并将渲染分块,从而倾向于限制长任务。另一方面,客户端渲染的标记被视为单个整体任务来处理,这可能会影响页面的 INP。

这种情况的补救措施取决于对以下问题的回答:是否有理由不能由服务器提供您页面的标记,而不是在客户端渲染? 如果答案是“否”,则应尽可能考虑服务器端渲染 (SSR) 或静态生成的标记,因为它将有助于预加载扫描器提前发现并机会性地获取重要资源。

如果您的页面确实需要 JavaScript 将功能附加到页面标记的某些部分,您仍然可以使用 SSR 来实现,可以使用原生 JavaScript,也可以使用水合来获得两全其美的效果。

帮助预加载扫描器帮助您

预加载扫描器是一种非常有效的浏览器优化,有助于页面在启动期间更快地加载。通过避免破坏其提前发现重要资源能力的模式,您不仅使自己的开发更简单,而且还在创建更好的用户体验,这将为许多指标(包括一些Web 指标)带来更好的结果。

总而言之,以下是您要从这篇文章中获得的内容:

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

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

资源

英雄图片来自 Unsplash,作者:Mohammad Rahmani