查看原文
其他

Node 模块化之争:为什么 CommonJS 和 ES Modules 无法相互协调

印记中文团队 印记中文 2021-01-12

作者:Shopee 周雨楠


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

原文链接: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 使用 import 和 export

**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 的映射。

一切就会好起来了。


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

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

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

// 文件名: util.cjs
module.exports.sum = (x, y) => x + y;

// 文件名: main.cjs
const {sum} = require('./util.cjs');
console.log(sum(24));

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

// 文件名: util.cjs
module.exports = (x, y) => x + y;

// 文件名: main.cjs
const whateverWeWant = require('./util.cjs');
console.log(whateverWeWant(24));

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

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

// 文件名: util.mjs
export const sum = (x, y) => x + y;

// 文件名: main.mjs
import {sum} from './util.mjs'
console.log(sum(24));

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

// 文件名: util.mjs
export default (x, y) => x + y;

// 文件名: main.mjs
import whateverWeWant from './util.mjs'
console.log(whateverWeWant(24));

ESM 和 CJS 是截然不同的动物

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

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

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

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

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

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

这就是为什么甚至在浏览器中 <script> 标签默认也不是 ESM 模式的。要添加一个 type="module" 属性来选择进入 ESM 模式。

从默认的 CJS 切换到 ESM 在向前兼容性方面存在很大断层。(最近炙手可热的 Node 替代品 Deno 将 ESM 作为默认,但是其结果就是 Deno 的生态环境要从零开始搭建。)

CJS 无法 require() ESM,因为有顶层的 await 限制

CJS 无法 require() ESM 的最简单原因就是 ESM 可以进行最外层的 await ,但是 CJS 代码不行。

顶层 await 能够让我们在 async 函数的外层使用 await 关键字,也就是处于“顶层”。

ESM 的多阶段加载器使得 ESM 实现顶层 await 时不会搬起石头砸自己的脚。从V8团队的博客文章中引用一些话:

也许你读过 Rich Harris 写的臭名昭著的 gist,一开始就罗列了一些所担心的关于顶层 await 的问题,并且迫切希望 JavaScript 语言不要实现出来。其中的一些问题是:

  • 顶层 await 会阻塞执行;
  • 顶层 await 会阻塞资源获取;
  • CommonJS 模块没法再做清晰的内嵌了。

第三阶段版本的提议也强调了这些问题:

  • 因为兄弟关系的代码是可以执行的,因此最终没有造成阻塞;
  • 顶层 await 出现在模块图的执行阶段。在此阶段,所有的资源都已经获得并且建立了链接,因此是不存在阻塞资源获取风险的;
  • 顶层 await 仅仅限制在 ESM 模块中使用,CommonJS 的模块或者代码中明确没有对此的支持。

(Rich 现在已经同意了当前顶层 await 的实现。)

因为 CJS 不支持顶层 await,那么从 ESM 的顶层 await 转译为 CJS 就是不可能的。在 CJS 中怎么重写这段代码呢?

export const foo = await fetch('./data.json');

有点打击人,因为绝大多数 ESM 代码不会去使用顶层 await,但是正如这一条 thread 中的一个评论者所说,“我并不认为设计系统的时候,单单假定一些功能不会被使用,是一条可行的路。”

如何在 ESM 中进行 require() 的问题,在这条 thread 上依旧激烈争论着。 (请看完整条 thread 和其中关联的讨论后再进行评论。如果你深入研究,你就会发现顶层 await 并不是唯一一个有着问题的情形。你觉得如果你同步 require 一个能够异步 import 一些能够同步 require ESM 的 CJS 的 ESM 会发生什么呢?你就会得到像斑马条纹那样一会同步一会异步的能整死人的东西。顶层 await 就是棺材板上的最后一根钉子,也是最容易解释的一个。)

通过对那些讨论进行评审,似乎我们不再会在 ESM 里做 require() 了。

