前端技术
#HTML#预加载扫描器#脚本阻塞#async#defer#preload

HTML 解析器与预加载扫描器:为什么脚本会阻塞预加载及如何解除

本文以首屏商品页为例,解释主 HTML 解析器遇到阻塞脚本时为何会暂停,以及预加载扫描器如何提前发现资源。通过对比 async、defer 和 preload 的适用场景,给出减少阻塞的实践与权衡。

一个常见的首屏延迟现象

打开一个商品详情页,页面顶部是商品主图,下方是价格、规格和“加入购物车”按钮。网络瀑布图里,主图请求却排在某个 JavaScript 文件之后,明明它在 HTML 中更靠前。更奇怪的是,这个脚本既没有操作 DOM,也没有改变图片的加载方式,只是被放在了 <head> 里。

问题出在浏览器解析 HTML 的方式上。浏览器的主 HTML 解析器按顺序处理标记,遇到没有 asyncdefer 属性的 <script> 时会暂停解析,先下载并执行脚本,再继续处理后面的内容。如果这个脚本恰好位于 <head> 中,而主图在 <body> 里,那么主图资源的发现就被推迟了。

浏览器并非对此毫无对策。它有一个辅助解析器,称为预加载扫描器,会在主解析器暂停时提前扫描原始标记,发现并请求后续资源。但预加载扫描器只能看到服务器返回的原始 HTML,看不到由 JavaScript 动态插入的标签。理解这两层解析器的分工,是优化首屏加载的关键。

主 HTML 解析器与阻塞脚本

HTML 解析器把字节流转换为 DOM 树。它逐个读取标记,遇到 <img><link><script> 等标签时,会创建对应节点并触发资源加载。这个过程本身是同步的,但并非所有资源都会阻塞解析。

阻塞发生在两类资源上:外部样式表和没有 asyncdefer 的脚本。样式表阻塞渲染,是为了避免无样式内容闪烁(FOUC);脚本阻塞解析,是因为浏览器无法预知脚本是否会修改 DOM。例如,document.write() 可以在解析过程中写入新的标记,如果解析器继续往下走,写入的内容就无法按正确位置插入。

因此,当主解析器遇到这样的脚本时,会停下等待脚本下载并执行。在慢速网络下,这个停顿可能持续数百毫秒甚至数秒。期间,解析器不会处理 <body> 中的任何标签,也就不会发现主图、懒加载占位符等资源。

预加载扫描器:第二双眼睛

预加载扫描器是浏览器内部的一个轻量级解析器,与主解析器并行运行。它不构建 DOM,也不执行脚本,只做一件事:扫描原始标记,找出带 srchrefsrcset 等属性的元素,提前发起资源请求。

例如,当主解析器被 <head> 中的样式表阻塞时,预加载扫描器已经看到了 <body> 里的 <img src="product.jpg">,并开始下载图片。这样,图片请求与样式表下载并行进行,而不是等样式表完成后再串行发起。

预加载扫描器的行为是推测性的。它假设这些资源将来会被使用,提前下载并不保证一定使用,但通常能显著缩短关键资源的等待时间。它只扫描原始标记,不解析 CSS,也不理解 JavaScript 的执行结果。

首屏商品页的阻塞场景

假设商品页的 HTML 结构如下:

<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" href="/styles.css">
  <script src="/analytics.js"></script>
</head>
<body>
  <img src="/product-main.jpg" alt="商品主图">
  <script src="/app.js"></script>
</body>
</html>

主解析器遇到 styles.css 时暂停,等待样式表下载并解析。预加载扫描器此时已经发现 analytics.jsproduct-main.jpg,开始请求它们。但 app.js 位于 <body> 末尾,预加载扫描器同样能发现它。

问题出现在主解析器遇到 analytics.js 时。这个脚本没有 asyncdefer,主解析器会再次暂停,等待它下载并执行。如果 analytics.js 体积较大或网络较慢,这个停顿会延迟 app.js 的发现——尽管预加载扫描器已经发现了它,但脚本的执行顺序是阻塞的,app.js 必须等 analytics.js 执行完才能开始。

更糟的情况是,如果 analytics.js 是动态注入的:

<script>
  var s = document.createElement('script');
  s.src = '/analytics.js';
  document.head.appendChild(s);
</script>

预加载扫描器看不到这个脚本,因为它不在原始标记中。主解析器执行这段内联脚本时,才会创建 <script> 元素并触发下载。如果这段内联脚本位于样式表之后,那么 analytics.js 的下载要等到样式表完成,而主图等资源可能已经被预加载扫描器发现,但脚本的延迟仍然会影响后续交互。

解除阻塞:async、defer 与 preload

asyncdefer 是解决脚本阻塞的两种标准方式,它们改变了脚本的下载和执行时机。

async 脚本在下载完成后立即执行,不等待解析器,也不阻塞其他资源的发现。但多个 async 脚本的执行顺序不保证,适合独立、互不依赖的脚本,如统计代码。

defer 脚本在文档解析完成后、DOMContentLoaded 事件之前执行。它保持脚本在文档中的顺序,适合需要操作 DOM 或依赖其他脚本的代码。defer 脚本的下载不阻塞解析,预加载扫描器可以正常发现后续资源。

