本节摘要:HTML 解析器逐字节构建 DOM,遇到不带 defer 的脚本必须停下:下载、执行、再继续解析——因为脚本可能改写文档,解析器不敢赌。这个停摆是「白屏」的经典成因。本节讲解析流水线、三种脚本标签(默认、async、defer)的时序差异、模块脚本与普通脚本的区别、以及加载指示属性的现代实践(preload、modulepreload、动态 import)。
HTML 解析是流式的:字节进来,分词、建树、有机会就渲染已完成的 部分。但对脚本,解析器天然不信任:
<body> <p>第一段</p> <script src="app.js"></script> <p>第二段</p> </body>
时序回放:解析到 p 一,可以渲染;遇到 script 标签,停止建树,发起下载;下载完成,把脚本丢给 JS 引擎执行(执行期间解析器继续等待,因为脚本可能 document.write 或查询后续结构——引擎不敢假设它不会);执行完毕,解析器恢复,「第二段」才进入 DOM。网络慢时用户盯着「第一段」看两秒,这就是脚本阻塞白屏。
停摆的理由是正确性:脚本的执行结果可能依赖「它之前的一切 DOM」,也可能改写「它之后的一切」。既然无法预判,只能全停。理解这一点,加载策略的全部设计都围绕一个主题:把「必须停的」和「不必停的」分开。

