不要阻碍浏览器 preload scanner 的运行

1,702 阅读11分钟

不要阻碍浏览器 preload scanner 的运行

我们都知道浏览器在渲染页面时候,其内部会帮开发者做各种优化,其中有一项就是preload scanner。通过这篇文章,我们可以了解什么是preload scanner,以及它是如何有助于性能的。

preload scanner 是什么?

我们都知道浏览器解析页面时候正常是通过html parser 从上往下解析标签。直到解析器遇到一个阻塞资源时候会暂停往下解析。比如遇到加载样式表带<link>标签、或者是没有async or deferscript标签。

图1: 如何阻塞浏览器的 HTML 解析器的示意图。在这种情况下,解析器会遇到一个外部 CSS 文件的 < link > 元素,该元素阻塞浏览器解析文档的其余部分(甚至是渲染) ,直到下载并解析 CSS。

在遇到外部css文件情况下,解析跟渲染都会被阻塞。因为阻塞了所以不会出现 flash of unstyled content (FOUC)的场景,简单理解就是如果不阻塞,继续往下解析渲染。那么就会导致从没有应用样式的状态突然跳变到应用样式的状态(因为你异步加载解析css去了)。

图2: FOUC 的模拟实例。左边是没有样式的 web.dev 的首页。右边是应用样式的相同页面。如果在下载和处理样式表时浏览器没有阻塞渲染,则未应用样式的状态可能会有跳变的过程。

HTML Parser遇到没有async或者defer属性的script标签时候,同样也会阻塞页面的解析与渲染。

小知识:如果是type = module属性的script标签,默认相当于加了defer属性。(defer:异步加载,但是等浏览器解析完才会执行)

浏览器之所以这么处理,是因为如果html parser继续工作,它不确定js脚本是否会去修改dom。这就是为什么我们一般建议在文档末尾去加载js脚本了。

我们上面说的都是浏览器在遇到上述场景时候,应该阻塞解析跟渲染的理由。但是光靠阻塞也不是最终解决问题的好办法,它会影响后面其他重要资源的加载,比如图片。于是我们的主角登场了,浏览器通过一种叫preloader scaner的东西来辅助html parser解决上述问题。

图3: 描述preloader scanner 如何与HTML 解析器并行工作以推测加载资源的图。在这里,HTML 解析器在开始处理 < body > 元素中的图像标记之前加载并处理 CSS 时被阻塞,但是preloader scanner可以在原始标记标签中向前查找该图像资源,并在HTML 解析器解除阻塞之前开始加载它。

preloader scaner是推测性的,因为它是解析原始 html标签,以便在html parse之前提前发现需要加载的资源。

如果判断 preloader scanner 是否正常工作

我们通过一个人为的示例页面,preload-scanner-fights.glitch.me/artifically…

这里我们借助www.webpagetest.org/result/2301…

图4: 通过模拟3G 连接在移动设备上运行的 Chrome 网页的 WebPageTest 网络瀑布图。即使样式表在开始加载之前通过代理被人为地延迟了两秒钟,preloader scanner 也会发现稍后位子的图像资源。

正如您在网络瀑布图看到的,即使文档解析和渲染被阻塞。preloader scanner还是发现 < img > 元素。如果没有这种优化,浏览器就不能在阻塞期间机会性地获取内容,

接下来我们看看在什么场景下preload scanner会失效?以及如果避免这种失效。

注入一个异步的脚本

假设在你的 < head > 标签中,其中包含一些内联的 JavaScript,如下所示:

<script>
  const scriptEl = document.createElement('script');

  scriptEl.src = '/yall.min.js';


  document.head.appendChild(scriptEl);

</script>

js动态注入的脚本文件默认是async异步的。如果我们把这个内联的script放在link之后。那么我们会得到一个糟糕的结果。

该页面包含一个样式表和一个注入的异步脚本。preloader scanner无法在阻塞阶段发现脚本,因为脚本是在客户端上动态注入的。

