前言

在 ECMAScript 2015 也就是 ES6 出来之后,js 模块算是有了标准的规范。在如今的前端开发中几乎都是在使用 esm 的模块方式开发。但是在 nodejs 环境中,依旧是以 commonjs 模块为主。因此,在开发一个 node 包的时候,经常会用 es6 的语法,通过编译或者打包工具编译成 commonjs 模块规范。

但是 esm 和 commonjs 并不能完全转换。因为 esm 有 default 默认导出这个概念。例如:

  1. // a.js
  2. function test(){}
  3. export default test;
  4. export const a = 123;
  1. import A from "./a.js"; // A 是默认导出,也就是 function test(){}
  2. import { a } from "./a.js"; // a 是命名导出
  3. import { default as test } from "./a.js"; // 给 default 重命名拿到默认导出
  4. import * as all from "./a.js"; // 拿到全部导出,all: { test(){}, a: 123 }

因此在 esm 中,加花括号{ }的方式拿到的只能是命名导出,不加花括号的方式拿到的只能是默认导出。想要拿到全部导出需要使用 * as xx的方式。

但是在 commanjs 的语法中,语法如下:

  1. // a.js
  2. module.exports = {
  3. test: () => {}
  4. }
  5. exports.a = 123;
  1. const all = require("./a.js"); // all: { test: ()=>{}, a: 123 }
  2. const { test, a } = require("./a.js"); // 这种方式是解构语法拿到 all 对象对应的属性

因此可以看到,在 esm 语法中拿到 default 导出的语法并不能在 commonjs 中使用。在 commonjs 中不加花括号{ }的方式只能拿到 module.exports 导出的全部值,而不是 module.exports.default 的值。

构建工具的转换方式

在不同的构建工具下,将 esm 的模块语法转换成 cjs 的模块语法的表现不太一样。

babel

babel5 时代
在 babel 5 的时候,如果本来的 esm 语法只有一个默认导出的时候,会转换如下:

  1. // 原 esm 语法
  2. function test(){}
  3. export default test;
  4. // 经过 babel 5 转换后的 cjs 语法
  5. "use strict";
  6. Object.defineProperty(exports, "__esModule", {
  7. value: true
  8. });
  9. exports.default = void 0;
  10. function test() {}
  11. var _default = exports.default = test;
  12. module.exports = exports.default;

此时引用转换后的 cjs 语法的时候,写法如下:

  1. const test = require("./a.js"); // 直接拿到 default 导出。

但是如果有不止一个导出的时候,如下:

  1. // 原 esm 语法
  2. function test() {}
  3. export default test;
  4. export const a = 123;
  5. // 经过 babel 5 转换之后的 cjs 语法
  6. "use strict";
  7. Object.defineProperty(exports, "__esModule", {
  8. value: true
  9. });
  10. exports.default = exports.a = void 0;
  11. function test() {}
  12. var _default = exports.default = test;
  13. const a = exports.a = 123;

此时引用转换后的 cjs 语法的时候,写法如下:

  1. const test = require("./a.js").default;
  2. // or
  3. const { default: test, a } = require("./a.js");

此时就会造成一个误解。比如:

  1. // 原 esm 语法
  2. const obj = {
  3. a: 123,
  4. b: 456
  5. }
  6. export default obj;
  7. // 经过 babel5 转换后的 cjs 代码
  8. "use strict";
  9. Object.defineProperty(exports, "__esModule", {
  10. value: true
  11. });
  12. exports.default = void 0;
  13. const obj = {
  14. a: 123,
  15. b: 456
  16. };
  17. var _default = exports.default = obj;
  18. module.exports = exports.default;

此时使用转换后的语法如下:

  1. const { a, b } = require("./a.js");
  2. console.log(a, b); // 123, 456

此时在使用者角度来看,这个 a,b 到底是 export a 还是 export default obj.a。就会存在疑惑。commonjs 的花括号{ }是 es6 的解构语法,而 esm 的 import 语法中的花括号 { }是 esm 的模块解析语法,虽然二者看起来一样,但是意义却完全不一样。花括号在 esm 的模块解析需要中,只能是命名导出。但在转换后的 cjs 语法中成了既可能是命名导出,又可能是默认导出的一个 key。

