先记住这个答案
降级方案是在页面上同时放两个标签:type="module" 加载现代代码,nomodule 加载转译过、带 polyfill 的兼容代码。支持 ESM 的浏览器看到 nomodule 会跳过它,只执行模块脚本;不支持 ESM 的老浏览器不认识 type="module",把它当成未知类型忽略,又不认识 nomodule 属性,于是正常执行兼容脚本。要注意三点:两类脚本执行时机不同;早期个别浏览器会两份都执行或都下载;支持模块不代表支持所有新语法。
- 新浏览器执行模块并跳过
nomodule - 模块脚本默认延后执行
- 早期个别浏览器两份都执行,或者两份都下载
- 支持模块不等于支持所有新语法,现代包也要设好目标
标准写法和它为什么能生效
先看页面上的标签怎么写,再看新老浏览器各自会怎么处理。
<!-- 支持 ESM 的浏览器:执行这个,默认 defer -->
<script type="module" src="/assets/app.modern.js"></script>
<!-- 不支持 ESM 的浏览器:执行这个,加 defer 让时机和上面一致 -->
<script nomodule defer src="/assets/app.legacy.js"></script>这套写法利用了两个「不认识就忽略」的规则。老浏览器遇到不认识的 type 值,会认为这不是 JavaScript,整个标签不处理;遇到不认识的属性,直接忽略属性,标签照常执行。新浏览器则明确知道 nomodule 的意思是「我支持模块,这个不用管」。
app.legacy.js 那一行加了 defer。模块脚本天生就是延迟执行的,等 HTML 解析完再按顺序运行;普通脚本不加 defer 会阻塞解析、下载完立即执行。两边时机不一致,代码里如果依赖 DOM 已经解析完,老浏览器上就可能出问题。
需要额外处理的几个坑
这套方案原理简单,但在真实浏览器里有几个已知的问题。
- 重复执行、重复下载和能力判断
- 早期有个别 Safari 版本支持了模块,却还没支持
nomodule,结果两份代码都执行,页面初始化两次。当时的通行做法是在页面里插一段小的检测脚本专门修正这个问题。 还有一些老版本的 IE 和 Edge,虽然只执行其中一份,却会把两份都下载下来,白白多一份流量。 第三个问题更隐蔽:type="module"只说明浏览器支持 ESM,不说明它支持你现代包里用到的所有东西。比如较早支持模块的浏览器里,有一部分还不支持动态import()。Vite 的 legacy 插件就会在页面里额外放一段检测,发现不支持这些特性时,也切换到兼容包。
容易答错的地方
- 以为
nomodule是给不支持 JS 的环境用的 - 它和
<noscript>没关系。nomodule只针对「能跑 JavaScript 但不支持 ES 模块」的浏览器,目的是给它们一份普通脚本。 - 以为现代包可以随便用最新语法
- 现代包的构建目标应该是「支持 ESM 的浏览器」这一批里最老的那些,而不是最新版 Chrome。用
browserslist设定目标,让构建工具按目标转译,不要默认不转译。 - 两份包的全局副作用不一致
- 兼容包里带了 polyfill,会修改
Array.prototype等全局对象;现代包没有。某段代码在老浏览器上能跑,是因为 polyfill 恰好补上了,换成现代包反而报错。测试时要两份都验。
面试官还会怎么问?
现在还有必要做这种降级吗?
看项目的用户群。面向大众的 C 端产品先看访问统计里老浏览器的占比,占比很低时很多团队已经直接不再构建兼容包,省下一半构建时间。政企、内网系统可能还有老版本 IE 内核,就得保留。
能不能只用 nomodule 做 polyfill 加载?
可以。只把 polyfill 放进 nomodule 脚本,业务代码用普通脚本并转译到老语法,这样新浏览器不用下载 polyfill。不过业务代码就得一直按老语法转译,享受不到现代包体积更小的好处。
服务端根据 UA 返回不同的包不是更准吗?
UA 判断能避免重复下载,但要维护 UA 规则库,还会和 CDN 缓存冲突,同一个 URL 要按 UA 缓存不同内容。module 加 nomodule 的好处是纯前端、无需服务端参与,缓存也简单。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。