其实也很好理解,我们之前说过preloader scanner它是通过解析html原始标签来进行推测,而我们是动态注入script标签,它肯定是推测不出来的。

在上述图你也可以看到动态js是在2.6s(css下载解析后)后才可以下载、解析执行。所以js脚本被阻塞了,这可能会影响页面的交互时间(TTI)。而上面的img标签是可以被preloader scanner发现的。

那如果我们改为外部引用js的方式呢?

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

该页面包含一个样式表和一个异步 < script > 元素。preloader scanner在阻塞阶段发现了脚本,并与 CSS 同时加载该脚本。

当然聪明的同学可能提出另外一个方法,通过rel = preload属性来让浏览器预先加载资源。这个方案当然有用,但是它会带来别的副作用。最主要的是我们不应该用hack的方式来为一个错误的方案兜底。

页面包含一个样式表和一个注入的异步脚本,但是预先加载了异步脚本以确保更快地发现它

我们通过预加载方式hack的“修复”了这里的问题,但它引入了一个新问题: 前两个演示中的异步脚本(尽管在 < head > 中加载)以“低”优先级加载,而样式表以“最高”优先级加载。在预加载异步脚本的最后一个演示中,样式表仍然以“最高”优先级加载,但脚本的优先级已经提升到“最高”。导致 js脚本先于css被浏览器加载了。

当资源的优先级提高时,浏览器会为其分配更多的带宽。这意味着ーー即使样式表具有最高的优先级ーー脚本提高的优先级可能会导致带宽占用。 如果这个js资源刚好特别大就会显得我们页面打开链接特别慢。

所以最好就是script标签放在合适的位置,根据场景合理使用asyncdefer属性。

使用 JavaScript 进行懒加载

我们应该经常看到大家通过懒加载方式来加载图片吧。然而有时候可能会被我们误用。

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

使data-前缀是 JavaScript 实现懒加载的一种常见模式。当图像滚动到可视区中时,延迟加载程序去掉 data-前缀,这意味着在前面的示例中,data-src 变成 src。提示浏览器获取资源。

preloader scanner读取data-src这样的前缀属性跟读取src或者srcset属性处理不同,所以preloader scanner确实发现不了图片资源因此不会提前加载。所以正常情况下懒加载的使用没有问题。

但是,假设如果一个图片按照上述的标签方式使用,而这个图像恰好又是在可视区中开始滚动。

图片资源是没有必要懒加载,这导致preload scanner失效了

如果一个图片资源恰好是LCP的一个元素时候,不恰当的懒加载操作,可能是在页面的样式表阻止渲染时,LCP就会受到影响。

对于懒加载图片,启动时候就处于viewport内的图片, 正确的解决方案如下:

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

预加载扫描器在 CSS 和 JavaScript 开始加载之前发现图像资源,这使浏览器在加载图像资源时有了先机

在这个简化的示例中,结果是在慢速连接上对 LCP 进行了100毫秒的改进。这可能看起来不像是一个巨大的改进,但是当你认为解决方案只是一个html属性的优化,并且大多数网页比这组例子更复杂的时候。这样的优化变得越来越重要。

图像并不是唯一可能遭受次优延迟加载模式影响的资源类型。< iframe > 元素也可能受到影响,而且由于 < iframe > 元素可以加载许多子资源,因此性能的影响可能会严重恶化。

CSS 背景图片

前面我们提到过preloader scanner只会扫描row html makeup,它不会扫描你的css文件,因此我们会想到如果在css文件内,把图片作为背景属性会怎么样。

像 HTML 一样,浏览器将 CSS 处理成自己的对象模型,称为 CSSOM。如果在构造 CSSOM 时发现了外部资源,那么这些资源将在发现时被请求,而不是preloader scanner请求。

假设页面的 LCP 元素是一个具有 CSS 背景图像属性的元素。下面是资源加载时发生的情况:

