先记住这个答案
在 HTML 里把 <script> 的 type 设成 "module",浏览器就会按 ESM 规则处理这个脚本——支持 import 和 export,自动启用严格模式,默认 defer 执行。浏览器会从入口模块开始,递归加载所有依赖,构建出一张模块图,全部加载完成后再按依赖顺序执行。
- 模块脚本默认
defer,在 DOM 解析完成后按顺序执行 - 自动严格模式,顶层
this是undefined - 跨域模块需要 CORS 头,否则加载失败
- 用
nomodule属性为老浏览器提供降级脚本
模块脚本的执行时机和加载行为
普通 <script> 标签遇到就执行,会阻塞 HTML 的解析。模块脚本不一样,它默认就是 defer 的——浏览器会在后台下载模块文件,等 HTML 解析完成后,再按它们在文档中出现的顺序依次执行。
<!-- 入口模块:浏览器会递归加载 app.js 的所有依赖 -->
<script type="module" src="./app.js"></script>
<!-- 内联模块:直接在标签里写 import -->
<script type="module">
import { init } from './app.js'
init()
</script>
<!-- nomodule:只有不支持 ESM 的老浏览器才会执行 -->
<script nomodule src="./legacy-bundle.js"></script>nomodule 属性是给老浏览器准备的降级方案。支持 ESM 的浏览器看到 nomodule 会直接跳过,而老浏览器不认识 type="module" 所以不会执行模块脚本,两边互不干扰。
如果给模块脚本加上 async 属性,它会在加载完成后立刻执行,不再等待 DOM 解析完成,也不保证和其他模块脚本的执行顺序。适合那些不依赖 DOM 也不依赖其他模块脚本执行结果的独立模块。
和普通脚本的关键差异
模块脚本和普通脚本在作用域、严格模式、跨域这几个方面行为完全不同。
模块脚本有独立的模块作用域。在模块里用 const x = 1 声明的变量不会挂到 window 上,其他模块或脚本也访问不到。普通脚本的顶层变量全都是全局的,多个脚本之间容易互相污染。
模块脚本自动启用严格模式,不需要写 'use strict'。这意味着不能用 with,不能给未声明的变量赋值,this 在顶层是 undefined 而不是 window。很多人从普通脚本迁移到模块脚本时,因为顶层 this 变了而踩坑。
模块脚本请求必须通过 CORS 检查。即使是同一域名下的文件,如果从 CDN 域名加载模块,服务器必须返回 Access-Control-Allow-Origin 头。普通 <script> 没有这个限制。
模块的去重和缓存
浏览器加载模块时会维护一个模块映射表,每个 URL 只会被下载和执行一次。如果 a.js 和 b.js 都 import 了 utils.js,utils.js 只下载一次,两边拿到的是同一个模块实例。
这和普通 <script> 不同——写两个 <script src="same.js">,脚本会执行两次。模块的去重机制保证了模块内的副作用只发生一次,也保证了不同文件导入同一模块时拿到的是同一份数据。
需要注意的是,模块的去重是按完整 URL 匹配的。./utils.js 和 ./utils.js?v=2 是两个不同的模块,会各自下载和执行一次。
容易答错的地方
- 以为模块脚本和普通脚本的
defer行为完全一样 - 模块脚本默认
defer,但和普通脚本的defer有一个细微区别:模块脚本在加载自身和所有依赖都完成后才执行,而普通defer脚本只等自身加载完就行。如果模块图很深、依赖链很长,模块脚本的执行时机可能比预期更晚。 - 以为本地文件可以直接用
file://打开 - 模块脚本在
file://协议下会被浏览器拒绝加载,因为file://不符合 CORS 策略。开发时必须起一个本地 HTTP 服务器,即使只是npx serve .也行。很多初学者双击 HTML 文件打开后发现import报错,原因就在这里。
面试官还会怎么问?
模块脚本里能用 document.write() 吗?
不能。模块脚本默认 defer 执行,在 DOM 解析完成后才运行,这时调用 document.write() 会清空整个页面。实际上,现代浏览器在 defer 或 async 脚本里调用 document.write() 时会直接忽略或在控制台警告。
<script type="module"> 加载的模块能不能被普通脚本访问到?
不能直接访问。模块脚本的变量在模块作用域里,不在全局。如果确实需要让普通脚本访问模块里的值,可以在模块里显式地挂到 window 上,比如 window.myLib = { ... }。但这样做就失去了模块化的意义,一般不推荐。
多个 <script type="module"> 之间怎么共享数据?
通过 import 同一个模块来共享。因为模块只执行一次,不同的入口模块 import 同一个数据模块时拿到的是同一份实例。在那个数据模块里维护状态,所有导入方看到的就是同一份数据,这其实就是单例模式的天然实现。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。