先记住这个答案
<script type="module"> 除了能用 import 和 export,还和普通脚本有好几处行为差异。执行时机上,它默认像加了 defer,不阻塞 HTML 解析,等文档解析完按顺序执行。代码层面,它自动是严格模式,有独立的模块作用域,顶层声明不会挂到 window 上,顶层 this 是 undefined。加载层面,它按 CORS 模式请求,跨域资源必须有 Access-Control-Allow-Origin 响应头,同一个 URL 在页面里只执行一次。
- 模块脚本默认延迟执行,不阻塞 HTML 解析
- 自动严格模式,顶层
this是undefined - 顶层
var和函数不会变成window的属性 - 跨域模块请求受 CORS 约束
同样的代码,两种标签跑出不同结果
页面上放一个普通脚本和一个模块脚本,内容几乎一样。
this 和执行顺序HTML<script type="module">
var a = 1
console.log('模块', this, window.a) // 模块 undefined undefined
</script>
<script>
var b = 2
console.log('普通', this === window, window.b) // 普通 true 2
</script>
<!-- 控制台先打印「普通」,再打印「模块」 -->模块脚本写在前面,却后执行,因为它默认延迟到文档解析完才运行。普通脚本遇到就立即执行。
模块里的 var a 只存在于这个模块自己的作用域,window.a 是 undefined;普通脚本的顶层 var b 会变成 window.b。模块的顶层 this 是 undefined,普通脚本的顶层 this 是 window。
加载方面的差异
这几条平时感觉不到,一旦碰上就是线上问题。
- CORS、只执行一次和
async - 模块脚本总是按 CORS 模式请求。从另一个域名的 CDN 加载模块,对方必须返回允许跨域的响应头,否则浏览器拒绝执行。普通
<script src>跨域加载则不需要。本地直接双击打开 HTML 文件时,页面来源是file://,模块请求也被当成跨域,所以会报错,要起一个本地 HTTP 服务。 同一个模块 URL 在页面上写两次<script type="module" src>,只会执行一次,因为模块有缓存。普通脚本写两次就执行两次。async属性对模块脚本也有效,而且对内联模块脚本同样生效:加了async的模块在自身和依赖都加载完后立刻执行,不再等文档解析完,也不保证顺序。普通脚本的async只对外部脚本有效。 服务器返回的 MIME 类型也要是 JavaScript 类型,比如text/javascript,模块脚本对此是严格检查的,返回text/plain会被拒绝执行。
容易答错的地方
- 以为模块脚本里的函数能在 HTML 的
onclick里调用 <button onclick="handleClick()">在全局作用域里找handleClick,而模块里定义的函数不在全局。要么在模块里用addEventListener绑定事件,要么显式写window.handleClick = handleClick。- 以为给模块脚本加
defer会改变什么 - 模块脚本本来就是延迟执行的,加
defer没有任何效果。能改变它时机的只有async。 - 以为模块脚本会在
DOMContentLoaded之后执行 - 非
async的模块脚本和defer脚本一样,在文档解析完成后、DOMContentLoaded事件触发前执行。所以在模块脚本里注册DOMContentLoaded监听是能收到事件的,但直接操作 DOM 也已经没问题了。
面试官还会怎么问?
模块脚本加载的依赖会阻塞页面吗?
不会阻塞 HTML 解析,但依赖是在执行前一层层发现的:先下载入口,解析出 import 才知道要下载哪些依赖,依赖又有依赖,层级深了就会形成请求瀑布。可以用 <link rel="modulepreload"> 提前声明关键依赖,让浏览器并行下载。
模块脚本里能用顶层 await 吗?
能,这也是模块独有的能力,普通脚本顶层写 await 是语法错误。但要注意它会让依赖这个模块的其它模块一起等待。
为什么模块脚本要求 CORS?
普通脚本跨域加载不需要授权,是早年 Web 留下的历史包袱,比如跨域脚本出错时页面只能拿到一句 Script error.,就是为了弥补这个口子。模块脚本是新设计的加载方式,没有兼容负担,规范就直接采用了更安全的默认:跨域资源必须由服务端显式允许。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。