注:下文 esm 指 ECMAScript Module ,即 ES6 的模块语法(import/export),cjs 指 CommonJS (module.exports/require)
浏览器端的ESM模块加载
浏览器中使用 esm 模块语法 import/export 或加载 ES6 模块是通过 script 标签实现时,必须加上type="module"
,从而浏览器会知道这是一个ES6模块。
浏览器对于带有type="module"
的<script>
都是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完,再执行模块脚本,等同于打开了<script>
标签的defer属性(可以使用async
属性)
esm 和 cjs 的差异
esm 是编译时加载(静态加载),有一个独立的模块依赖的解析阶段,模块输出的是接口(引用),import命令是异步加载的;
cjs 是运行时加载(动态加载),模块输出的是值(模块对象),值会被缓存,require 为同步加载
esm 输出的是接口,是对模块内值的一个只读引用,因此不同脚本加载模块时得到的接口指向的都是同一个实例。根据输出的接口去取值,所以模块内部值的改变会被不同的脚本读取到
cjs 输出的是值的拷贝(module.exports对象,缓存输出的值),这也是为什么 require 是运行时加载的,因为只有到运行的时候才能生成一个模块对象。一旦模块文件加载执行完毕就会输出了一个缓存值,模块内部后续的变化影响不到这个值(不同脚本之间引用的是同一个值),如果要取到模块内部变化之后的值,那么就需要特别定义一个 get 函数作为取值器来返回模块内部的值
CommonJS 的一个模块,就是一个脚本文件。require命令第一次加载该脚本,就会执行整个脚本,然后在内存生成一个对象
{
id: '...', // 模块名
exports: { ... }, // 模块输出的各个接口
loaded: true, // 是否执行完毕
...
}
以后需要用到这个模块的时候,就会到exports属性上面取值。
即使再次执行require命令,也不会再次执行该模块,而是到缓存中取值(这就是上述提及的值拷贝和缓存)。
也就是说,CommonJS 模块无论加载多少次,都只会在第一次加载时运行一次,以后再加载,就返回第一次运行的结果,除非手动清除系统缓存
参考:https://es6.ruanyifeng.com/#docs/module-loader#CommonJS-%E6%A8%A1%E5%9D%97%E7%9A%84%E5%8A%A0%E8%BD%BD%E5%8E%9F%E7%90%86
esm 模块和 cjs 模块相互引用
cjs 模块引入 esm 模块 —— 由于 esm 的加载、解析和执行都是异步的,cjs 都是同步的,不能通过 require() 来引入 esm 模块,需要利用 import()
(async () => {
await import("...");
})()
esm语法引入cjs模块 —— 可以直接引用,但是只能整体加载
import m from "cjsModule.js";
import { a } from "cjsModule.js"; // 报错
// 这是因为 ES6 模块需要支持静态代码分析,而 CommonJS 模块的输出接口是 module.exports ,是一个对象,无法被静态分析,所以只能整体加载
在一个文件中不建议 ESM 和 CJS 语法混用,尤其是在项目中如果涉及到 babel 编译的情况,往往会出现一些莫名其妙的错误
https://github.com/xiaoxiaojx/blog/issues/27
https://www.tangshuang.net/7686.html
循环加载
“循环加载”是指a脚本的执行依赖b脚本,而b脚本的执行又依赖a脚本
// 比如
// a.js
const a = require("./b.js");
// b.js
const a = require("./a.js");
在 esm 模块 和 cjs 模块中,循环加载的表现有差异:
cjs 只输出已经执行的部分,还未执行的部分不会输出(此时模块脚本还未执行完毕,模块输出值还会改变)
esm 先执行的脚本a引用另一个模块脚本b时,会先去执行脚本b,b中再引用脚本a时,引擎此时会认为b要从a引入的接口已经存在了,b脚本会正常执行,此时往往可能会报未定义的错误(这种未定义错误可以通过var或function的声明提升来解决)
https://es6.ruanyifeng.com/#docs/module-loader#%E5%BE%AA%E7%8E%AF%E5%8A%A0%E8%BD%BD
模块内容动态注入
esm注入:利用其输出的接口在不同脚本中都是指向同一个实例的特点
// module.js
// 输出一个对象引用,这个引用是只读的,但是内部的值是可以修改的
export default {
// 对象内部的属性是可以修改的
aInterface: {}
}
// inject.js
import m from "./module.js";
m.aInterface = {
msg: "new things"
}
cjs注入:利用其一旦使用require()
后就输出一个模块对象缓存,后续在不同脚本之间都使用该缓存值的特点
// module.js
// 被 require 后会生成一个对象,所有脚本都用这个对象
module.exports = {
aInterface: {}
}
// inject.js
// 一旦被 require 就会在执行完这个模块脚本后生成一个会被缓存的对象
const m = require("./module.js");
m.aInterface = {
msg: "new things"
}
参考
https://es6.ruanyifeng.com/#docs/module
https://es6.ruanyifeng.com/#docs/module-loader
https://zhuanlan.zhihu.com/p/337796076