ESM 转 CJS 的这些坑你知道吗?

1,230 阅读10分钟

一、前言

自从 2009 年 Node.js 的诞生,前端先后出现了 CommonJS、AMD、CMD、UMD 和 ES Module 等模块规范,与此同时也催生出了一系列的工具链,比如 AMD 规范提出时社区诞生的模块加载工具 requestJS,基于 CommonJS 规范对的模块打包工具 browserify,能让开发者用上 ES Module 语法的 JS 编译器 Babel、能兼容各种模块规范的打包工具 Webpack 以及基于浏览器原生对 ES Module 支持的 构建工具 Vite 等。

对于前端开发者来说最受欢迎的当属于 ES Module,ES Module 作为 ECMAScript 官方提出的规范,经过几年的发展,不仅得到的众多浏览器的支持(如下图所示),同时也在 Node.js 中得到了原生支持,是一个支持跨平台的模块规范,现在社区的生态库也开始逐渐在使用,尤其是当前比较受欢迎的的构建工具 Vite,可以说 ES Module 前景一片光明。

image.png

但是我们也知道,ES Module 是 ES6 才引入的,在此之前大多数第三方库都是使用的 CommonJS 规范,所以我们也就避免不了出现 ES Module 调用 CommonJS 的情况,下面我们将带着这几个问题去探讨下 ES Modules。

  • 当使用 require 去引入一些库时还要加上 default(require('xx').default), 这是为什么?
  • 为什么 ES Module 和 CommonJS 之间能够相互调用?
  • ES Module 降级到 CommonJS 时他们的 API 对应关系是什么?
  • 听说 babel5 已经实现了一套 ES Module 到 CommonJS 的转换机制,为什么 babel6 废弃了自己重新实现了一套?
  • Node.js 环境现在也可以使用 ES Module 规范,但是它的转换机制(ESM到CJS)和 babel/Webpack 都不一样,那我们开发的 npm 包该怎么兼容呢?

二、export 和 export default

在 ES Module 中有两种导出命令 export 和 export default,export很容易理解,就是导出我们想暴露的变量、函数等,如下所示:

export var firstName = 'Michael';
export var lastName = 'Jackson';

既然有了 export 那为什么还要 export default 呢?其实就是为了简化用户的使用,因为使用 export 导出数据后如果用户想进行导入就必须要去了解你导出有哪些属性或方法。

export default 为模块指定默认导出,其他模块加载该模块时,import 命令可以为该模块导出的属性或方法指定任意名字。本质上 export default 就是输出一个叫做 default 的变量或方法,所以后面只能接具体的值,不能接变量申明语句,这也为后面转换到 CommonJS 规范埋下伏笔。

三、babel 是怎么将 ESM 转换成 CJS的

为了能够在开发过程中使用 ES Module 规范同时又需要兼容一些使用 CommonJS 规范开发的工具库,我们就不得不对我们的代码进行编译降级,即将 ES Module 转换成与其行为一致的 CommonJS,此时我们可以借助 Babel 帮我们去做这一层转换,但是这种转换有一个问题:如何准确的将 ESM 降级到 CJS 呢?

对于 import 语法来说比较简单,由于 CommonJS 模块的导出是动态的,而 ES 模块的导出是静态的,一般来说,在模块实例化的时候不可能确定一个 CommonJS 模块的导出名称,因为此时代码还没有被执行。所以 module.exports 的值只能作为默认的导出,即 import bar from 'bar' 等价于 const bar = require('bar')

但是对于导出来说可没这么容易,Babel 其实也是经历过几次迭代,不同版本之间的转换方式还是有所不同的,下面我们一一介绍。

1、babel@5

在 babel@5 时代,大部分人都用 require 去引用 ESM 导出的 default 值,只是把 default 看作是一个模块的默认导出,所以 babel@5 对这个逻辑做了 hack:如果一个 ESM 模块只有一个 default 导出,那么在转换 CJS 的时候会直接将 default 的值赋给 module.exports,这样我们在使用 require 导入的时候就不需要加 default 了,下面看个例子:

