了解 ESM 和 CJS
推荐文章:
让我们看看 Obsidian CEO 是怎么使用 Obsidian 的 – 效率火箭,火箭君的新博客
分别介绍两种模块
- ESM
- CJS
两种模块的互相转换
- ESM 转 CJS
- CJS 转 ESM
一、ESM 和 CJS 的导入和导出
ESM
// app.ts
// esm 的导入
import * as fs from 'fs'
const app = 'xxx'
// esm 的两种导出:1. 命名导出,2.导出为默认值
export const appBuf = fs.readFileSync('a.txt')
export default app
对于这两种导出,对应的导出是不同的
默认导出:直接就作为单独一个变量使用
命名导出:都是放到一个对象里面,导入时可以通过对象解构来使用
// app1.ts
import app, { appBuf } from '../app'
const a = app
const b = appBuf
CJS
// app.ts
// cjs 的导入
const fs = require('fs')
const app = 'xxx'
const appBuf = fs.readFileSync('a.txt')
// cjs 的导出
module.exports = {
app,
appBuf
}
在 cjs 模块中,cjs 模块的导入就是只有通过 require
函数来导入使用
// app1.ts
// cjs 的导入,然后解构使用
const { app, appBuf } = require('../app')
console.log(app)
console.log(appBuf)
二、两种模块的互相转换
ESM 转 CJS
ESM 转 CJS 的场景很常见,例如:
- 使用 ESM 开发,正常来说,在本地开发的时候,一般都要利用到
ESM 转 CJS
。比如:利用ts-node-dev
在本地开发时,需要生成的代码都是 CJS 模块的,但是开发写的代码是 ESM,所以这里就利用了模块转换了。 - npm 库,一般开发一个 npm 库都是使用 ESM 开发,输出提供了 ESM 和 CJS,以供开发者选择。
各大工具,如 TSC
、Babel、Vite、webpack、Rollup
等,都自带了 ESM 转 CJS 的能力。上面 ts-node-dev 就是利用的 TSC。
export 的转换
情况一:仅默认导出
export default 'xxx'
Rollup 会转换成如下,因为 modules.exports
导出的整个东西就是默认导出
moudule.exports = 'xxx'
用 CJS 导入该模块:
const lib = require('lib')
// 'xxx'
console.log(lib)
情况二:仅命名导出
export const a = 123
export const b = 456
Rollup 转换成如下,命名导出利用了 exports 对象,用 module.exports.xxx
一个个导出就行
module.exports.a = 123
module.exports.b = 456
用 CJS 引入该模块
const {a, b} = require('lib')
// 123 456
console.log(a, b)
情况三:两者同时存在
// lib.js
export default 666
export const a = 123
export const b = 234
这时就不是单单上面的组合了,比如下面这样:
modules.exports = 666
// 这两个会报错
module.exports.a = 123
module.exports.b = 234
毕竟 modules.exports
不是对象,因此设置不了属性。
然后就考虑下面这样:
- module.exports.default:为默认导出
- module.exports.xxx:为命名导出
为了和前两种情况区分,还增加了一个标记:__esModule
,下面就是实际代码:
+ Object.defineProperty(exports, '__esModule', { value: true })
+ module.exports.default = 666
- module.exports = 666
module.exports.a = 123
module.exports.b = 234
同样 CJS 模块引入:
const lib = require('lib')
// 666 123 234
console.log(lib.default, lib.a, lib.b)
上面我们用了 .default
访问默认导出,为啥我们平时在项目开发中没有感知到这个问题呢?因为一般我们使用 ESM 写项目,然后编译成 CJS 来运行。
// ESM 模块中导入上面代码:
// foo.js
import lib, {a, b} from 'lib'
console.log(lib, a, b)
在 rollup 中会被转换成如下代码:
'use strict';
var lib = require('lib');
function _interopDefault (e) {
return e && e.__esModule ? e : { default: e };
}
var lib__default = /*#__PURE__*/_interopDefault(lib);
console.log(lib__default.default, lib.a, lib.b);
\_interopDefault 函数会自动根据 \_\_esModule,将导出对象标准化,使 .default 一定为默认导出,并保证默认导出的值是正确的行为:
- 如果有 \_\_esModule,不用处理,就是原来值
- 如果没有 \_\_esModule,将模块值赋值给
default
属性,作为默认导出
我们的项目,在编译的时候,全部 ESM 模块都转为 CJS(不是只转换一个,所有都会被转),在转换过程中它自动屏蔽了模块默认导出的差异,由于编译工具已经帮我们处理好,因此实际运行的时候没有任何感知。
如果我们直接写 CJS,去引入 ESM 转换后的 CJS,就需要自行处理该问题。在 Rollup 官网上一个示例:
// your-lib package entry
export default 'foo';
export const bar = 'bar';
// a CommonJS consumer
/* require( "your-lib" ) returns {default: "foo", bar: "bar"} */
const foo = require('your-lib').default;
const bar = require('your-lib').bar;
/* or using destructuring */
const { default: foo, bar } = require('your-lib');
要想尽量避免这种情况,建议全部都使用命名导出,由于没有默认导出,就不需要担心默认导出是 module.exports
还是 module.exports.default
,都用以下方式进行引入即可:
const {a, b} = require('lib')
这样开发者在任何情况下都没有心智负担。
import 的转换
这部分前面已经基本讲完了
import lib from 'lib'
import {a, b} from 'lib'
console.log(lib, a, b)
会被转换成:
'use strict';
var lib = require('lib');
function _interopDefault (e) {
return e && e.__esModule ? e : { default: e };
}
var lib__default = /*#__PURE__*/_interopDefault(lib);
console.log(lib__default.default, lib.a, lib.b);
为什么这里要用 _interopDefault
函数,因为为了统一上面三种情况的导出,屏蔽了不同情况下默认导出的差异,这样 ESM 转 CJS 就不需要关注默认导出的差异问题。
小结
对于 ESM 转 CJS,不同的工具的输出稍微有些不同,上面介绍的是 Rollup
的转换方式,下面是 TSC
的转换方式,方式都大差不差
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var tslib_1 = require("tslib");
// 这个 __importStar 函数和 _interopDefault 函数类似
var a_1 = tslib_1.__importStar(require("./a"));
console.log(a_1.a, a_1.b, a_1.default);
不过这些工具的转换思路都是遵守 __esModule
的约定,标记 __esModule
的模块默认导出是 .default
。
CJS 转 ESM
CJS 转 ESM 的场景不多,一般不会用 CJS 来写 npm 库,然后输出 ESM。因此一般是开发 ESM 项目,然后引入了 CJS 依赖,且编译输出 ESM 时,才会用到 CJS 到 ESM 的转换。
那为什么我们用 webpack、ts-node-dev(tsc) 写 ESM 时,一般都会引入 CJS,但是我们基本上没有遇到问题?
要运行 ESM 引入 CJS 的代码,有两种方式:
- 把 ESM 转 CJS,然后运行 CJS
- 把 CJS 转成 ESM,然后运行 ESM
因为 webpack、ts-node-dev(tsc) 都是第一种,而 ESM 转 CJS 能够很好转换。
CJS 转 ESM,没有一种统一的转换标准(相对来说,ESM 转 CJS 有 __esModule
约定),不同的工具和库,可能转换出来的结果是不一样的,可能会导致代码不兼容。
提供 rollup.config.mjs
方便复现,为了模拟导入外部 CJS 模块,需要将 ./lib
模块配置成外部模块。
import {cleandir} from "rollup-plugin-cleandir";
import path from "path";
import commonjs from "@rollup/plugin-commonjs";
export default {
input: 'index.js',
output: [
// {file: path.resolve('./out/bundle.js'), format: 'cjs'},
{file: path.resolve('./out/bundle-esm.mjs'), format: 'esm'},
],
external: ['./lib'], // 将 './lib' 模块视为外部模块
plugins: [
cleandir('out'),
commonjs(),
]
};
export 的转换
场景一:
module.exports = {
a: 3,
b: 4
}
Rollup 会转换成如下:module.exports
被当做默认导出
function getDefaultExportFromCjs (x) {
return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
}
var js = {
a: 3,
b: 4
};
var index = /*@__PURE__*/getDefaultExportFromCjs(js);
export { index as default };
而 esbuild 会这样转换:
var __getOwnPropNames = Object.getOwnPropertyNames;
// 辅助函数 `__require` 为了模拟 CommonJS 的 `require` 机制。
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
var require_js = __commonJS({
"index.js"(exports, module) {
module.exports = {
a: 3,
b: 4
};
}
});
export default require_js();
esbuild 会给代码包一层辅助函数,好处是这样编译工具就不需要考虑代码的真正意义,直接简单包一层即可。这种情况下,虽然 Rollup 和 esbuild 转换的代码不太相同,但代码的运行结果是相同的。
场景二
exports.c = 123
exports.d = 456
这种情况下,Rollup 会转换成 默认导出
和 命名导出
,转换结果:
var js = {};
var c = js.c = 123;
var d = js.d = 456;
export { c, d, js as default };
而 esbuild 的转换结果为:
var __getOwnPropNames = Object.getOwnPropertyNames;
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
var require_js = __commonJS({
"index.js"(exports) {
exports.c = 123;
exports.d = 456;
}
});
export default require_js();
这里 esbuild 还是给代码包装一层辅助函数,但是和 Rollup 转换的代码相比,Rollup 多了一个 默认导出
。
场景三
exports.d = 123 // 当然,这个是无效的
module.exports = {
a: 3,
b: 4
}
exports.c =123
Rollup 会编译成如下:
function getDefaultExportFromCjs (x) {
return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
}
var js = {exports: {}};
js.exports;
// 包装辅助函数
(function (module, exports) {
exports.d = 123;
module.exports = {
a: 3,
b: 4
};
exports.c =123;
} (js, js.exports));
var jsExports = js.exports;
var index = /*@__PURE__*/getDefaultExportFromCjs(jsExports);
export { index as default };
esbuild 这里同样还是包装了一层辅助函数
var __getOwnPropNames = Object.getOwnPropertyNames;
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
var require_js = __commonJS({
"index.js"(exports, module) {
exports.d = 123;
module.exports = {
a: 3,
b: 4
};
exports.c = 123;
}
});
export default require_js();
Rollup 和 esbuild 两个都包装了辅助函数,辅助函数的好处:不需要关注代码逻辑。
上面 exports.d = 123;
都出现在结果中,这就体现了这种情况下两者都没有分析代码的语义。
require 的转换
// lib.js
module.exports = {
c: 123
}
// index.js
const lib = require('./lib')
const {c} = require('./lib')
console.info(lib,c)
note:
在 rollup 配置文件中,配置成外部模块: external: ['./lib']
。因为 rollup 对于自定义的文件,rollup 会将模块里面内容打包到最终输出文件中。
Rollup 对 require 的转换比较简单,不管解构,只有默认引入。转换结果如下:
import require$$0 from './lib';
var js = {};
const lib = require$$0;
const {c} = require$$0;
console.info(lib,c);
export { js as default };
而 esbuild 现版本还不支持,直接给 warning 了
小结
问题:对于 CJS 转 ESM,各种工具转换的结果不同呢?
因为 CJS 转 ESM 是语义存在歧义的
module.export.a = 123
module.export.b = 345
// 等价于如下
module.export = {
a: 123,
b: 345,
}
对于上面第一个那到底是默认导出,还是命名导出呢?其实都可以,本质上,CJS 只有一个导出方式,不确定它对应的是 ESM 的命名导出还是默认导出。
由于这个歧义,加上没有一个标准规范这个行为,所以不同工具转换结果是不同的。目前仅有少量工具支持 CJS 到 ESM 的转换:esbuild
、babel-plugin-transform-commonjs
、@rollup/plugin-commonjs
。
参考文章