一个常见的首屏延迟现象
打开一个商品详情页,页面顶部是商品主图,下方是价格、规格和“加入购物车”按钮。网络瀑布图里,主图请求却排在某个 JavaScript 文件之后,明明它在 HTML 中更靠前。更奇怪的是,这个脚本既没有操作 DOM,也没有改变图片的加载方式,只是被放在了 <head> 里。
问题出在浏览器解析 HTML 的方式上。浏览器的主 HTML 解析器按顺序处理标记,遇到没有 async 或 defer 属性的 <script> 时会暂停解析,先下载并执行脚本,再继续处理后面的内容。如果这个脚本恰好位于 <head> 中,而主图在 <body> 里,那么主图资源的发现就被推迟了。
浏览器并非对此毫无对策。它有一个辅助解析器,称为预加载扫描器,会在主解析器暂停时提前扫描原始标记,发现并请求后续资源。但预加载扫描器只能看到服务器返回的原始 HTML,看不到由 JavaScript 动态插入的标签。理解这两层解析器的分工,是优化首屏加载的关键。
主 HTML 解析器与阻塞脚本
HTML 解析器把字节流转换为 DOM 树。它逐个读取标记,遇到 <img>、<link>、<script> 等标签时,会创建对应节点并触发资源加载。这个过程本身是同步的,但并非所有资源都会阻塞解析。
阻塞发生在两类资源上:外部样式表和没有 async 或 defer 的脚本。样式表阻塞渲染,是为了避免无样式内容闪烁(FOUC);脚本阻塞解析,是因为浏览器无法预知脚本是否会修改 DOM。例如,document.write() 可以在解析过程中写入新的标记,如果解析器继续往下走,写入的内容就无法按正确位置插入。
因此,当主解析器遇到这样的脚本时,会停下等待脚本下载并执行。在慢速网络下,这个停顿可能持续数百毫秒甚至数秒。期间,解析器不会处理 <body> 中的任何标签,也就不会发现主图、懒加载占位符等资源。
预加载扫描器:第二双眼睛
预加载扫描器是浏览器内部的一个轻量级解析器,与主解析器并行运行。它不构建 DOM,也不执行脚本,只做一件事:扫描原始标记,找出带 src、href、srcset 等属性的元素,提前发起资源请求。
例如,当主解析器被 <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.js 和 product-main.jpg,开始请求它们。但 app.js 位于 <body> 末尾,预加载扫描器同样能发现它。
问题出现在主解析器遇到 analytics.js 时。这个脚本没有 async 或 defer,主解析器会再次暂停,等待它下载并执行。如果 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
async 和 defer 是解决脚本阻塞的两种标准方式,它们改变了脚本的下载和执行时机。
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> 标签,并搭配 async 或 defer。
基于 JavaScript 的懒加载也会让预加载扫描器失效。例如,首屏图片使用 data-src 属性,由懒加载脚本在滚动到视口时替换为 src。预加载扫描器只识别 src 和 srcset,不会读取 data-src,因此图片的下载被推迟到脚本执行之后。对于首屏图片,应直接使用 src,让预加载扫描器尽早发现。
CSS 背景图片是另一种情况。预加载扫描器不解析 CSS,因此 background-image 引用的图片只能在 CSS 解析时被发现。如果 LCP 元素是背景图片,可以考虑使用 <link rel="preload" as="image"> 提前加载。
客户端渲染也会隐藏资源。如果页面内容完全由 JavaScript 生成,预加载扫描器只能看到空壳 HTML,所有资源都要等脚本执行后才能发现。服务端渲染或静态生成可以让预加载扫描器重新发挥作用。
权衡与决策
选择 async、defer 还是 preload,取决于资源的依赖关系和优先级。
async 脚本不保证顺序,如果脚本之间有依赖,会导致执行错误。defer 保持顺序,但会延迟到解析完成后执行,可能影响交互时间。preload 能提前下载资源,但会提高资源的网络优先级,可能与其他关键资源争抢带宽。
在商品页中,如果 analytics.js 与 app.js 无依赖,可以都用 async;如果 app.js 需要操作 DOM,则用 defer。preload 应谨慎使用,只针对那些预加载扫描器无法发现的资源,如 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>中的脚本改为async或defer,避免阻塞解析。 - 确保首屏图片使用
src属性,而不是data-src。 - 如果 LCP 元素是背景图片,使用
<link rel="preload" as="image">提前加载。 - 避免动态注入关键脚本,改用静态标签。
这些改动不需要重构架构,只是调整标记和属性,但能让预加载扫描器发挥作用,显著缩短首屏资源的发现时间。
预加载扫描器是浏览器内置的优化机制,它无法被开发者直接调用,但可以被无意中破坏。理解它的工作原理,避免使用动态注入、懒加载等阻碍它的模式,是前端性能优化中成本最低、收益最直接的手段之一。