// 编译前
export default 1;
export const x = 2;

// 编译后
"use strict";
Object.defineProperty(exports, "__esModule", {
  value: true
});
exports["default"] = 1;
var x = 2;
exports.x = x;

我们看到,如果模块中存在多个导出,那么所有的属性包括 default 都会被赋值给 exports, 我们 require 的时候如果想拿到 default 的值就必须在后面加上 default 值这个没问题,下面我们将 export 这条导出去掉:

// 编译前 bar.js
export default 1;

// 编译后
"use strict";
Object.defineProperty(exports, "__esModule", {
  value: true
});
exports["default"] = 1;
module.exports = exports["default"];

我们发现 export default 在不同的场景下编译后的结果是不一样的 这样问题就来了,如果我的导入语句是经过 babel 编译的,那肯定没问题(因为有 _interopRequireDefault 这个工具函数),下面是他的实现:

// 编译前
import bar from './bar.js';
const a = bar;

// 编译后
function _interopRequireDefault(obj) {
    return obj && obj.__esModule 
        ? obj 
        : { 'default': obj }; 
} 

var _bar = require('./bar.js'); // 1 
var _bar2 = _interopRequireDefault(_bar); // {default: 1}
var a = _bar2["default"]; // 1

但是如果我们直接在 Node.js 环境直接使用 require 导入的时候(如实例一存在多个导出的情况下)就必须手动加上 default,也就是说,有时默认导出是由 require('module') 直接返回的,其他时候你需要访问 require('module').default 属性,这会对开发者产生很大的困惑,感兴趣的同学可以查看这个issue,如果想自己动手试试可以安装下面这两个 plugin。

image.png

2、babel@6

在 babel@6 中为了解决 babel@5 中存在的问题决定去除 module.export = export["default"]这一操作,下面是官方的解释:

image.png 也就是说所有属性和方法最终都会通过 exports 进行导出,升级 babel@6 产生的不兼容问题可以通过引入babel-plugin-add-module-exports 这个 plugin 解决,下面我们通过一个例子来理解:

编译前:

// bar.js
export default 1;

// index.js
import a from './bar.js'
const b = a;

编译后:

// bar.js
Object.defineProperty(exports, "__esModule", {
  value: true
});
exports["default"] = 1; // 主要变化点

// index.js
var _bar = require("./bar.js");
var _bar2 = _interopRequireDefault(_bar); // {default: 1}
var b = _bar2["default"]; // 1
function _interopRequireDefault(obj) { 
    return obj && obj.__esModule 
        ? obj 
        : { "default": obj }; 
}

正如我们讲的,babel@6 将所有的属性或方法都通过 exports 导出,我们在导入的时候会通过 _bar2["default"] 去拿默认到处的值,至此,前端社区的代码实际上可以认为跑在了一个虚拟的 Babel |Webpack 的 runtime上,这个 babel runtime 通过将 ESM 编译为 CJS 帮我们解决了 ESM 和 CJS 的交互性问题了。

四、噩梦的开始(Node 支持 ESM 了)

前面讲到在 babel@6 中已经很好的解决了 ESM 转 CJS 的问题,然而另一件不幸的事已经悄悄来临,Node@18 已经原生支持 ESM 规范,但是他们却采用的却是和 Babel 不兼容的实现,在 Node.js 中 export default 导出总是等于 module.exports,这打破了与现有的 ESM 模块生态系统的兼容性,现在你必须根据你的代码是需要在 Node 环境中还是在 Babel 环境中运行,来添加或删除一个额外的 default 属性,这就导致了更严重性的互操作性问题,比如下面这个例子,我开发了一个叫 bar 的库:

// bar 开发时的入口文件 index.js
export default 1;

// 经过babel@6编译后
Object.defineProperty(exports, "__esModule", {
  value: true
});
exports["default"] = 1;

// 在Node环境使用 import 导入
import bar from 'bar'
const a = bar; //{default: 1}

为了和原来的的语义保持一致,我希望在 Node 环境中通过 import bar from 'bar' 就能拿到他的默认导出 1,但是因为在 Node 环境 import bar from 'bar'const bar = require('bar') 等价所以最终拿到的是 {default: 1}