CJS 能够 import() ESM,但是这并不好

目前为止,如果你在写 CJS,你想 import 一段 ESM 代码,你得使用异步动态的 import()

(async () => {
    const {foo} = await import('./foo.mjs');
})();

看上去……还行,只要别有 exports 就行。如果你需要做 exports,你就得导出一个 Promise,这对于你的用户来说会是一个大大的不便。

ESM 无法引入命名引出的 CJS,除非 CJS 代码脱离执行顺序

你可以这样写:

import _ from './lodash.cjs'

但是你没法这样写:

import {shuffle} from './lodash.cjs'

这是因为 CJS 代码会在执行的时候计算它们的命名导出,而 ESM 的命名导出必须在转译阶段才会被计算。

对我们而言,幸运的是有曲线救国的方式!这个曲线十分恼人,但是还是能做的。我们这样引入 CJS 代码就可以了:

import _ from './lodash.cjs';
const {shuffle} = _;

这样做没什么特别的弊端,而且感知了 ESM 的 CJS 库甚至能够提供它们自己的 ESM 包裹层,为我们封装了这样的写法框架。

完全没问题!要是能更好点就好了。

脱离执行顺序也能工作,但是有更坏的结果

有一部分的人提出,在 ESM 引入之前执行 CJS 的引入是脱离了执行顺序的。这样一来,CJS 的命名导出会和 ESM 的命名导出在同时计算。

但是这样就会产生一个新的问题。

import {liquor} from 'liquor';
import {beer} from 'beer';

如果 liquor 和 beer 最初都是 CJS,把 liquor 从 CJS 换成 ESM 就会使得顺序从 liquor, beer 变成 beer, liquor,那么如果 beer 中依赖 liquor 中先执行的内容,这样就会令人呕吐地有问题。

脱离顺序的执行依然在争论当中, 虽然几周之前这个话题就几乎没啥声音了。

动态模块能拯救,但是它们的星号有毒

有一个替代方案的提议,既不需要脱离执行顺序,也不需要做封装,称作动态模块(Dynamic Modules)。

在 ESM 中,导出的地方会静态定义所有命名导出。在动态模块方案下,引入的地方会在 import 中定义导出的名字。ESM 加载器一上来会信任动态模块(CJS 代码)能够提供所有需要的命名导出,如果之后有地方不满足,则再抛出一个异常。

然而,动态模块需要 JavaScript 语言发生一些变化,这些变化需要 TC39 语言委员会进行同意。而他们不同意。

ESM 代码可以 export * from './foo.cjs',这意思是重新把 foo 中导出的所有名字进行导出。(称为**“星号导出(star export)”**。🤩)

然而,如果我们从动态模块中星号导出,加载器就无法知道导出的是什么。

动态模块的星号导出在规范合格性上也产生了问题。比如,export * from 'omg'; export * from 'bbq'; 应该抛出异常,因为 omg 和 bbq 都导出了相同名字的 wtf。允许这些命名能够被用户/消费者进行定义,意味着这个合法性校验阶段需要被滞后处理或者忽略。

动态模块的提倡者提议在动态模块中禁止星号导出方式,但是 TC39 拒绝了这一提议。一个 TC39 的成员把这个提议比作是“语法毒(syntax poisoning)“,因为星号导入在动态模块中就像是被“下毒”了一样。

这个带毒的星星对你很生气。图片来源:seekpng

(在我看来,我们已经居住在一个语法毒的世界里了。在 Node 14 版中,命名导出就是被下毒的,在动态模块中,星号导入也是被下毒的。因为命名导出极其普遍而星号导出相对罕见,动态模块会在生态中减少语法毒的成分。)

这并不意味着动态模块已经穷途末路。案上依然有提议,让所有 Node 模块都成为动态模块,甚至带上纯 ESM 模块,并且在 Node 中弃用 ESM 的多阶段加载器。让人眼前一亮的是,这样并不会产生用户可见的影响,除了一些可能发生的轻微启动性能下降。ESM 多阶段加载器是在网络缓慢的情况下加载代码而设计的。

