前言
早期Javascript这门语言是没有模块化的概念的,直到nodejs诞生,才把模块系统引入js。nodejs使用的是CJS
(Commonjs)规范,也就是我们平时所见的require
、module.exports
。而js语言标准的模块规范是ESM
(Ecmascript Module),也就是我们在前端工程大量使用的import
、export
语法。nodejs已经在逐步支持ESM,目前很多主流浏览器也已经原生支持ESM。
项目使用的是ESM还是CJS?
Node.js 8.5.0增加了ESM的实验性支持,使用--experimental-modules
标识,加上以.mjs
为后缀的文件名可以让nodejs执行ESM规范导入导出的模块。例如:
1
node --experimental-modules index.mjs
Node.js 12.17.0,移除了--experimental-modules
标识。虽然ESM还是试验性的,但已经相对稳定了。
之后的版本,nodejs按以下流程判断模块系统是用ESM还是CJS: 不满足以上判断条件的会以CJS兜底。如果你的工程遵循CJS规范,并不需要特殊的文件名后缀和设置package.json
type
字段等额外的处理。
当然你也可以明确告诉nodejs要用CJS,方法跟上面差不多:
- 文件以
.cjs
为后缀 package.json
里定义了"type": "commonjs"
--eval
或者STDIN
管道方式执行nodejs,带上--input-type=commonjs
标识
实际上我们很少见到有项目通过.mjs
、.cjs
这样的文件后缀来区分模块系统,一般都是使用package.json
里的type
字段。
模块入口
我们知道有很多第三方库同时支持在nodejs和浏览器环境执行,这种库通常会打包出CJS和ESM两种产物,CJS产物给nodejs用,ESM产物给webpack
之类的bundler使用。所以,当我们使用require
和import
导入模块moduleA
时,入口文件路径往往是不一样的。那么问题来了,如何让nodejs或者bundler找到对应的入口文件呢?
一般我们通过package.json的main
字段定义CJS的入口文件,module
字段定义ESM的入口文件。
1
2
3
4
5
{
"name": "moduleA",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js"
}
这样,nodejs和bundler就知道分别从./dist/cjs/index.js
和./dist/esm/index.js
导入模块了。
Node.js v12.16.0给package.json
增加了exports
字段,允许我们在不同条件下匹配不同的路径。exports
有很多用处,包括区分nodejs还是browser环境、区分development还是production环境、限制访问私有路径等。这里重点讲它对CJS和ESM模块导入的影响。
我们可以这么定义:
1
2
3
4
5
6
7
8
9
{
"name": "moduleA",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"exports": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
}
}
当使用require('moduleA')
时,实际导入的是node_modules/moduleA/dist/cjs/index.js
,而使用import moduleA from 'moduleA'
时,导入的是node_modules/moduleA/dist/esm/index.js
。
exports
的优先级比main
和module
高,也就是说,匹配上exports
的路径就不会使用main
和module
的路径。
咋一看好像exports
并没有给CJS和ESM带来多少新东西。的确,普通的场景来说,main
和module
字段已经满足需求,但是如果要针对不同路径或者环境引入不同的CJS或者ESM模块,exports
就显然更灵活。而且,exports
是新规范,我们也有必要了解甚至在工程里尝试使用。
当然,这里还是建议大家保留main
和module
字段,用来兼容不支持exports
字段的nodejs版本或bundler。
互操作
nodejs14以上版本ESM模块能够通过default import
、name import
、namespace import
等方式导入CJS模块,但反过来CJS模块只能通过dynamic import
即import()
导入ESM模块。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// default_add.mjs
export default function add(a, b) {
return a + b;
}
// name_add.mjs
export function add(a, b) {
return a + b;
}
// index.cjs
import('./default_add.mjs').then(
({ default: add }) => {
console.log('default import: ', add(1, 2)); // default import: 3
}
);
import('./name_add.mjs').then(
({ add }) => {
console.log('name import: ', add(1, 2)); // name import: 3
}
);
区别
特性被移除
如果想用ESM写nodejs,这里就要特别注意下。
ESM模块里没有__dirname
、__filename
这些变量,但我们可以通过import.meta.url
和nodejs的url
模块(使用firedirname也可以)来解析出dirname和filename。
1
2
3
4
5
6
// dir-path/index.mjs
import filedirname from 'filedirname';
const [filename, dirname] = filedirname(import.meta.url);
console.log('dirname: ', dirname); // dirname: dir-path
console.log('filename: ', filename); // filename: dir-path/index.mjs
ESM引入json模块目前只能通过实验性的标识--experimental-json-modules
来实现
1
2
3
4
5
6
// index.mjs
import { readFile } from 'fs/promises';
const json = JSON.parse(
await readFile(new URL('./package.json', import.meta.url))
);
console.log(json);
1
node index.mjs --experimental-json-modules
ESM不支持native模块导入,移除require.resolve,不过这两项可以通过module.createRequire()
实现。
另外,ESM移除NODE_PATH
、resolve.extensions
和resolve.cache
(ESM有自己的缓存机制)。
上面说到的很多在ESM里移除的能力,我们可以通过module.createRequire()
,在ESM里也能使用require
(正常来说,ESM模块里使用require
会报错),从而曲线救国。
1
2
3
4
5
6
7
8
9
10
11
// util.cjs
exports.add = function add(a, b) {
return a + b;
};
// index.mjs
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const { add } = require('./util.cjs');
console.log(add(1, 2)); // 3
严格模式vs非严格模式
CJS默认是非严格模式,而ESM默认是严格模式。
引用vs拷贝
CJS模块require
导入的是值的拷贝,而ESM导入的是值的引用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// a.cjs
let age = 18;
exports.setAge = function setAge(val) {
age = val;
};
exports.age = age;
// index.cjs
const { age, setAge } = require('./a.cjs');
console.log(age); // 18
setAge(19);
console.log(age); // 18
// a.mjs
export let age = 18;
export function setAge(val) {
age = val;
}
// index.mjs
import { age, setAge } from './a.mjs';
console.log(age); // 18
setAge(19);
console.log(age); // 19
可以看到,index.cjs
从a.cjs
引入了age
,并通过setAge
修改了a.cjs
里的age
,但是最后打印的age
没有变,而ESM则相反。
动态vs静态
我们都知道javascript是一门JIT语言,v8引擎拿到js代码后会边编译边执行,在编译的时候v8就给import
导入的模块建立静态的引用,并且不能在运行时不能更改。所以import
都放在文件开头,不能放在条件语句里。
而require
导入模块是在运行时才对值进行拷贝,所以require
的路径可以使用变量,并且require
可以放在代码的任何位置。
基于这个差异,ESM比CJS好做tree-shaking。
异步vs同步
ESM是顶层await的设计,而require是同步加载,所以require无法导入ESM模块,但是可以通过import()
导入。
web项目中ESM的处理
我们平时用react、vue开发业务的时候都是遵循ESM规范,但最终交给浏览器执行的并不是ESM的代码,因为需要兼容旧版本的浏览器嘛。处理过程大致如下:
- ESM规范编写代码,使用
import
、export
; - babel等编译器将ESM代码转成CJS代码;
- 但是浏览器不支持CJS规范啊,所以webpack按照CJS规范实现了类似
require
和module.exports
的模块加载机制。
这里顺便说一下最近比较热门的话题:esbuild 0.14.4版本在CJS和ESM的转换上引入了breaking change,掀起社区热烈的讨论,esbuild也在changelog里详细记录了事情的来由。大概情况就是babel为了将ESM准确降级成CJS,把export default 0
处理成module.exports.default = 0
,然后通过__esModule
是否为true决定import foo from 'bar'
时foo是module.exports.default
还是module.exports
来保证import foo from 'bar'
和const foo = require('bar')
等价。但是nodejs ESM的实现是将export default
和module.exports
对等起来。这种不一致导致esbuild对nodejs和browser这两个环境下使用的三方库的处理出现错误。
最后
这篇文章结合热门话题讲了一些ESM和CJS的知识点,讲得比较杂,但也算是个人的总结吧,希望对大家有用。
参考资料
- https://github.com/nodejs/node/blob/master/CHANGELOG.md
- https://nodejs.medium.com/announcing-a-new-experimental-modules-1be8d2d6c2ff
- https://nodejs.org/api/packages.html
- https://nodejs.org/api/esm.html
- https://nodejs.org/api/modules.html
- https://zhuanlan.zhihu.com/p/113009496
- https://github.com/evanw/esbuild/blob/master/CHANGELOG.md#0144
- https://redfin.engineering/node-modules-at-war-why-commonjs-and-es-modules-cant-get-along-9617135eeca1