为了解决这个问题 babel 和 esbuild 都给出了自己的方案,在 babel@7 中它提供了一个新的 transform plugin(plugin-transform-modules-commonjs),在这个插件中我们将 importInterop 设置为 node,效果如下:

// 编译前
import a from 'a';
const b = a;

// 编译后
var _a = require("a");
var b = _a;

我们可以直观地看到,这个和之前 babel@6 的编译结果有着明显的差异,因为他模拟的就是 Node 环境,但是对于导出语法来说还是和 babel@6 是一样的(即所有属性/方法都是通过 exports 导出),现在我们的变量 a 就不再是 export default 出来的值了,当然这种解决方式也不能说是很完美的,官方文档也说了这种编译方式和 Node 并不完全相同。

而对于 esbuild,为了进行兼容性修复,在 0.14.4 版本进行了一个 break changing,原地址点击这里,完整的方案如下:

  • 如果使用 import 语句加载 CommonJS 文件并且 a) module.exports 是一个对象,并且 b) module.exports.__esModule 是 true,并且 c)文件名不以 .mjs 或者 .mts 并且 package.json 文件不包含 "type: module",那么 esbuild 会将 default 导出设置为 module.exports.default(如babel),否则 default 导出设置为 module.exports (如Node)。

请注意,这意味着默认出口在以前没有被定义的情况下现在可能是未定义的。这与 Webpack 的行为相匹配,所以希望它能更加兼容。

还要注意,这意味着导入行为现在取决于文件的扩展名和 package.json 的内容。这也符合 Webpack 的行为,希望能提高兼容性。

  • 如果一个 require 调用被用来加载一个 ES 模块文件,返回的模块命名空间对象的 __esModule属性被设置为true。这就像ES模块已经通过Babel兼容的转换被转换为CommonJS一样。
  • 如果导入语句或 import() 表达式被用来加载一个 ES 模块,esModule 标记现在不应该出现在模块命名空间对象上。这释放了 esModule 的导出名称,使其可以用于 ES 模块。
  • 现在允许在 ES 模块中使用 __esModule 作为一个正常的导出名。这个属性可以被其他 ES 模块访问,但不能被使用 require 加载 ES 模块的代码访问,他们将会始终看到这个属性被设置为 true。

对于 rollup 作者暂时还没看到这方面的更新,不过社区也有相应的讨论,有兴趣的可点击这里,同时 rollup 实现了一个 CommonJS 到 ES Module 转换的插件 @rollup/plugin-commonjs

五、总结

整篇文章我们介绍了 ESM 的发展到 babel 不同版本对 ESM 转 CJS 的实现,到最后 Node 支持 ESM 后各个工具为了兼容 Node 环境做了哪些调整,现在可以回答一下开头提出的几个问题:

  • 当使用 require 去引入一些库时还要加上 default(require('xx').default), 这是为什么?

    对于 export default 来说它本质上和 export 没啥区别,defaule 实际上就是一个变量名,所以 babel@6 在将 ESM 转成 CJS 的时候会将 export default 转换成 module.exports.default,所以也就导致了如果我们想通过 require 去获取 ESM 中的默认导出就必须要在后面加上 default。

  • 为什么 ES Module 和 CommonJS 之间能够相互调用?

    这个其实是 babel/webpack/node 帮我们做了转换,我们用的时候不需要去关心。

  • ES Module 降级到 CommonJS 时他们的 API 对应关系是什么?

    只需要分不同的环境,如果是 babel@5 那么export default 的值就会直接通过 module.exports 导出,但是在 babel@6 中都是通过 exports 导出的。

  • 听说 babel5 已经实现了一套 ES Module 到 CommonJS 的转换机制,为什么 babel6 废弃了自己重新实现了一套?

    因为在 babel@5 中 export default 的编译结果有两种情况,如果模块只有一个 default 导出,那最终会被编译成 module.exports 导出,如果有多个那就会将所有的属性/方法都通过 exports 导出。