likes
comments
collection
share

为什么能将ESM和CJS规范的代码混合使用?

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

背景

ES Module(ESM)和CommonJS(CJS)是Javascript生态中模块化规范的两种具体实现,两者的具体异同点已经有很多优秀的介绍的文章了,本文中就不再赘述这些细节了。

本文主要介绍ESM和CJS在混合使用时,一般打包工具可能会进行的一些兼容性处理,了解这些细节之后,在遇到包格式问题时,可能会更快的定位到问题。

ESM与CJS的相互转换规则

由于ESM和CJS是两种不同的规范,在相互使用中就需要有一定的转换方法来在一定程度上消除掉两种规范中的差异。比如:API上的差异、运行逻辑的差异等。但是由于两种规范从底层设计上就有着很大的差异,通过转换方法也很难完全消除掉两者的差异,这也是在两种规范混合使用中最受大家吐槽的地方。

不同打包工具转换规则有所差别,但是总体上来说还是大同小异,大家都遵守相同的规范。

ESM转换CJS

大部分的打包工具可以直接将使用ESM规范编写的代码转移成CJS规范,我们以一段简单的源码看一下ESM转换成CJS,就基本能明白这种转换规则了。

const foo = 'foo';
export const foo2 = 'foo2';
export default foo;

webpack转换结果

webpack打包后辅助类函数比较多,我们可以挑出来重点部分:

__webpack_require__.r = (exports) => {
  if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
    Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
  }
  Object.defineProperty(exports, '__esModule', { value: true });
};
//...
var __webpack_exports__ = {};
__webpack_require__.r(__webpack_exports__);
__webpack_require__.d(__webpack_exports__, {
  "default": () => (__WEBPACK_DEFAULT_EXPORT__),
  "foo2": () => (/* binding */ foo2)
});
const foo = 'foo';
const foo2 = 'foo2';
const __WEBPACK_DEFAULT_EXPORT__ = (foo);

typescript转换结果

typescript可以通过配置编译选项输出格式为CommonJS来实现将ESM转换为CJS的目的,具体配置:

{
    "compilerOptions": {
        "module": "CommonJS",
     }
}

转换结果的逻辑相对于webpack显得就比较清晰明了:

Object.defineProperty(exports, "__esModule", { value: true });
exports.foo2 = void 0;
const foo = 'foo';
exports.foo2 = 'foo2';
exports.default = foo;

小结

从webpack和typescript两种典型工具的转换结果可以看出,将ESM规范的Javascript代码转换成CJS规范的代码一般的转换方法:

  1. 在exports对象上定义__esModule属性,并设置属性值为true
  2. 将ESM规范中定义的导出对象设置到exports上对应的属性上,具体对应规则为:
  • default导出设置到exports的default属性上
  • 其他具名导出设置到exports上对应的属性值上

通过以上方式实现将ESM转换成符合CJS规范的代码。

CJS转换ESM

将CJS转换成ESM在之前也不算是一个比较常见的选项,因为由于诸多的历史原因,CJS的使用广泛度和兼容性都远大于ESM,因此在很多的场景中都是使用CJS规范的代码。但是,由于CJS本身的一些限制,同时ESM由于其在设计之初就是针对Web场景,因而能够在一定程度上解决这些问题,比如在Web场景中,大家普遍比较关心的包体积大小问题,在ESM规范下就有着比较好的解决方案(当然并不是说CJS下就不能解决这些问题,通过code split的方式也能一定程度上实现比较类似的结果)。

将CJS转换成ESM的场景,我们就以Rollup下的使用场景来看。我们知道Rollup是一款比较流行的支持ESM Bundle的打包工具。其本身是不能处理CJS规范的代码的,如果我们在代码中引用了CJS规范的代码时,需要配置@rollup/plugin-commonjs插件,该插件的作用就是将CJS规范的代码转换成Rollup能够处理的符合ESM规范的代码。

源码

esm-lib-ts为typescript编译后的结果

import esmLibTs from 'esm-lib-ts';
console.log(esmLibTs);
export const result = esmLibTs;

编译后

var o = {};
Object.defineProperty(o, "__esModule", { value: !0 });
o.foo2 = void 0;
const t = "foo";
o.foo2 = "foo2";
var e = o.default = t;
console.log(e);
const f = e;
export {
  f as result
};

