先记住这个答案
主 HTML 解析器遇到不带 async/defer 的 script 时必须停下:先下载再执行,因为脚本可能 document.write 改写文档。但浏览器另起一个预加载扫描器,它只做推测性词法扫描,不构建 DOM、不执行脚本、不处理条件注释,只从原始字节里识别 src、href 这类可提前请求的资源标签,如 script、link、img,然后直接交给网络栈排队下载。这样等主解析器恢复并走到该标签时,资源往往已在传输甚至已就绪。它是纯优化,即使扫描器漏掉某个资源,主解析器走到那里仍会正常请求,只是更晚。
- 预加载扫描器独立于主解析器,只做推测扫描
- 只识别标签里的静态资源引用,不执行脚本
- JS 拼接的资源 URL 扫描器看不到
- 被提前请求的资源省去的是发现延迟
为什么阻塞主解析器却阻塞不了扫描器
主 HTML 解析器承担 DOM 构建,遇到同步 script 必须暂停:脚本可能通过 document.write 改变后续文档结构,浏览器必须先下载并执行它才能保证解析结果正确。这段等待期间网络连接是空闲的,而文档后续字节往往已经在缓冲区里。
预加载扫描器是浏览器在同一文档上跑的第二条轻量通路:它对剩余字节做推测性词法扫描,不做树构建、不执行任何脚本、不求值条件分支,只匹配 script src、link href、img src 这类资源引用,把发现的 URL 直接提交给网络栈。两条通路读同一份字节流但互不等待,所以主解析器被脚本卡住时,扫描器照样把后面的 CSS、字体、脚本提前送上网络。
首屏有同步第三方脚本时的加载对比
假设某页面 <head> 第三个标签是同步的统计脚本,其后还有 styles.css、web 字体和首屏 hero.jpg。统计服务器 TTFB 约 800ms,期间主解析器完全停在该 script 上。若没有扫描器,CSS、字体、图片要等 800ms 后才被发现,首屏渲染预期会整体后移近一秒。
实际行为是:HTML 字节到达后预加载扫描器立即向后扫,在统计脚本还没返回时就把 styles.css、字体和 hero.jpg 的请求发出。等统计脚本执行完、主解析器走到 link 标签时样式表可能已下载完毕,可直接进入 CSSOM 构建。开发者可在 DevTools Network 瀑布图中观察到这些资源与统计脚本几乎同时起求,而非串行排在它后面。
推测扫描失效或被削弱的条件
第一类失效是内容不可见:资源 URL 由脚本拼接、藏在 template 内容由脚本实例化、或位于需要条件求值的分支里,扫描器无法判断是否会真正用到,只能放弃。第二类是请求本身发不出:目标域名尚未解析、连接数打满、或被 Content-Security-Policy 拦截,提前发现不等于提前拿到。
还要注意扫描器只解决发现时机,不解决带宽竞争:同步脚本阻塞期间扫描器同时发出的请求会共享网络,慢请求照样拖住后续渲染。可操作判断是:在瀑布图中某资源起始时间明显晚于 HTML 到达时间且它是首屏必需,就该检查它是否藏在脚本后面,需要时改为静态标签或加 preload。
容易答错的地方
- 扫描器会执行或部分执行脚本
- 错误。预加载扫描器只做词法层面的标签匹配,不解析 JavaScript、不构建 DOM、不执行任何代码。它能容忍猜测错误,因为主解析器才是语义的最终裁决者,扫描器漏掉的资源稍后仍会被正常请求。
- 扫描器能发现页面上的所有资源
- 错误。它只能识别 HTML 字节流中字面写出的
src/href。脚本动态插入的标签、JS 拼接的 URL、CSS 内部url()引用都不在其视野内,这些资源要等宿主代码执行或样式表解析后才会被发现。
面试官还会怎么问?
预加载扫描器和 preload 资源提示是什么关系?
扫描器是浏览器自动的推测机制,只覆盖 HTML 里静态写出的资源;rel="preload" 是开发者显式声明,能覆盖扫描器看不到的资源如字体、CSS 背景图。两者都让请求更早发出,但 preload 必须配对正确的 as 属性。
同步脚本都改成 async/defer 后扫描器还有用吗?
仍然有用。主解析器不再被脚本暂停,但 CSS、字体、图片仍按标签顺序被发现,扫描器仍能在主解析器到达之前提前发出这些请求,缩短发现延迟。它只是与脚本阻塞无关的另一个收益点。
扫描器提前请求的资源会被主解析器重复请求吗?
不会。请求进入统一的网络栈和缓存,主解析器走到对应标签时直接复用已在传输或已缓存的响应。例外是 preload 的 as 与实际使用类型不匹配,可能导致同一 URL 被当作不同类型资源重复下载。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。