[译] Node 模块化之争:为什么 CommonJS 和 ES Modules 无法好好相处

原文标题:Node Modules at War: Why CommonJS and ES Modules Can't Get Along

两者可以进行适配,但是会徒增负担。

原文链接:https://redfin.engineering/node-modules-at-war-why-commonjs-and-es-modules-cant-get-along-9617135eeca1

在 Node 14 版本下,现存两类语法:老式的 CommonJS (CJS) 的语法和新式的 ESM 语法(aka MJS)。CJS 使用 require()module.exports;ESM 使用 importexport

ESM 和 CJS 可以看作是完全不同的动物。表面上看,ESM 和 CJS 很像,但是他们的实现却是大相径庭。如果说一个是蜜蜂,那么另一个就是杀人蜂。

图中是一只黄蜂和一只蜜蜂。其中一个好比 ESM,另一个好比 CJS,但是我永远记不住哪个是哪个。

图片来源:wikimedia,wikimedia

无论是在 ESM 中使用 CJS 还是反过来,都是有可能的,但这是徒增负担。

以下是一些规则,我会在后文中详细解释。

  1. 在 ESM 代码中无法使用 require();你只能 import ESM 代码,比如:import {foo} from 'foo'

  2. CJS 代码无法使用如上所示的静态 import 语句;

  3. ESM 代码可以 import CJS 代码,但是只能使用“默认导入(default import)”语法,如 import _ from 'lodash',而不是“命名导入(named import)”语法,如 import {shuffle} from 'lodash',因此如果 CJS 代码使用了命名导出,就会很麻烦;

  4. ESM 代码可以 require() CJS 代码,即便是命名导出也可以,但是明显不值得大费周章,因为这样需要更多的框架平台,而且最不好的一点就是诸如 Webpack 和 Rollup 这样的包,不知道,也不会知道怎么处理含有 require() 的 ESM 代码;

  5. CJS 是默认允许使用的,而 ESM 模式则需要你选择性加入。通过把代码文件从 .js 重命名为 .mjs 就可以启用 ESM 模式。除此之外,在 package.json 中设置 "type": "module",然后就可以通过把 .js 重命名为 .cjs 选择退出 ESM 模式。(你甚至可以在某一个子目录下添加一个只有一行 {"type": "module"}package.json 文件来调整。)

这些条条框框太痛苦了。对于很多使用者,尤其是 Node 入门者来说是更为痛苦的,这些规则压根不可理喻。(不慌,这篇文章里我都将解释清楚。)

很多 Node 生态的关注者已经发现这些规则是由于先前领导的失败,甚至是对 ESM 的敌意导致的。不过正如下文所说,所有的规则都有其存在的意义,这使得未来想要打破这些规则也很难。

我为开源库的开发者整理了三条指南用于借鉴:

  • 为你的开源库提供一个 CJS 的版本;

  • 为你的 CJS 版本提供一个较浅的 ESM 封装;

  • 在你的 package.json 文件中添加一个 exports 的映射。

一切就会好起来了。

Reddit 上的讨论

Hacker News 上的讨论

1. 背景介绍: CJS 是什么? ESM 又是什么?

从 Node 初见以来,Node 中的模块就是以 CommonJS 模块来写的。我们使用 require() 来引入它们。当实现了一个模块并且想让他人使用时,我们就会定义 exports 内容,要么通过设置 module.exports.foo = 'bar' 进行“命名导出(named export)”,要么通过设置 module.exports = 'baz' 进行“默认导出(default export)”。

这是一个 CJS 使用命名导出的例子,util.cjs 中有一个命名为 sum 的导出函数。


// 文件名: util.cjs

module.exports.sum = (x, y) => x + y;

// 文件名: main.cjs

const {sum} = require('./util.cjs');

console.log(sum(2, 4));

这是一个 CJS 在 util.cjs 中使用默认导出的例子。默认导出是不指定名字的,而是由使用 require() 的模块自行定义名称。


// 文件名: util.cjs

module.exports = (x, y) => x + y;

// 文件名: main.cjs

const whateverWeWant = require('./util.cjs');

console.log(whateverWeWant(2, 4));

在 ESM 代码中,importexport 是这类语言的一部分。和 CJS 类似,它也有两套不同的语法进行命名导出和默认导出。

这是一个 ESM 使用了命名导出的例子,util.mjs 有一个命名为 sum 的导出函数。


// 文件名: util.mjs

export const sum = (x, y) => x + y;

// 文件名: main.mjs

import {sum} from './util.mjs'

console.log(sum(2, 4));

这是一个 ESM 在 util.mjs 中设置了默认导出的例子。和 CJS 中一样,默认导出是没有名字的,但是使用了 import 的模块会自行定义名称。


// 文件名: util.mjs

export default (x, y) => x + y;

// 文件名: main.mjs

import whateverWeWant from './util.mjs'

console.log(whateverWeWant(2, 4));

2. ESM 和 CJS 是截然不同的动物

在 CommonJS 中,require() 是同步的。它不会返回一个 promise 或者调用回调函数。require() 从硬盘(或者甚至从网络)中进行读操作,然后立刻执行代码。这样就会使得它自行进行 I/O 或产生其它副作用,然后返回任何设置在 module.exports 上的值。

在 ESM 中,模块加载器是在异步阶段执行的。在第一个阶段,它会做词法分析,在不执行导入代码的情况下检测是否存在 importexport 的调用。在词法转换阶段,ESM 加载器能够立刻检测到命名导入中的拼写错误,并且在不执行依赖代码的情况下抛出异常。

ESM 加载器接下来异步地下载并转译任何引入的代码,然后对引入的代码进行编码,根据依赖建立出一个“模块图(module graph)”,直到最后它发现某块代码没有引入任何东西。最后,这一块代码被允许执行,然后所有这一块代码所依赖的代码被允许执行,依次类推。

ES 模块图中所有具有“兄弟”关系的代码都是并行下载的,但是是按照次序执行的。这一次序由加载器指定并确保执行。

3. CJS 是默认模式,因为 ESM 改变了很多东西

ESM 改变了 JavaScript 中的很多东西。 ESM 语法默认使用严格模式(use strict),它们的 this 不指向全局对象,作用域也有差异,等等。

这就是为什么甚至在浏览器中

你可能感兴趣的:([译] Node 模块化之争:为什么 CommonJS 和 ES Modules 无法好好相处)