页面的 LCP 元素是一个具有 CSS 背景图像属性的元素(第3行)。在 CSS 解析器找到它之前,它请求的图像不会提前获取。

在上面这种情况,preloader scanner就发挥不了作用了。那我们可能会想到另外一个解法:

<!-- 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">

如果您的 LCP 对象来自背景图像 CSS 属性,但是该图像根据视口大小而变化,则需要在 < link > 元素上指定 imagesrcset 属性。

尽管Rel = preload的作用有限,但是它仍然可以帮助浏览器比其他方式更快地发现图像:

页面的 LCP 元素是一个具有 CSS 背景图像属性的元素(第3行)。Rel = preload 提示可以帮助浏览器比没有提示的情况下提前约250毫秒发现图像。

使用 rel = preload 降低 LCP 时间。虽然这个提示有助于解决这个问题,但是更好的选择可能是评估图像作为 LCP 元素是否必须从 CSS 加载。使用 < img > 标记,您可以更好地控制加载适合视口的图像,同时允许preloader scanner发现它。

内嵌了太多资源

我们都知道内联引用也是常用的一种引用资源的方式,而且它比可能外部引用更快,因为它不需要发出网络请求。它就在文档中,并且可以立即加载。然而,它也有明显的缺点:

  • 如果不缓存 HTML ーー如果 HTML 响应是动态的,那么就不能缓存内联资源ーー那么内联资源永远不会被缓存。这会影响性能,因为内联资源不可重用。

  • 即使可以缓存 HTML,内联资源也不会在文档之间共享。与可以跨整个源缓存和重用的外部文件相比,这降低了缓存效率。

  • 如果内联资源过多,将延迟preload scanner扫描文档时候发现后面的资源,因为下载额外的内联内容需要更长的时间。

以这个示例页面为例子preload-scanner-fights.glitch.me/inline-noth… 元素是页面顶部的图像,CSS 位于由 < link > 元素加载的单独文件中。该页面还使用了四种网页字体,这些字体在CSS 资源的文件中单独请求的。

页面的 LCP 元素是从 < img > 元素加载的图像,但是由preloade scanner发现,因为页面加载所需的 CSS 和字体位于单独的资源中,这不会延迟预加载扫描器的工作。

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

页面的 LCP 元素是一个从 < img > 元素加载的图像,但是 CSS 及其四个字体资源的内联“延迟了preloader scanner 发现图像,直到这些资源被完全下载。

在这个例子中,内联的影响会对 LCP 产生负面影响,并且一般来说会对性能产生负面影响。不内联任何内容的页面版本只需3.5秒就可以绘制 LCP 图像。内联所有内容的页直到刚刚超过7秒时才绘制 LCP 图像。

这不仅仅是preloader scanner的问题。内联字体不是一个很好的策略,因为 base64是一种低效的二进制资源格式。另一个影响因素是,除非 CSSOM 认为有必要,否则不会下载外部字体资源。当这些字体内联为 base64时,无论当前页面是否需要它们,它们都会被下载。

所以对于内联到 HTML 中的内容要非常小心,特别是 base64编码的资源。一般来说,除了非常小的资源外,不推荐使用它。内嵌越少越好,因为内嵌太多是在玩火。

CSR 渲染

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

一种可以打败preloader scanner的模式是 CSR

你可以看示例页面preload-scanner-fights.glitch.me/client-rend…

html是通过外部js进行渲染的。

render(content, document.body);

本质就是我们现在流行的CSR。

CSR中图片资源对preloader scanner是隐藏的

所以举个例子如果你的LCP元素是图片比较多的大,那么CSR就会对你的LCP造成影响。所以有时候可以考虑服务器端渲染(SSR)或静态生成的标记,因为它将帮助preloader scanner提前发现并机会性地获取重要资源。

文献

  1. 原文:web.dev/preload-sca…

image.png