先记住这个答案
外部模块脚本默认会并行获取,等 HTML 解析完成后再按模块依赖关系执行,因此无需再加 defer。它也天然运行在严格模式,顶层变量不会自动变成 window 的属性。若加 async,模块及依赖准备好后就尽快执行,不能再依赖普通延后脚本的原有先后顺序。
- 模块依赖可并行获取
- 模块默认延后执行
defer对模块无额外作用async会改变执行时机
head 中的模块入口何时执行
页面 <head> 中加载应用入口。普通 <script src> 可能阻塞解析,改成 <script type="module" src="/app.js"> 后不必为了默认延后执行再写一遍 defer。若脚本需要操作正文 DOM,默认模块执行时文档结构已经解析完;但如果模块又动态等待异步依赖或执行了其他异步任务,不能把“解析完成”理解成所有业务逻辑已经就绪。
<head>
<script type="module" src="/app.js"></script>
<script defer src="/legacy.js"></script>
</head>
<body><main id="app"></main></body>
<!-- 模块默认延后执行;defer 对模块无额外作用 -->浏览器为模块构建依赖图,获取入口与依赖并在适当时机执行;静态 import 的依赖先于导入它的模块求值。
示例把模块入口与传统 defer 脚本都放在 <head>,两者都不会像普通同步脚本那样阻断 HTML 解析。但模块还能导入依赖,依赖获取和求值可能延后入口运行;若传统脚本期待模块先设置某个全局变量,单凭标签位置并不稳。应让依赖通过模块导入、显式初始化函数或事件传递,不要依靠偶然的网络完成顺序。
模块默认等解析完成再执行
普通经典脚本不加 defer 或 async 时会在遇到时阻塞解析;经典脚本加 defer 与模块默认行为更接近。模块脚本跨源加载还受 CORS 规则影响,不能简单把旧脚本的 URL 原样替换。async 模块可能打乱与其他脚本的相对执行时序。
普通脚本没有 defer 时会在解析到标签处执行;模块入口则默认等文档解析后执行,因此放在 <head> 通常不需再加同义属性。但“解析完成后”不等于依赖已按源码顺序同步执行:模块图要先获取和求值。若页面初始化依赖多个入口的顺序,应显式通过 import 表达依赖关系,不要靠标签摆放位置碰运气。
如果模块入口依赖另一个模块的初始化,直接在入口里 import 它能让依赖关系进入模块图;把两个互不关联的 <script type="module"> 放在某种顺序里,不应成为表达业务依赖的唯一手段。加 async 更会改变执行时机,适合完全独立的埋点等模块,而页面主入口通常需要明确依赖和 DOM 准备条件。
容易答错的地方
- 给模块加
defer才以为不会阻塞解析 - 模块脚本默认已有延后执行行为,
defer对它无额外作用。 - 认为模块顶层变量会成为全局属性
- 模块拥有自己的作用域,顶层声明不会自动挂到
window。
面试官还会怎么问?
模块加 async 后会怎样?
入口和依赖准备好后尽快执行,不再保证默认的解析后顺序。
模块为什么要检查服务器 MIME?
模块脚本使用更严格的资源加载规则,错误的 JavaScript MIME 类型可能导致加载失败。
模块内部默认是严格模式吗?
是,模块代码自动采用严格模式,不需要写 use strict。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。