前言
在 ECMAScript 2015 也就是 ES6 出来之后,js 模块算是有了标准的规范。在如今的前端开发中几乎都是在使用 esm 的模块方式开发。但是在 nodejs 环境中,依旧是以 commonjs 模块为主。因此,在开发一个 node 包的时候,经常会用 es6 的语法,通过编译或者打包工具编译成 commonjs 模块规范。
但是 esm 和 commonjs 并不能完全转换。因为 esm 有 default 默认导出这个概念。例如:
// a.js
function test(){}
export default test;
export const a = 123;
import A from "./a.js"; // A 是默认导出,也就是 function test(){}
import { a } from "./a.js"; // a 是命名导出
import { default as test } from "./a.js"; // 给 default 重命名拿到默认导出
import * as all from "./a.js"; // 拿到全部导出,all: { test(){}, a: 123 }
因此在 esm 中,加花括号{ }
的方式拿到的只能是命名导出,不加花括号的方式拿到的只能是默认导出。想要拿到全部导出需要使用 * as xx
的方式。
但是在 commanjs 的语法中,语法如下:
// a.js
module.exports = {
test: () => {}
}
exports.a = 123;
const all = require("./a.js"); // all: { test: ()=>{}, a: 123 }
const { test, a } = require("./a.js"); // 这种方式是解构语法拿到 all 对象对应的属性
因此可以看到,在 esm 语法中拿到 default 导出的语法并不能在 commonjs 中使用。在 commonjs 中不加花括号{ }
的方式只能拿到 module.exports 导出的全部值,而不是 module.exports.default 的值。
构建工具的转换方式
在不同的构建工具下,将 esm 的模块语法转换成 cjs 的模块语法的表现不太一样。
babel
babel5 时代
在 babel 5 的时候,如果本来的 esm 语法只有一个默认导出的时候,会转换如下:
// 原 esm 语法
function test(){}
export default test;
// 经过 babel 5 转换后的 cjs 语法
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
function test() {}
var _default = exports.default = test;
module.exports = exports.default;
此时引用转换后的 cjs 语法的时候,写法如下:
const test = require("./a.js"); // 直接拿到 default 导出。
但是如果有不止一个导出的时候,如下:
// 原 esm 语法
function test() {}
export default test;
export const a = 123;
// 经过 babel 5 转换之后的 cjs 语法
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = exports.a = void 0;
function test() {}
var _default = exports.default = test;
const a = exports.a = 123;
此时引用转换后的 cjs 语法的时候,写法如下:
const test = require("./a.js").default;
// or
const { default: test, a } = require("./a.js");
此时就会造成一个误解。比如:
// 原 esm 语法
const obj = {
a: 123,
b: 456
}
export default obj;
// 经过 babel5 转换后的 cjs 代码
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
const obj = {
a: 123,
b: 456
};
var _default = exports.default = obj;
module.exports = exports.default;
此时使用转换后的语法如下:
const { a, b } = require("./a.js");
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
命名导出。
// 原 esm 语法
function test(){}
export default test;
// 经过 babel6 转换后的 cjs 代码
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
function test() {}
var _default = exports.default = test;
在使用转换后的 cjs 语法的时候如下:
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 使用,应该避免使用默认导出,全部使用命名导出。
但是,如果还是想要如下的引用方式:
// 原 esm 代码
function test(){}
export default test;
export const a = 123;
// 转换成 cjs 的使用方式
const test = require("./a.js"); // 拿到默认导出 test
const { a } = require("./a.js"); // 拿到具名导出 a
解决方式
参考了现有的 node 包的源代码及其编译方式,比如 babel-plugin 、 rollup-plugin 、vite plugin 等等。
rollup 插件的处理方式:使用 rollup 工具的 footer 功能统一添加
极个别插件的处理方式:单独添加
vite 插件的处理方式:写脚本统一转换。
总结下来,目前大部分的解决方式如下:
// 原 esm 语法
function test() {}
export default test;
export const a = 123;
// 工具转换后的 cjs 代码
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = exports.a = void 0;
function test() {}
var _default = exports.default = test;
const a = exports.a = 123;
// 额外添加的代码
module.exports = Object.assign(exports.default, exports);
通过以上方式形成的导出结构如下:
function test(){}
test.default = {
test,
a: 123;
};
test.a = 123;
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