两个属性都允许「下载与解析并行」,差别只在执行时机:
defer:执行推迟到 HTML 解析完成,多个 defer 脚本按出现顺序执行,全部执行完才触发 DOMContentLoaded。语义是「不挡渲染,但要按序、要完整的 DOM」——依赖 DOM 结构、彼此有依赖的主应用脚本全用 defer。
async:下载完立即执行(执行那一下仍然阻塞解析),多个 async 脚本执行顺序不定。语义是「完全独立的增量」——统计埋点、广告、A/B 实验注入这类「晚到也不影响主逻辑」的脚本。
一个容易忽略的硬边界:执行时机决定脚本看到的 DOM。默认脚本可靠访问「它之前」的节点;defer 脚本看到完整 DOM;async 脚本什么都不保证。所以「脚本为什么找不到元素」的排查清单第一条就是看标签上的 defer 与 async。内联脚本(无 src)无法 defer——想延后就包进动态脚本或移入模块。
动态创建脚本是运行时版的 async:
const s = document.createElement('script'); s.src = '统计模块地址'; document.head.appendChild(s); // 下载异步,执行即插即跑
按需加载第三方脚本(地图、播放器)的标准做法,配合 Promise 包一层完成回调就能 await。
type="module" 的脚本自带一套加载纪律:默认等同 defer(不阻塞解析、解析完按序执行);严格模式;模块级作用域(顶层变量不再自动变全局);静态依赖(import 的模块先递归加载);同源限制宽松但 CORS 要求。依赖图先构建、再深度优先后执行——引擎把整个 import 网络理清后按依赖序跑一遍,不存在循环引用未定义的经典问题(循环引用时被依赖方拿到的是尚未赋值的绑定,第 7 章模块一节展开)。
预加载指示交给浏览器更早开工:link 的 rel 值 preload 提示「这个资源马上要用」;modulepreload 专用于模块及其依赖树。它们不执行任何东西,只占用网络管道提前下载——首屏关键脚本配 preload、模块入口配 modulepreload 是加载性能的常规操作。
最后一张决策表收束本节:
| 需求 | 用法 |
|---|---|
| 主应用入口,依赖 DOM | type=module(或 defer)放 head |
| 独立统计/广告 | async |
| 按需重型库 | 动态 import 或动态脚本 |
| 提前下载不提前执行 | modulepreload 或 preload |
| 必须先改 DOM 再渲染 | 接受阻塞,放 body 尾部并保持极小 |
⚠️ 常见坑:defer 脚本之间保序,但 defer 与 async 混用时序完全不可预期——别让 async 脚本依赖任何「稍后一定已执行」的假设。另一个坑:module 脚本的相对路径必须带 ./ 前缀(规范要求),漏写在本地调试能跑、构建后炸掉,报错为模块解析失败。
三个真实页面场景,各配一套标签组合与时序推演。
场景一:内容型官网。 首屏是文章正文,脚本只有统计与一个轻交互库。策略:主脚本 defer 挂 head(解析全程并行下载,DOMContentLoaded 前按序执行);统计脚本 async(独立、晚到无妨);不引重型库。时序推演:用户几乎立即看到正文,脚本执行发生在解析尾部,无感知。
场景二:数据面板(强依赖图表库)。 首屏就要画图,图表库 800KB。策略:图表库与入口都走 module 静态 import(依赖图明确、按序求值),配 modulepreload 提前占管道;若首屏还有登录墙,把图表相关改成动态 import——登录成功那一刻才拉库,首屏负担直接摘除。时序推演:静态依赖保证「库先于入口执行」,modulepreload 保证「下载与解析并行」,动态版则把 800KB 从首屏关键路径挪到用户操作之后。
场景三:老页面增量改造。 页面主体是十年前的内联脚本,只敢局部加新功能。策略:新功能模块用 type=module(自带 defer 纪律与严格模式,不影响老脚本);老脚本原位不动,但把「文档未就绪就查元素」的那几个补上 DOMContentLoaded 监听(或移到 body 尾部)。时序推演:module 脚本等全部静态脚本与解析完成后按序执行,新代码看到的 DOM 完整,与老代码的执行先后由位置决定。
三个场景合成的决策树:独立用 async、主应用用 module 或 defer、重型按需用动态 import、提前下载用 modulepreload——再复杂的页面也是这四条的组合。判断顺序永远是「这个脚本看到什么 DOM、依赖谁、被谁依赖」,而不是「放 head 好还是 body 好」这种位置玄学。
DOMContentLoaded 和 load 到底差在哪?
DOMContentLoaded 在「HTML 解析完成、defer 脚本执行完」那一刻触发,不等图片样式表这些子资源;load 在「全部资源(图、字体、iframe)加载完」后触发。初始化 DOM 相关逻辑挂前者的静态化版本(module 脚本天然就是),统计上报与截图类需求才等后者。两个事件之间隔的是网络下载时间,页面越重隔得越远。
内联脚本想延迟执行怎么办?
defer 对内联无效(无下载可并行,属性被忽略)。三个出路:把代码搬进外部文件享受 defer;包成动态 import 的模块按需拉;小段逻辑直接听 DOMContentLoaded。顺带一提,内联脚本的唯一硬优势是「少一次请求」,构建工具的 Critical JS 内联用的就是这一点——但那段内联必须刻意保持极小。
动态 import 失败了会怎样,怎么兜底?
网络失败或模块内部顶层抛错,import 的 Promise 会 reject——正好落进第 7 章的异步错误通道,用 catch 接住后给降级 UI 与重试入口。给「首次进入某路由必失败一次」的场景(弱网)配 withRetry,用户体验就从「白屏」变成「加载中→再试一次」。
加载优化做完要验收,三个位置给出三种答案。
观测位一:网络面板瀑布图。 每个请求一条时间线,蓝色段是等待、绿色段是下载。看两件事:关键脚本与 HTML 解析是否并行(defer 与 module 的效果直接可见);有没有「一条长请求挡住后面一串」的队头阻塞。瀑布图是最直观的「谁在等谁」现场,和调用栈之于函数一样。
观测位二:时序事件日志。 Performance 录制里有两条关键竖线:DOMContentLoaded(解析与 defer 完成)与 load(全部资源完成)。两线之间拖得越长,说明子资源(图、字体)越拖沓——它们不挡解析但挡「完全加载」指标;想压首屏,重点在解析完成前的段落。
观测位三:真实用户指标。 实验室数据之外,生产环境采集「首次内容绘制」与「最大内容绘制」(PerformanceObserver 的 paint 与 LCP 条目),它们才是用户体感的锚。本地测出 0.8 秒、真机弱网三秒是常态——加载优化的最终裁决权在真实分布,不在开发者电脑。
三步走完,「首屏慢」就能拆解成明确的三类问题:关键路径上的大文件(分割)、串行等待(并行化)、子资源拖尾(懒加载与预加载)——分别对应本节与第 7 章的工具位。
字体也会影响加载吗? 会,且方式隐蔽:字体文件晚到会引发文字闪动(先无字体渲染、字体到了重画),字体声明配显示策略可以控制这个行为;更大的坑是字体请求排在关键脚本之后时,首屏观感被拖长。字体属于「重要但不该挡路」的资源,预加载加合理显示策略是常规组合。
preload 与 prefetch 有什么区别?
优先级宣言不同:preload 说「本页马上要用,现在就下」——关键脚本、关键字体配它;prefetch 说「下一页可能用,空闲时下」——预判式的投机下载,浏览器资源紧张时可以直接忽略。两者都不能执行任何东西,只动网络管道。常见误区是给所有东西都挂 preload——优先级全员最高等于没有优先级,管道反而被无关资源占满。挑出真正关键的少数几个,是这两个属性的使用纪律。
modulepreload 会导致重复下载吗?
不会。它只做「提前下载加解析」的准备工作,模块真正执行仍等 import 语句到达;若入口模块与 modulepreload 指向同一地址,浏览器按缓存与依赖图去重,不会下两遍。反过来,「忘了配」的代价才是真的——入口解析到 import 才发现模块没下载,关键路径平白多一个来回。入口模块与它的直接依赖,是 modulepreload 的标准配置对象。
脚本加载顺序问题怎么在测试里提前暴露?
两个低成本手段:其一,弱网模拟跑首屏——开发者工具限速后刷新,同步脚本与未配 defer 的入口会以白屏形态显形;其二,在持续集成里对构建产物做静态检查——扫描 HTML 里的 script 标签属性组合,入口非 defer 或 module 即告警。加载时序问题是典型的「本地无感、线上暴雷」,把检查自动化进流水线,比人眼评审可靠。
加载的时序定了,接下来看跑起来之后的排队:定时器、网络与存储各自在哪个线程干活、结果怎么送回主线程。