脚本加载与兼容:代码什么时候才有资格运行
相同的 querySelector,放在页面底部能找到按钮,移到 <head> 却返回 null。不是选择器忽然失忆,而是 HTML 还没解析到按钮。脚本执行时间与 DOM 构建时间必须一起考虑。
经典脚本、defer、async、module
以下讨论由 HTML 解析器遇到的脚本,动态插入的脚本有自己的加载细节。
| 写法 | 下载与执行 | 适合的依赖关系 |
|---|---|---|
| 外部经典脚本,无属性 | 通常暂停解析,取回后执行,再继续解析 | 旧式顺序依赖 |
外部经典脚本 defer | 并行下载,解析完成后按文档顺序执行 | 依赖 DOM、彼此有顺序 |
外部经典脚本 async | 并行下载,就绪后执行,不保证文档顺序 | 相对独立的脚本 |
type="module",无 async | 加载依赖图,通常在解析完成后执行 | 使用显式模块依赖 |
“并行下载”不代表“执行不占主线程”。一个耗时循环即使写在 async 脚本里,也会影响页面响应。defer 对内联经典脚本不产生同样的延后作用;模块不需要额外加 defer。
一个可以直接打开的时序实验
保存为 HTML 后打开,看控制台:
<!doctype html>
<meta charset="utf-8">
<title>解析时序</title>
<script>
console.log('前:', document.querySelector('button')); // null
document.addEventListener('DOMContentLoaded', () => {
console.log('就绪:', document.querySelector('button').textContent);
});
</script>
<button type="button">现在存在了</button>
<script>
console.log('后:', document.querySelector('button').textContent);
</script>输出顺序是“前:null”“后:现在存在了”“就绪:现在存在了”。DOMContentLoaded 表示文档解析及相关延后脚本处理到相应时机,不等图片全下载;load 的等待范围更广。不要用 setTimeout(..., 1000) 猜页面何时准备好,慢机器没有签署一秒协议。
对于可能在文档就绪后才加载的代码,应同时检查当前状态:
function start() { console.log('可以读取初始 DOM'); }
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start, { once: true });
} else {
start();
}用两个外部文件区分下载顺序与执行顺序
内联实验确认了 DOM 会随解析逐步出现,接着测试外部经典脚本。把以下三个文件放在同一目录,通过本地 HTTP 服务访问 HTML:
<!-- loading.html -->
<!doctype html>
<meta charset="utf-8">
<title>延后脚本顺序</title>
<script defer src="./dictionary.js"></script>
<script defer src="./show.js"></script>
<p id="message">等待脚本</p>// dictionary.js:经典脚本
window.chapterLabel = '模块';
console.log('词典准备好');// show.js:经典脚本
document.querySelector('#message').textContent = window.chapterLabel;
console.log('内容已显示');两个 defer 脚本可以并行下载,但文档解析完成后,按标签顺序执行,所以 show 执行时,词典和段落都已存在。网络面板里 show 先下载完成,也不推翻执行顺序。
把两个 defer 改成 async,程序就失去了这些保证:show 可能先执行,也可能执行时段落尚未解析出来;缓存和网速会让现象时好时坏。某次刷新成功不是排序保证。恢复 defer 后,仍要考虑 dictionary 自身执行错误:加载属性只管理时机,不负责替你制造丢失的数据。
从解析器角度看,无属性经典脚本遇到后需要等待并执行,因此后续 HTML 解析可能暂停;defer 把执行放到解析后;async 则允许资源就绪时执行,执行期间主线程仍被占用。三种安排都不会把长循环变成另一个线程。
如果改用 ESM,应该由入口 import 所需绑定,而不是继续依赖 window.chapterLabel 的隐式约定。模块图提供依赖语义,script 属性提供宿主加载安排,两者解决的层次不同。
模块加载需要正确的服务器响应
浏览器的多文件模块示例应通过 HTTP 服务访问。已有 Python 时,可以在示例目录运行 python3 -m http.server 8000,再打开 http://localhost:8000。这是本地学习服务,结束后关闭它即可。
模块地址要能请求成功,响应的 MIME 类型要正确,跨源模块请求还要满足 CORS。直接双击多文件模块 HTML 并不能可靠复现服务器环境。裸模块名如 import 'some-package' 也不是浏览器天然认识的 npm 包路径,需要 import map 映射或构建工具处理。
转译与 polyfill 解决不同问题
转译把某些新语法改写为旧环境能解析的代码,例如把可选链改成条件访问。polyfill 则补充缺失的运行时 API,例如某个数组方法。解析器连新语法都不认识时,给全局对象加函数救不了它。
同样,转译了一段调用 fetch 的语法,并不意味着目标环境就拥有网络请求 API。平台能力、权限和安全上下文也不能简单靠 polyfill 凭空制造。
if ('IntersectionObserver' in globalThis) {
console.log('可以使用交叉观察器');
} else {
console.log('直接显示内容,保留基本阅读能力');
}优先检测所需能力,不根据浏览器名字猜测。对语法本身的支持则应结合目标环境和构建配置验证;不能在一个包含不受支持语法的文件里,期待普通 if 分支绕过解析错误。
严格模式也属于语义差异:模块默认严格,经典脚本可在开头声明 "use strict"。它会禁止一些宽松模式下悄悄发生的行为,例如给未声明的变量赋值,并不会自动检查所有逻辑错误。
练习
两个经典脚本分别定义工具和使用工具,为什么同时加 async 可能失败?
某旧浏览器不认识可选链,也没有 fetch,为什么只引入 fetch polyfill 仍可能白屏?
show.js先下载完成,是否意味着它必定先于dictionary.js执行?分别按defer与async解释。
把 dictionary/show 两个经典脚本改为模块:词典只导出 chapterLabel,入口导入它并更新段落,HTML 只加载入口。通过本地 HTTP 服务验证显示“模块”,并确认不再向 window 写 chapterLabel。