Node 模块规范鏖战:难以相容的 CJS 与 ESM
自 13.2.0
版本开始,Node.js 在保留了 CommonJS(CJS)语法的前提下,新增了对 ES Modules(ESM)语法的支持。
天下苦 CJS 久已,Node 逐渐拥抱新标准的规划当然值得称赞,我们也会展望未来 Node 不再需要借助工具,就能打破两种模块化语法的壁垒……
但实际上,一切没有想象中的那么美好。
一、并不完美的 ESM 支持
1.1 在 Node 中使用 ESM
Node 默认只支持 CJS 语法,这意味着你书写了一个 ESM 语法的 js 文件,将无法被执行。
如果想在 Node 中使用 ESM 语法,有两种可行方式:
- ⑴ 在
package.json
中新增"type": "module"
配置项。 - ⑵ 将希望使用 ESM 的文件改为
.mjs
后缀。
对于第一种方式,Node 会将和 package.json
文件同路径下的模块,全部当作 ESM 来解析。
第二种方式不需要修改 package.json
,Node 会自动地把全部 xxx.mjs
文件都作为 ESM 来解析。
同理,如果在
package.json
文件中设置"type": "commonjs"
,则表示该路径下模块以 CJS 形式来解析。
如果文件后缀名为.cjs
,Node 会自动地将其作为 CJS 模块来解析(即使在package.json
中配置为 ESM 模式)。
我们可以通过上述修改 package.json
的方式,来让全部模块都以 ESM 形式执行,然后项目上的模块都统一使用 ESM 语法来书写。
如果存在较多陈旧的 CJS 模块懒得修改,也没关系,把它们全部挪到一个文件夹,在该文件夹路径下新增一个内容为 {"type": "commonjs"}
的 package.json
即可。
Node 在解析某个被引用的模块时(无论它是被 import
还是被 require
),会根据被引用模块的后缀名,或对应的 package.json
配置去解析该模块。
1.2 ESM 引用 CJS 模块的问题
ESM 基本可以顺利地 import
CJS 模块,但对于具名的 exports(Named exports,即被整体赋值的 module.exports
),只能以 default export 的形式引入:
/** @file cjs/a.js **/
// named exports
module.exports = {
foo: () => {
console.log("It's a foo function...")
}
}
/** @file index_err.js **/
import { foo } from './cjs/a.js';
// SyntaxError: Named export 'foo' not found. The requested module './cjs/a.js' is a CommonJS module, which may not support all module.exports as named exports.
foo();
/** @file index_err.js **/
import pkg from './cjs/a.js'; // 以 default export 的形式引入
pkg.foo(); // 正常执行
具体原因我们会在后续提及。
1.3 CJS 引用 ESM 模块的问题
假设你在开发一个供别人使用的开源项目,且使用 ESM 的形式导出模块,那么问题来了 —— 目前 CJS 的 require
函数无法直接引入 ESM 包,会报错:
let { foo } = require('./esm/b.js');
^
Error [ERR_REQUIRE_ESM]: require() of ES Module BlogDemo3\220220\test2\esm\b.js from BlogDemo3\220220\test2\require.js not supported.
Instead change the require of b.js in BlogDemo3\220220\test2\require.js to a dynamic import() which is available in all CommonJS modules.
at Object.<anonymous> (BlogDemo3\220220\test2\require.js:4:15) {
code: 'ERR_REQUIRE_ESM'
}
按照上述错误陈述,我们不能并使用 require
引入 ES 模块(原因会在后续提及),应当改为使用 CJS 模块内置的动态 import
方法:
import('./esm/b.js').then(({ foo }) => {
foo();
});
// or
(async () => {
const { foo } = await import('./esm/b.js');
})();
开源项目当然不能强制要求用户改用这种形式来引入,所以又得借助 rollup 之类的工具将项目编译为 CJS 模块……
由上可见目前 Node.js 对 ESM 语法的支持是有限制的,如果不借助工具处理,这些限制可能会很糟心。
对于想入门前端的新手来说,这些麻烦的规则和限制也会让人困惑。
截至我落笔书写本文时, Node.js LTS 版本为 16.14.0
,距离开始支持 ESM 的 13.2.0
版本已过去了两年多的时间。
那么为何 Node.js 到现在还无法打通 CJS 和 ESM?
答案并非 Node.js 敌视 ESM 标准从而迟迟不做优化,而是因为 —— CJS 和 ESM,二者真是太不一样了。
二、CJS 和 ESM 的不同点
2.1 不同的加载逻辑
在 CJS 模块中,require()
是一个同步接口,它会直接从磁盘(或网络)读取依赖模块并立即执行对应的脚本。
ESM 标准的模块加载器则完全不同,它读取到脚本后不会直接执行,而是会先进入编译阶段进行模块解析,检查模块上调用了 import
和 export
的地方,并顺腾摸瓜把依赖模块一个个异步、并行地下载下来。
在此阶段 ESM 加载器不会执行任何依赖模块代码,只会进行语法检错、确定模块的依赖关系、确定模块输入和输出的变量。
最后 ESM 会进入执行阶段,按顺序执行各模块脚本。
所以我们常常会说,CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
在上方 1.2 小节,我们曾提及到 ESM 中无法通过指定依赖模块属性的形式引入 CJS named exports:
/** @file cjs/a.js **/
// named exports
module.exports = {
foo: () => {
console.log("It's a foo function...")
}
}
/** @file index_err.js **/
import { foo } from './cjs/a.js';
// SyntaxError: Named export 'foo' not found. The requested module './cjs/a.js' is a CommonJS module, which may not support all module.exports as named exports.
foo();
这是因为 ESM 获取所指定的依赖模块属性(花括号内部的属性),是需要在编译阶段进行静态分析的,而 CJS 的脚本要在执行阶段才能计算出它们的 named exports 的值,会导致 ESM 在编译阶段无法进行分析。
2.2 不同的模式
ESM 默认使用了严格模式(use strict
),因此在 ES 模块中的 this
不再指向全局对象(而是 undefined
),且变量在声明前无法使用。
这也是为何在浏览器中,<script>
标签如要启用原生引入 ES 模块能力,必须加上 type="module"
告知浏览器应当把它和常规 JS 区分开来处理。
2.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 模块…… 这种复杂的嵌套逻辑处理起来会变得十分棘手。
2.4 ESM 缺乏 __filename 和 __dirname
在 CJS 中,模块的执行需要用函数包起来,并指定一些常用的值:
NativeModule.wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});'
];
所以我们才可以在 CJS 模块里直接用 __filename
、__dirname
。
而 ESM 的标准中不包含这方面的实现,即无法在 Node 的 ESM 里使用 __filename
和 __dirname
。
参考:Node.js 源码。
从上方几点可以看出,在 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 的语法来书写全部模块,这样可以最大化地利用构建工具来减少最终构建文件的体积。
希望本文能为你提供帮助,共勉~
FYI:
Node Modules at War: Why CommonJS and ES Modules Can’t Get Along
CommonJs和ES6 module的区别 - 王玉略的回答
阮一峰 ES6 - Module
Node 模块规范鏖战:难以相容的 CJS 与 ESM的更多相关文章
- Commonjs规范及Node模块实现
前面的话 Node在实现中并非完全按照CommonJS规范实现,而是对模块规范进行了一定的取舍,同时也增加了少许自身需要的特性.本文将详细介绍NodeJS的模块实现 引入 nodejs是区别于java ...
- 兼容多种模块规范(AMD,CMD,Node)的代码
在JavaScript模块化开发中,为了让同一个模块可以运行在前后端,以及兼容多种模块规范(AMD,CMD,Node),类库开发者需要将类库代码包装在一个闭包内. AMD规范 AMD,即“异步模块定义 ...
- 【转】Commonjs规范及Node模块实现
前言: Node在实现中并非完全按照CommonJS规范实现,而是对模块规范进行了一定的取舍,同时也增加了少许自身需要的特性.本文将详细介绍NodeJS的模块实现 引入 nodejs是区别于javas ...
- [转]模块化——Common规范及Node模块实现
Node在实现中并非完全按照CommonJS规范实现,而是对模块规范进行了一定的取舍,同时也增加了少许自身需要的特性.本文将详细介绍NodeJS的模块实现 引入 nodejs是区别于javascrip ...
- node模块加载层级优化
模块加载痛点 大家也或多或少的了解node模块的加载机制,最为粗浅的表述就是依次从当前目录向上级查询node_modules目录,若发现依赖则加载.但是随着应用规模的加大,目录层级越来越深,若是在某个 ...
- 浅析JS中的模块规范(CommonJS,AMD,CMD)////////////////////////zzzzzz
浅析JS中的模块规范(CommonJS,AMD,CMD) 如果你听过js模块化这个东西,那么你就应该听过或CommonJS或AMD甚至是CMD这些规范咯,我也听过,但之前也真的是听听而已. ...
- Javascript模块规范(CommonJS规范&&AMD规范)
Javascript模块化编程(AMD&CommonJS) 前端模块化开发的价值:https://github.com/seajs/seajs/issues/547 模块的写法 查看 AMD规 ...
- JS中的模块规范(CommonJS,AMD,CMD)
JS中的模块规范(CommonJS,AMD,CMD) 如果你听过js模块化这个东西,那么你就应该听过或CommonJS或AMD甚至是CMD这些规范咯,我也听过,但之前也真的是听听而已. 现在就看看吧, ...
- TypeScript和Node模块解析策略
一般我们在模块化编码时,总会导入其它模块,通常我们使用如下语法: import { A } from './a'; // ES6语法 import { A } from 'a'; var A = re ...
随机推荐
- 第3届云原生技术实践峰会(CNBPS 2020)重磅开启,“原”力蓄势待发!
CNBPS 2020将在11月19-21日全新启动!作为国内最有影响力的云原生盛会之一,云原生技术实践峰会(CNBPS)至今已举办三届. 在2019年的CNBPS上,灵雀云CTO陈恺喊出"云 ...
- 《剑指offer》面试题58 - II. 左旋转字符串
问题描述 字符串的左旋转操作是把字符串前面的若干个字符转移到字符串的尾部.请定义一个函数实现字符串左旋转操作的功能.比如,输入字符串"abcdefg"和数字2,该函数将返回左旋转两 ...
- leetcode 13. 罗马数字转整数 及 12. 整数转罗马数字
13. 罗马数字转整数 问题描述 罗马数字包含以下七种字符: I, V, X, L,C,D 和 M. 字符 数值 I 1 V 5 X 10 L 50 C 100 D 500 M 1000 例如,罗马数 ...
- HttpRunner3的用例是怎么运行起来的
在PyCharm中打开examples/httpbin/basic_test.py: 首先映入眼帘的是左上角那个绿色小箭头,点了一下,可以直接运行,意味着HttpRunner是能够直接被pytest驱 ...
- Spark-寒假-实验3
1.安装 Hadoop 和 Spark 进入 Linux 系统,参照本教程官网"实验指南"栏目的"Hadoop 的安装和使用",完成 Hadoop 伪分布式模式 ...
- Github基于Web的编辑器
在 GitHub 存储库中使用基于 Web 的编辑器来创建和提交代码更改. 关于 GitHub 基于 Web 的编辑器 基于 Web 的编辑器引入了全新的轻量级编辑体验,可完全在您的浏览器中运行.使用 ...
- asp.net core监控—引入Prometheus(三)
上一篇博文中说到Prometheus有四种指标类型:Counter(计数器).Gauge(仪表盘).Histogram(直方图).Summary(摘要),并且我们做了一个Counter的Demo,接下 ...
- Jupyter Notebook 更改字体、字体大小、行高
(废话):今天在做实验的时候遇到了一点问题,就问了问本科的室友,结果室友推荐我使用Jupyter Notebook来写代码,以前看其他同学使用过,但是一直在用Pycharm写,需要的时候顶多是Debu ...
- 010 Linux 文本统计与去重 (wc 和 uniq)
wc 命令一般是作为组合命令的一员与其他命令一同起到统计的作用.而一般情况下使用wc -l 命令较多. uniq 可检查文本文件中重复出现的行,一般与 sort 命令结合使用.一起组合搭配使用完成统计 ...
- 体验 正式发布 的OSM v1.0.0 版本
2021年10月份发布了OSM 1.0 RC[1],在过去的几个月里,OSM 的贡献者一直在努力为 v1.0.0 版本的发布做准备.2022年2月1日,OSM 团队正式发布 1.0.0 版本[2]. ...