babel6 及其之后
因此在 babel6 及其之后,babel 更正了这个错误。在 esm 编译成 cjs 的时候,始终加 default命名导出。

  1. // 原 esm 语法
  2. function test(){}
  3. export default test;
  4. // 经过 babel6 转换后的 cjs 代码
  5. "use strict";
  6. Object.defineProperty(exports, "__esModule", {
  7. value: true
  8. });
  9. exports.default = void 0;
  10. function test() {}
  11. var _default = exports.default = test;

在使用转换后的 cjs 语法的时候如下:

  1. const test = require("./a.js").default;

babel 6 变动之后,有的人想再用回 babel5 的那种错误语法,有一个 babel 插件可以做到回到 babel5 模块转换结果的样子。即 babel-plugin-add-module-exports

typescript

typescript 工具在将 esm 的 ts 语法转换成 cjs 的 js 语法的时候,采取的策略和 babel6 及其之后的策略是一样的。

rollup

rollup 的默认方式是和 babel 5 一样的方式,但是提供了选项供用户配置。这个选项是 output.exports 选项。该选项默认值是 auto。会和 babel5 一样,在只有默认导出时转换成 module.exports的方式,否则就是 exports.default的方式。

webpack

webpack 在将 esm 的 ts 语法转换成 cjs 的 js 语法的时候,采取的策略和 babel6 及其之后的策略是一样的。

结论

那看到这里,我们在使用由 esm 编译而来的 cjs 的 node 包的时候,只能加 default 的方式拿到默认导出。实际上,这样才是对的使用方式。如果不想加 default 使用,应该避免使用默认导出,全部使用命名导出。

但是,如果还是想要如下的引用方式:

  1. // 原 esm 代码
  2. function test(){}
  3. export default test;
  4. export const a = 123;
  5. // 转换成 cjs 的使用方式
  6. const test = require("./a.js"); // 拿到默认导出 test
  7. const { a } = require("./a.js"); // 拿到具名导出 a

解决方式

参考了现有的 node 包的源代码及其编译方式,比如 babel-plugin 、 rollup-plugin 、vite plugin 等等。

rollup 插件的处理方式:使用 rollup 工具的 footer 功能统一添加
截屏2024-03-17 10.39.58.png

极个别插件的处理方式:单独添加
截屏2024-03-17 10.40.59.png

vite 插件的处理方式:写脚本统一转换。
截屏2024-03-17 10.43.21.png

总结下来,目前大部分的解决方式如下:

  1. // 原 esm 语法
  2. function test() {}
  3. export default test;
  4. export const a = 123;
  5. // 工具转换后的 cjs 代码
  6. "use strict";
  7. Object.defineProperty(exports, "__esModule", {
  8. value: true
  9. });
  10. exports.default = exports.a = void 0;
  11. function test() {}
  12. var _default = exports.default = test;
  13. const a = exports.a = 123;
  14. // 额外添加的代码
  15. module.exports = Object.assign(exports.default, exports);


通过以上方式形成的导出结构如下:

  1. function test(){}
  2. test.default = {
  3. test,
  4. a: 123;
  5. };
  6. test.a = 123;
  7. module.exports = test;

其实就是将默认导出作为了 module.exports 导出,并将其他的命名导出挂到了默认导出上,并且还保留了 default,default 包含了默认导出和全部的命名导出。因此会存在循环依赖,而且当默认导出不是对象时,挂载其他命名导出就会有问题。

这种方式不符合规范,但也是目前常用的一种解决方式。

参考

TypeScript 中文网: 文档 - 模块 - ESM/CJS 互操作性
https://github.com/nodejs/node/issues/50981
https://github.com/vuejs/rollup-plugin-vue/blob/next/src/index.ts
https://github.com/59naga/babel-plugin-add-module-exports