likes
comments
collection
share

ESM和CJS模块杂谈

作者站长头像
站长
· 阅读数 24

前言

早期Javascript这门语言是没有模块化的概念的,直到nodejs诞生,才把模块系统引入js。nodejs使用的是CJS(Commonjs)规范,也就是我们平时所见的requiremodule.exports。而js语言标准的模块规范是ESM(Ecmascript Module),也就是我们在前端工程大量使用的importexport语法。nodejs已经在逐步支持ESM,目前很多主流浏览器也已经原生支持ESM。

项目使用的是ESM还是CJS?

Node.js 8.5.0增加了ESM的实验性支持,使用--experimental-modules标识,加上以.mjs为后缀的文件名可以让nodejs执行ESM规范导入导出的模块。例如:

node --experimental-modules index.mjs

Node.js 12.17.0,移除了--experimental-modules标识。虽然ESM还是试验性的,但已经相对稳定了。 ​

之后的版本,nodejs按以下流程判断模块系统是用ESM还是CJS: 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使用。所以,当我们使用requireimport导入模块moduleA时,入口文件路径往往是不一样的。那么问题来了,如何让nodejs或者bundler找到对应的入口文件呢? ​

一般我们通过package.json的main字段定义CJS的入口文件,module字段定义ESM的入口文件。

{
	"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模块导入的影响。 ​

我们可以这么定义:

{
	"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的优先级比mainmodule高,也就是说,匹配上exports的路径就不会使用mainmodule的路径。

咋一看好像exports并没有给CJS和ESM带来多少新东西。的确,普通的场景来说,mainmodule字段已经满足需求,但是如果要针对不同路径或者环境引入不同的CJS或者ESM模块,exports就显然更灵活。而且,exports是新规范,我们也有必要了解甚至在工程里尝试使用。 ​

当然,这里还是建议大家保留mainmodule字段,用来兼容不支持exports字段的nodejs版本或bundler。

互操作

nodejs14以上版本ESM模块能够通过default importname importnamespace import等方式导入CJS模块,但反过来CJS模块只能通过dynamic importimport()导入ESM模块。

// 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。

// 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来实现

// index.mjs
import { readFile } from 'fs/promises';
const json = JSON.parse(
  await readFile(new URL('./package.json', import.meta.url))
);
console.log(json);
node index.mjs --experimental-json-modules

ESM不支持native模块导入,移除require.resolve,不过这两项可以通过module.createRequire()实现。 ​

另外,ESM移除NODE_PATHresolve.extensionsresolve.cache(ESM有自己的缓存机制)。 ​

上面说到的很多在ESM里移除的能力,我们可以通过module.createRequire(),在ESM里也能使用require(正常来说,ESM模块里使用require会报错),从而曲线救国。

// 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导入的是值的引用。

// 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.cjsa.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的代码,因为需要兼容旧版本的浏览器嘛。处理过程大致如下:

  1. ESM规范编写代码,使用importexport;
  2. babel等编译器将ESM代码转成CJS代码;
  3. 但是浏览器不支持CJS规范啊,所以webpack按照CJS规范实现了类似requiremodule.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 defaultmodule.exports对等起来。这种不一致导致esbuild对nodejs和browser这两个环境下使用的三方库的处理出现错误。

最后

这篇文章结合热门话题讲了一些ESM和CJS的知识点,讲得比较杂,但也算是个人的总结吧,希望对大家有用。

参考资料

  1. github.com/nodejs/node…
  2. nodejs.medium.com/announcing-…
  3. nodejs.org/api/package…
  4. nodejs.org/api/esm.htm…
  5. nodejs.org/api/modules…
  6. zhuanlan.zhihu.com/p/113009496
  7. github.com/evanw/esbui…
  8. redfin.engineering/node-module…