Node 对 ES Modules 支持
Node verison 13.2.0 起开始正式支持 ES Modules 特性。
注:虽然移除了 --experimental-modules 启动参数,但是由于 ESM loader 还是实验性的,所以运行 ES Modules 代码依然会有警告:
(node:47324) ExperimentalWarning: The ESM module loader is experimental.
在 NodeJS 中使用 ES Modules
在 Node 中使用 ESM 有两种方式:
1)在 package.json 中,增加 "type": "module" 配置;
文件目录结构:
.
├── index.js
├── package.json
└── utils
└── speak.js
js代码:
// utils/speak.js
export function speak() {
console.log('Come from speak.')
}
// index.js
import { speak } from './utils/speak.js';
speak(); //come from speak
2)在 .mjs 文件可以直接使用 import 和 export;
文件目录结构:
.
├── index.mjs
├── package.json
└── utils
└── sing.mjs
js代码:
// utils/sing.mjs
export function sing() {
console.log('Come from sing')
}
// index.mjs
import { sing } from './utils/sing.mjs';
sing(); //come from sing
注意:
- 若不添加上述两项中任一项,直接在 Node 中使用 ES Modules,则会抛出警告:
Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
- 根据 ESM 规范,使用 import 关键字并不会像 CommonJS 模块那样,在默认情况下以文件扩展名填充文件路径。因此,ES Modules 必须明确文件扩展名。
模块作用域
一个模块的作用域,由父级中有 "type: "module" 的 package.json 文件路径定义。而使用 .mjs 扩展文件加载模块,则不受限于包的作用域。
同理,package.json 中没有 type 标志的文件都会默认采用 CommonJS 模块机制, .cjs 类型的扩展文件使用 CommonJS 方式加载模块同样不受限于包的作用域。
在 CJS 文件中导入 ESM 模块
由于 ES Modules 的加载、解析和执行都是异步的,而 require() 的过程是同步的、所以不能通过 require() 来引用一个 ES6 模块。
ES6 提议的 import() 函数将会返回一个 Promise,它在 ES Modules 加载后标记完成。借助于此,我们可以在 CommonJS 中使用异步的方式导入 ES Modules:
// 使用 then() 来进行模块导入后的操作
import(“es6-modules.mjs”).then((module)=>{/*…*/}).catch((err)=>{/**…*/})
// 或者使用 async 函数
(async () => {
await import('./es6-modules.mjs');
})();
在 ESM 文件中导入 CJS 模块
在 ES6 模块里可以很方便地使用 import 来引用一个 CommonJS 模块,因为在 ES6 模块里异步加载并非是必须的:
import { default as cjs } from 'cjs';
// The following import statement is "syntax sugar" (equivalent but sweeter)
// for `{ default as cjsSugar }` in the above import statement:
import cjsSugar from 'cjs';
console.log(cjs);
console.log(cjs === cjsSugar);
CJS 和 ESM 的不同点
1.不同的加载逻辑
在 CJS 模块中,require() 是一个同步接口,它会直接从磁盘(或网络)读取依赖模块并立即执行对应的脚本。
ESM 标准的模块加载器则完全不同,它读取到脚本后不会直接执行,而是会先进入编译阶段进行模块解析,检查模块上调用了 import 和 export 的地方,并顺腾摸瓜把依赖模块一个个异步、并行地下载下来。
在此阶段 ESM 加载器不会执行任何依赖模块代码,只会进行语法检错、确定模块的依赖关系、确定模块输入和输出的变量。
最后 ESM 会进入执行阶段,按顺序执行各模块脚本。
所以我们常常会说,CommonJS 模块是运行时加载,ES6 模块是编译时输出接口
例如
- ES Modules 导入的模块会被预解析,以便在代码运行前导入;
- 在 CommonJS 中,模块将在运行时解析;
举一个简单的例子来直观的对比下二者的差别:
// ES Modules
// a.js
console.log('Come from a.js.');
import { hello } from './b.js';
console.log(hello);
// b.js
console.log('Come from b.js.');
export const hello = 'Hello from b.js';
输出:
Come from b.js.
Come from a.js.
Hello from b.js
同样的代码使用 CommonJS 机制:
// CommonJS
// a.js
console.log('Come from a.js.');
const hello = require('./b.js');
console.log(hello);
// b.js
console.log('Come from b.js.');
module.exports = 'Hello from b.js';
输出:
Come from a.js.
Come from b.js.
Hello from b.js
可以看到 ES Modules 预先解析了模块代码,而 CommonJS 是代码运行的时候解析的。
需要注意的是,根据 EMS 规范 import / export 必须位于模块顶级,不能位于作用域内;其次模块内的 import/export 会提升到模块顶部,这是在编译阶段完成的。
2.不同的模式
ESM 默认使用了严格模式(use strict),因此在 ES 模块中的 this 不再指向全局对象(而是 undefined),且变量在声明前无法使用。
这也是为何在浏览器中,<script> 标签如要启用原生引入 ES 模块能力,必须加上 type="module" 告知浏览器应当把它和常规 JS 区分开来处理。
3.ESM 支持“*** await”,但 CJS 不行。
ESM 支持 ***** await**(top-level await),即 ES 模块中,无须在 async 函数内部就能直接使用 await:
// index.mjs
const { foo } = await import('./c.js');
foo();
在 CSJ 模块中是没有这种能力的(即使使用了动态的 import 接口),这也是为何 require 无法加载 ESM 的原因之一。
试想一下,一个 CJS 模块里的 require 加载器同步地加载了一个 ES 模块,该 ES 模块里异步地 import 了一个 CJS 模块,该 CJS 模块里又同步地去加载一个 ES 模块…… 这种复杂的嵌套逻辑处理起来会变得十分棘手
4.ESM 缺乏 __filename 和 __dirname
在 CJS 中,模块的执行需要用函数包起来,并指定一些常用的值:
NativeModule.wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});'
];
所以我们才可以在 CJS 模块里直接用 __filename、__dirname。
而 ESM 的标准中不包含这方面的实现,即无法在 Node 的 ESM 里使用 __filename 和 __dirname
Node 中 ES Modules 的现状和未来
node端Js代码都是依赖 CommonJS 模块机制进行包管理的,引入esm,开发人员可以享受到与发布规范相关的许多好处。但需要注意的是,最新版 Node v18.11.0 中,该特性在npm中(Stability: 2 - Stable. Compatibility with the npm ecosystem is a high priority.)
从上方几点可以看出,在 Node.js 中,如果要把默认的 CJS 切换到 ESM,会存在巨大的兼容性问题。
这也是 Node.js 目前,甚至未来很长一段时间,都难以解决的一场模块规范持久战。
如果你希望不借助工具和规则,也能放宽心地使用 ESM,可以尝试使用 Deno 替代 Node,它默认采用了 ESM 作为模块规范(当然生态没有 Node 这么完善)。
借助工具实现 CJS、ESM 混写
借助构建工具可以实现 CJS 模块、ES 模块的混用,甚至可以在同一个模块同时混写两种规范的 API,让开发不再需要关心 Node.js 上面的限制。另外构建工具还能利用 ESM 在编译阶段静态解析的特性,实现 Tree-shaking 效果,减少冗余代码的输出。
这里我们以 rollup 为例,先做全局安装:
pnpm i -g rollup
接着再安装 rollup-plugin-commonjs 插件,该插件可以让 rollup 支持引入 CJS 模块(rollup 本身是不支持引入 CJS 模块的):
pnpm i --save-dev @rollup/plugin-commonjs
我们在项目根目录新建 rollup 配置文件 rollup.config.js:
import commonjs from 'rollup-plugin-commonjs';
export default {
input: 'index.js', // 入口文件
output: {
file: 'bundle.js', // 目标文件
format: 'iife'
},
plugins: [
commonjs({
transformMixedEsModules: true,
sourceMap: false,
})
]
};
plugin-commonjs默认会跳过所有含import/export的模块,如果要支持如import + require的混合写法,需要带transformMixedEsModules属性。
接着执行 rollup --config 指令,就能按照 rollup.config.js 进行编译和打包了。
示例
/** @file a.js **/
export let func = () => {
console.log("It's an a-func...");
}
export let deadCode = () => {
console.log("[a.js deadCode] Never been called here");
}
/** @file b.js **/
// named exports
module.exports = {
func() {
console.log("It's a b-func...")
},
deadCode() {
console.log("[b.js deadCode] Never been called here");
}
}
/** @file c.js **/
module.exports.func = () => {
console.log("It's a c-func...")
};
module.exports.deadCode = () => {
console.log("[c.js deadCode] Never been called here");
}
/** @file index.js **/
let a = require('./a');
import { func as bFunc } from './b.js';
import { func as cFunc } from './c.js';
a.func();
bFunc();
cFunc();
打包后的 bundle.js 文件如下:
(function () {
'use strict';
function getAugmentedNamespace(n) {
if (n.__esModule) return n;
var a = Object.defineProperty({}, '__esModule', {value: true});
Object.keys(n).forEach(function (k) {
var d = Object.getOwnPropertyDescriptor(n, k);
Object.defineProperty(a, k, d.get ? d : {
enumerable: true,
get: function () {
return n[k];
}
});
});
return a;
}
let func$1 = () => {
console.log("It's an a-func...");
};
let deadCode = () => {
console.log("[a.js deadCode] Never been called here");
};
var a$1 = /*#__PURE__*/Object.freeze({
__proto__: null,
func: func$1,
deadCode: deadCode
});
var require$$0 = /*@__PURE__*/getAugmentedNamespace(a$1);
// named exports
var b = {
func() {
console.log("It's a b-func...");
},
deadCode() {
console.log("[b.js deadCode] Never been called here");
}
};
var func = () => {
console.log("It's a c-func...");
};
let a = require$$0;
a.func();
b.func();
func();
})();
可以看到,rollup 通过 Tree-shaking 移除掉了从未被调用过的 c 模块的 deadCode 方法,但 a、b 两模块中的 deadCode 代码段未被移除,这是因为我们在引用 a.js 时使用了 require,在 b.js 中使用了 named exports,这些都导致了 rollup 无法利用 ESM 的特性去做静态解析。
常规在开发项目时,还是建议尽量使用 ESM 的语法来书写全部模块,这样可以最大化地利用构建工具来减少最终构建文件的体积
参考文章链接:
zhuanlan.zhihu.com/p/337796076
www.likecs.com/show-205207…