可以看到在ESM规范中引用CJS规范的代码时Rollup中处理的方式:

  1. 将CJS包的内容拷贝过来
  2. 定义一个对象,用来替换CJS中的exports对象,将exports上的相关属性转移到新对象上,实现获取CJS规范下导出的结果

小结

在一般情况下,CJS转换成ESM的过程时比较好理解的,但是由于CJS是动态的,其功能的丰富性要高于ESM,在某些情况下很难用ESM完整转换出CJS,比如说存在循环引用的情况。

循环引用

由于CJS的动态导入的特性,在CJS中很容易出现循环引用的情况,我们可从一个简单的例子进行分析:

index.js文件

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.main = exports.foo2 = void 0;
const circular_1 = require("./circular");
const foo = 'foo';
exports.foo2 = 'foo2';
exports.default = foo;
exports.main = `${(0, circular_1.getMain)()}-circular`;

circular.js文件

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getMain = void 0;
const _1 = require(".");
const name = 'circular';
function getMain() {
    return `${_1.default}-circular`;
}
exports.getMain = getMain;
exports.default = name;

可以看到上面两个文件存在循环引用的关系,这种包在Rollup中是如何正确转换成ESM规范从而被正确解析呢?还是以上述的引用源码进行分析,我们可以获得打包后的代码:

var r = {}, e = {}, o;
function l() {
  if (o)
    return e;
  o = 1, Object.defineProperty(e, "__esModule", { value: !0 }), e.getMain = void 0;
  const t = a(), i = "circular";
  function c() {
    return `${t.default}-circular`;
  }
  return e.getMain = c, e.default = i, e;
}
var u;
function a() {
  if (u)
    return r;
  u = 1, Object.defineProperty(r, "__esModule", { value: !0 }), r.main = r.foo2 = void 0;
  const t = l(), i = "foo";
  return r.foo2 = "foo2", r.default = i, r.main = `${(0, t.getMain)()}-circular`, r;
}
var n = a();
console.log(n);
const s = n;
export {
  s as result
};

从打包后的代码可以看出来,将依赖包中的两个文件index.js和circular.js分别对应了两个函数a和l, 通过执行这两个函数来获取依赖包的导出结果,这与上述不存在循环依赖时处理CJS规范包的方法是不同的。值得注意的是,虽然通过这种方式有效解决了CJS包中存在循环引用的问题,但是console.log中输出的内容并不是我们想要的。

import esmLibTs from 'esm-lib-ts';
console.log(esmLibTs);

这两行代码其实是想要获取到esm-lib-ts包中的default导出的结果,而打包后给到我们的是整个包输出的结果也就是类似于:

{
    default: ...
}

与我们的期望值其实是不一致的。其实这也就引出了另外一个问题,何时是获取CJS包的全部导出也就是exports对象本身,何时是获取exports上的default属性对应的导出结果?这个问题在typescript中反映的是编译配置esModuleInterop,关于这个配置的作用有很多文章讲述的都比较清楚了,在此就不再赘述了,简单来说就是根据exports上的__esModule属性值的结果来决定是引用exports本身还是exports上的default属性,这也是esm与cjs差异比较大的地方。

这也是我在使用Rollup打包时遇到的问题,可以通过一些方式来解决这个问题。

default导出

当我们开启typescript配置的中的esModuleInterop选项时,可以看出typescript的处理方式:

var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getMain = void 0;
const _1 = __importDefault(require("."));
const name = 'circular';
function getMain() {
    return `${_1.default}-circular`;
}

可以看出来,主要是根据exports对象上的__esModule属性值来选择主动在原有的导出中封装一层带有default属性的对象,来隐藏这一层的差异,大家可以在typescript配置中开启或者关闭esModuleInterop选项的差别。

总结

目前各类打包工具将符合ESM规范编写的代码打包成CJS规范的代码时都会遵循添加__esModule标记的规范,用来表示这是一个从ESM包转移而来的。打包工具根据这一标记来处理ESM和CJS包在相互引用时会存在的各种问题。当遇到包格式问题时,首先从这个方向下手排查应该能节省一些时间,快速定位到问题所在。

转载自:https://juejin.cn/post/7183184156539977765
评论
请登录