rel="preload" 则是一种更主动的资源提示。它告诉浏览器:这个资源很重要,请提前下载。preload 不改变脚本的执行时机,只提前发起请求。它常用于预加载 CSS 中的背景图片、字体,或 JavaScript 动态请求的资源。

下表对比了这些方法的关键差异:

方法下载时机执行时机是否阻塞解析适用场景
普通脚本遇到时下载后立即同步依赖,但应避免
async遇到时下载完成后,不保证顺序独立脚本,如统计
defer遇到时解析完成后,按顺序依赖 DOM 或顺序的脚本
preload遇到时不执行,仅下载提前加载关键资源

在商品页场景中,analytics.js 可以改为 async,因为它不依赖其他脚本;app.js 可以改为 defer,因为它需要操作 DOM。这样,两个脚本都不会阻塞解析,主图也能被预加载扫描器更快发现。

预加载扫描器失效的常见模式

预加载扫描器并非万能。它只扫描原始标记,因此任何由 JavaScript 动态生成的资源引用都无法被提前发现。

动态注入脚本是最常见的失效模式。如前所述,document.createElement('script') 创建的脚本对预加载扫描器不可见。解决办法是使用静态的 <script> 标签,并搭配 asyncdefer

基于 JavaScript 的懒加载也会让预加载扫描器失效。例如,首屏图片使用 data-src 属性,由懒加载脚本在滚动到视口时替换为 src。预加载扫描器只识别 srcsrcset,不会读取 data-src,因此图片的下载被推迟到脚本执行之后。对于首屏图片,应直接使用 src,让预加载扫描器尽早发现。

CSS 背景图片是另一种情况。预加载扫描器不解析 CSS,因此 background-image 引用的图片只能在 CSS 解析时被发现。如果 LCP 元素是背景图片,可以考虑使用 <link rel="preload" as="image"> 提前加载。

客户端渲染也会隐藏资源。如果页面内容完全由 JavaScript 生成,预加载扫描器只能看到空壳 HTML,所有资源都要等脚本执行后才能发现。服务端渲染或静态生成可以让预加载扫描器重新发挥作用。

权衡与决策

选择 asyncdefer 还是 preload,取决于资源的依赖关系和优先级。

async 脚本不保证顺序,如果脚本之间有依赖,会导致执行错误。defer 保持顺序,但会延迟到解析完成后执行,可能影响交互时间。preload 能提前下载资源,但会提高资源的网络优先级,可能与其他关键资源争抢带宽。

在商品页中,如果 analytics.jsapp.js 无依赖,可以都用 async;如果 app.js 需要操作 DOM,则用 deferpreload 应谨慎使用,只针对那些预加载扫描器无法发现的资源,如 CSS 背景图片或动态加载的模块。

下图展示了预加载扫描器与主解析器的协作流程,以及脚本阻塞时的资源发现顺序:

flowchart TD
    A[HTML 字节流] --> B[主 HTML 解析器]
    A --> C[预加载扫描器]
    B --> D{遇到阻塞资源?}
    D -- 是 --> E[暂停解析]
    D -- 否 --> F[继续解析并构建 DOM]
    C --> G[提前发现 img/script/link]
    G --> H[发起资源请求]
    E --> I[下载并执行脚本]
    I --> F
    H --> J[资源到达]
    F --> K[完成 DOM 构建]
    J --> L[渲染]

在这个流程中,预加载扫描器与主解析器并行,即使主解析器暂停,它也能继续发现资源。但如果资源是动态注入的,预加载扫描器就无法参与,资源发现只能等待主解析器恢复。

诊断与验证

要确认预加载扫描器是否正常工作,可以在浏览器开发者工具中查看网络瀑布图。如果资源请求的时间线显示某些资源在阻塞期间没有被提前请求,而是等阻塞解除后才发起,就说明预加载扫描器可能被绕过了。

在 Chrome DevTools 中,可以右键点击网络面板的列标题,启用“优先级”列,查看资源的加载优先级。预加载扫描器发现的资源通常具有较高的优先级,而动态注入的资源可能以较低优先级延迟加载。

另一个方法是人为延迟阻塞资源。例如,通过代理工具给样式表增加 2 秒延迟,观察图片是否在样式表下载期间就开始请求。如果是,说明预加载扫描器起作用;如果不是,则说明图片的引用方式有问题。

回到商品页

商品页的优化可以从几个方面入手:

  • <head> 中的脚本改为 asyncdefer,避免阻塞解析。
  • 确保首屏图片使用 src 属性,而不是 data-src
  • 如果 LCP 元素是背景图片,使用 <link rel="preload" as="image"> 提前加载。
  • 避免动态注入关键脚本,改用静态标签。

这些改动不需要重构架构,只是调整标记和属性,但能让预加载扫描器发挥作用,显著缩短首屏资源的发现时间。

预加载扫描器是浏览器内置的优化机制,它无法被开发者直接调用,但可以被无意中破坏。理解它的工作原理,避免使用动态注入、懒加载等阻碍它的模式,是前端性能优化中成本最低、收益最直接的手段之一。

资料来源

  1. HTML Standard: Parsing HTML documents
  2. MDN: Preloading content with rel=preload
  3. 不要与浏览器的预加载扫描程序冲突| Articles
  4. Web性能优化:不要与浏览器预加载扫描器对抗 - 腾讯云