本篇文章给大家带大家了解一下Node的两种模块规范(难以相容的 CJS 与 ESM),介绍一下CJS 和 ESM 的不同点,怎么实现 CJS、ESM 混写,希望对大家有所帮助!
自 13.2.0
版本开始,Nodejs 在保留了 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(); // 正常执行
到 Github 获取示例代码(test1):
https://github.com/VaJoy/BlogDemo3/tree/main/220220/test1
具体原因我们会在后续提及。
1.3 CJS 引用 ESM 模块的问题
假设你在开发一个供别人使用的开源项目,且使用 ESM 的形式导出模块,那么问题来了 —— 目前 CJS 的 require
函数无法直接引入 ESM 包,会报错:
let { foo } = require('./esm/b.js'); ^ Error [ERR_REQUIRE_ESM]: require() of ES Module BlogDemo3220220test2esmb.js from BlogDemo3220220test2require.js not supported. Instead change the require of b.js in BlogDemo3220220test2require.js to a dynamic import() which is available in all CommonJS modules. at Object.<anonymous> (BlogDemo3220220test2require.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'); })();
到 Github 获取示例代码(test2):
https://github.com/VaJoy/BlogDemo3/tree/main/220220/test2
查阅 dynamic import 文档
https://v8.dev/features/dynamic-import#dynamic
开源项目当然不能强制要求用户改用这种形式来引入,所以又得借助 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 区分开来处理。
查看 ESM 严格模式的