MENU

了解 ESM 和 CJS

May 31, 2024 • Read: 1805 • 前端

推荐文章:
让我们看看 Obsidian CEO 是怎么使用 Obsidian 的 – 效率火箭,火箭君的新博客
  1. 分别介绍两种模块

    • ESM
    • CJS
  2. 两种模块的互相转换

    • 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 了

esbuild 结果

小结

问题:对于 CJS 转 ESM,各种工具转换的结果不同呢?

因为 CJS 转 ESM 是语义存在歧义的

module.export.a = 123
module.export.b = 345

// 等价于如下
module.export = {
    a: 123,
    b: 345,
}

对于上面第一个那到底是默认导出,还是命名导出呢?其实都可以,本质上,CJS 只有一个导出方式,不确定它对应的是 ESM 的命名导出还是默认导出。

由于这个歧义,加上没有一个标准规范这个行为,所以不同工具转换结果是不同的。目前仅有少量工具支持 CJS 到 ESM 的转换:esbuildbabel-plugin-transform-commonjs@rollup/plugin-commonjs


参考文章

  1. 终于搞懂了 ESM 和 CJS 互相转换
  2. CommonJS 和 ES Module 终于要互相兼容了???
Last Modified: August 15, 2024