不过我依然不觉得会这么走运。Github 上关于动态模块的 issue 最近被关闭了,因为去年没有关于动态模块的讨论。

还有一个方案悬而未决,那就是做一次充分努力的尝试,把 CJS 模块进行词法分析,从而检测出导出内容,但是这个方案不可能在 100% 的用例中使用。(最新的 PR 在 npm 前 1000 的模块中只有 62% 正常工作。)因为这种启发式的东西太不可靠,一些 Node 模块工作组成员是反对的。

ESM 可以 require(),但是很可能并不值得

require() 默认并不在 ESM 代码范畴内,不过你可以轻松把它找回。

import { createRequire } from 'module';
const require = createRequire(import.meta.url);

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

这个方法的问题在于它没能帮多大忙。实际上也就比做一个默认导入然后解构多了几行代码。

import cjsModule from './foo.cjs';
const {foo} = cjsModule;

另外,像 Webpack 和 Rollup 这样的打包工具并不知道如何处理 createRequire 这样的模式,所以意义何在呢?

如何创建一个良好的包含了 CJS 和 ESM 的“二重包”

如果你手上至今都维护着一个库,需要支持 CJS 和 ESM,那么就给你的用户做点好事,按照上文的方针建造一个“二重包”,能够在 CJS 和 ESM 下都良好工作。

给库提供一个 CJS 的版本

这是为了方便你的 CJS 用户。同时也确保了你的库能够在 Node 的早期版本中正常工作。

(如果你使用的是 TypeScript 或者其它最终转译成 JS 的语言,那么就转译成 CJS 吧。)

给 CJS 提供一个浅的 ESM 封装

(注意,给 CJS 库写一个 ESM 包裹层是不难的,但是给 ESM 库写一个 CJS 包裹层就不可能了。)

import cjsModule from '../index.js';

export const foo = cjsModule.foo; 

把 ESM 包裹层 放到一个 esm 的子目录下,同时放入一个一行的 package.json,里面只放 {"type": "module"}。(你可以重命名你的包裹层文件为 .mjs,在 Node 14 下是正常的,但是有的工具和 .mjs 搭配不好,因此我倾向于使用一个子目录。)

避免二次转译。如果你是在从 TypeScript 做转译,你可以转译成 CJS 和 ESM,但是这就会带来一个潜在的危害,用户可能偶然既 import 了你的 ESM 代码,又 require() 了你的 CJS 代码。(比如,假设一个库 omg.mjs 依赖于 index.mjs,另一个库 bbq.cjs 依赖于 index.cjs,然后你还既要依赖 omg.mjs 又要依赖 bbq.cjs。)

Node 自身会给模块做去重,不过 Node 并不知道你的 CJS 和 ESM 其实是”相同的“文件,于是你的代码就会执行两次,并且保留你的库状态的两份拷贝。这就能引发各种奇异的 Bug。

给你的 package.json 添加一个 exports 映射

就像这样:

"exports": {
    "require""./index.js",
    "import""./esm/wrapper.js"
}

注意:添加一个 exports 映射永远要作为“语义化版本控制中的主要层级”的重大变化。 默认情况下,你的用户能够进入你的包,然后 require() 任何他们想要的代码,甚至是你想要变成内部层的文件。exports 映射确保了用户只能 require/import 你刻意暴露出来的入点文件。

这就快是一个好的东西了!但是这也是一个重大变化。

(如果你跟着你的用户进行 import 或者 require() 你的模块里的其他文件,你也可以分开来设置入点。具体请查阅 ESM 的 Node 文档。

*始终要在导出映射目标中包含文件扩展名。*写成 "index.js" 而不是 "index" 或者一个类似 "./build" 的目录。

如果你遵循了上述的方针,你的用户就会很安分。一切都会变得很安分。

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存