阅读 1216

为什么能快乐的在 esm 中使用 cjs 模块

注: 本文中使用 cjs 表示 commonjs module。 使用 esm 表示 es module。

前言

对于我们现在的 web 开发项目中,我们快乐的使用着 export/import 来进行模块化开发。好像一切原本就这么简单~

但是我们也知道, esm 是es6 才在规范中引入。在此之前 js 语言规范中并没有模块化一说(当然,以前 web 也没有那么复杂)。 cjs 就是在没有 esm 之前的一个产物。正是 cjs 简单而又高效的模块化设计, 使得 cjs 受到了大量开发人员的青睐。虽然,后面 esm 在语言层面提出了模块化的规范,但是在 es6 出现之前,已经存在了大量的 cjs 的模块,总不能要求所有的模块都迁移使用 esm 吧。 cjs 和 esm 的模块化实现还上还是有很大不同的,但是在项目中,又不可避免的出现 esm 调用 cjs 的情形, 那么这是怎么实现的呢?

翻译翻译

esm 和 cjs 本质上是两个东西, 而且 esm 规范中,只是说明了es module 的行为, 如何兼容 cjs 并不在其中。之所以我们可以愉快的相互调用,全靠编译器在背后支持你(哪有什么岁月静好,只是有人替你负重前行[doge])。 大体思路上是把 esm 转换成cjs的形式然后统一处理。 首先,让我们抛开 babel, rollup 这些东西,把自己当成一个编译器开发者,你会怎么处理 esm调用cjs的问题呢。

假设有一个 esm 模块:

//lib.js

export const a = 1;
export const b = 2;

export default () => {
  return 3;
};
复制代码

现在交给你来处理,你需要把这段代码处理成cjs 的形式。首先我们看到 lib js 有两个 export,这个简单 我们把 commonjs 的 exports.xx 和 es module 的 export const xx 一一对应起来就好了啊。 然后还有个 export default, 查阅了下资料,我们知道在 es module 中我们可以认为 export default 是 export 了一个 default 的变量或方法。 这下就简单了,直接 exports.default = xx 不就好了,于是得到以下 cjs 的代码:

// lib.js [cjs]
module.exports {
  a: 1,
  b: 2,
  default: function(){return 3;}
}
复制代码

这个翻译过程看上去没什么问题,我们愉快的保存了 lib.js 的 cjs 版本。 这时候有人在项目中用到了 lib.js 他是使用的 es module 形式:

import fn, { a, b } from 'lib';
console.log(a);
console.log(b);
console.log(fn());
复制代码

恰好这个光荣的翻译工作还是你来做,一看题目,这还不简单,一个小小的 import, 办它。于是进行了转换:

const { a, b } = require('lib');
const fn = require('lib').default;
console.log(a);
console.log(b);
console.log(fn());
复制代码

好像看上去没什么问题。因为代码逻辑上,完全正确啊。 但是注意这里的 default, 我们把 default 当成 exports 的一个属性导出。如果使用这种转换规则的话, 那么在 react 项目中使用这个编译器将会收到一堆报错。因为 import react from 'react' 会被翻译成 const react = require('react').default; , 但是 react 模块导出的对象上,并没有 default 属性。而且, 不仅仅是 react , 很多 cjs 的 lib 只会导出一个方法或者 class 类似这种:module.exports = function() {}。那么为了直接使用这些 cjs 的库,你只能这样写:import * as React from 'react', 来整体导出使用。 这也是 之前 typescript 的处理方式。

但是,在typescript 项目中开启 esModuleInterop或者 babel 项目中, 你是这样导入一个 cjs 的模块了: import react from 'react'. 这些,就多亏了这些神奇的编译器。

babel 处理 esm 使用 cjs

我们直接使用 babel online 看看他是怎么处理我们上面 lib.js 的:

'use strict';

Object.defineProperty(exports, '__esModule', {
  value: true
});
exports.default = exports.b = exports.a = void 0;
var a = 1;
exports.a = a;
var b = 2;
exports.b = b;
var _default = 3;
exports.default = _default;
复制代码

这里看上去,和我们的处理方式没什么区别。 只是在 exports 挂了一个 __esModule 属性。 然后我们看 import, 先看最简单的:

import { a, b } from 'lib';

console.log(a);
console.log(b);
复制代码

经过 babel 处理后,变成了这样:

'use strict';

var _lib = require('lib');

console.log(_lib.a);
console.log(_lib.b);
复制代码

babel 在处理这一块的时候,使用整体导入的。那 default import 呢?

'use strict';

var _lib = _interopRequireDefault(require('lib'));

function _interopRequireDefault(obj) {
  return obj && obj.__esModule ? obj : { default: obj };
}

console.log(_lib.default);
复制代码

这里注意到,虽然 babel 还是使用了整体导入的形式,但是包了一层 _interopRequireDefault, 单独处理 default import 的形式。前面我们也看到了, es module 在转换时会在 export 上挂载__esModule 属性,所有在导入时,如果是 es module 直接返回,如果不是则当 cjs 处理, 把整体模块挂在一个对象的 default 属性上,这样后续的操作就统一了。这也是为什么 你可以在使用了 babel 的项目中可以以import react from 'react' 的形式使用。

rollup 处理 esm 使用 cjs

在 rollup 把 esm 转换成 cjs 上, 大部分的操作是一致的,lib.js rollup 会这样处理:

'use strict';

Object.defineProperty(exports, '__esModule', { value: true });

const a = 1;
const b = 2;
var module_2 = 3;

exports.a = a;
exports.b = b;
exports.default = module_2;
复制代码

import 是这样处理的:

function _interopDefault(ex) {
  return ex && typeof ex === 'object' && 'default' in ex ? ex['default'] : ex;
}

var c = require('lib');
var c__default = _interopDefault(c);

console.log(c.a);
console.log(c.b);
console.log(c__default);
复制代码

rollup 在处理 default import 的时候,使用了 _interopDefault 方法, 就是去获取模块上有没有 default 属性。这里注意一点, rollup 本身并没有使用 __esModule 的标识来处理 default, 但是在 编译 esm 时,加上了这个标识,这样即使你是 rollup 打的包,在babel 项目中也可以愉快的使用。

使用 rollup 一点需要注意,如果只有一个单独的 default export 的时候, rollup 的处理方式会不同:

// esm
export default function add(a, b) {
  console.log(a + b);
}

// rollup 处理成 cjs
function add(a, b) {
  console.log(a + b);
}

module.exports = add;
复制代码

我们看到,rollup 直接把 default export 挂到了 module.exports 上。而 babel 还是通过 __esModule 的标识,挂载在 exports.default 上。这个地方需要特别关注, 假如有一个库之前时 使用 babel 处理的, 那 cjs 用户只能以 require('lib').default 的形式来使用。 有一天这个库的作者决定使用 rollup, 那么 cjs 的用户想要使用新的库,只能去更改原先的代码。

最后

为了快乐的在 esm 中使用 cjs 的模块, 比较通用的形式是转换为 cjs 统一处理,在 esm 转换为 cjs 的过程上, 最主要的问题体现在 export default 上, 它让事情变得复杂。在我们平常的开发中,可能我们已经习惯了 default export, 特别是在 react 项目中,我们自然的写下 export default myComponent。如果本身项目都建立在 es module的体系下, default export 绝对是一个 很便利的方式。但是,如果你同时需要支持 cjs 和 esm,涉及到相互调用的问题, 那就要慎重考虑 default export。 因为 esm 与 cjs 如何成功相互使用,并不由你决定,而是由帮你打包处理的工具决定。

参考文章