两者可以进行适配,但是会徒增负担。
在 Node 14 版本下,现存两类语法:老式的 CommonJS (CJS) 的语法和新式的 ESM 语法(aka MJS)。CJS 使用 require() 和 module.exports;ESM 使用 import 和 export。
**ESM 和 CJS 可以看作是完全不同的动物。**表面上看,ESM 和 CJS 很像,但是他们的实现却是大相径庭。如果说一个是蜜蜂,那么另一个就是杀人蜂。
图中是一只黄蜂和一只蜜蜂。其中一个好比 ESM,另一个好比 CJS,但是我永远记不住哪个是哪个。
无论是在 ESM 中使用 CJS 还是反过来,都是有可能的,但这是徒增负担。
以下是一些规则,我会在后文中详细解释。
在 ESM 代码中无法使用 require();你只能 import ESM 代码,比如:import {foo} from 'foo'
CJS 代码无法使用如上所示的静态 import 语句;
ESM 代码可以 import CJS 代码,但是只能使用“默认导入(default import)”语法,如 import _ from 'lodash',而不是“命名导入(named import)”语法,如 import {shuffle} from 'lodash',因此如果 CJS 代码使用了命名导出,就会很麻烦;
ESM 代码可以 require() CJS 代码,即便是命名导出也可以,但是明显不值得大费周章,因为这样需要更多的框架平台,而且最不好的一点就是诸如 Webpack 和 Rollup 这样的包,不知道,也不会知道怎么处理含有 require() 的 ESM 代码;
CJS 是默认允许使用的,而 ESM 模式则需要你选择性加入。通过把代码文件从 .js 重命名为 .mjs 就可以启用 ESM 模式。除此之外,在 package.json 中设置 "type": "module",然后就可以通过把 .js 重命名为 .cjs 选择退出 ESM 模式。(你甚至可以在某一个子目录下添加一个只有一行 {"type": "module"} 的 package.json 文件来调整。)
这些条条框框太痛苦了。不过,这篇文章里我都将解释清楚。
我为开源库的开发者整理了三条指南用于借鉴:
为你的开源库提供一个 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(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 代码中,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(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));**在 CommonJS 中,require() 是同步的。**它不会返回一个 promise 或者调用回调函数。require() 从硬盘(或者甚至从网络)中进行读操作,然后立刻执行代码。这样就会使得它自行进行 I/O 或产生其它副作用,然后返回任何设置在 module.exports 上的值。
**在 ESM 中,模块加载器是在异步阶段执行的。**在第一个阶段,它会做词法分析,在不执行导入代码的情况下检测是否存在 import 和 export 的调用。在词法转换阶段,ESM 加载器能够立刻检测到命名导入中的拼写错误,并且在不执行依赖代码的情况下抛出异常。
ESM 加载器接下来异步地下载并转译任何引入的代码,然后对引入的代码进行编码,根据依赖建立出一个“模块图(module graph)”,直到最后它发现某块代码没有引入任何东西。最后,这一块代码被允许执行,然后所有这一块代码所依赖的代码被允许执行,依次类推。
ES 模块图中所有具有“兄弟”关系的代码都是并行下载的,但是是按照次序执行的。这一次序由加载器指定并确保执行。
CJS 无法 require() ESM 的最简单原因就是 ESM 可以进行最外层的 await ,但是 CJS 代码不行。
顶层 await 能够让我们在 async 函数的外层使用 await 关键字,也就是处于“顶层”。
ESM 的多阶段加载器使得 ESM 实现顶层 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 代码,你得使用异步动态的 import()。
(async () => { const {foo} = await import('./foo.mjs'); })();看上去……还行,只要别有 exports 就行。如果你需要做 exports,你就得导出一个 Promise,这对于你的用户来说会是一个大大的不便。
你可以这样写:
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 中先执行的内容,这样就会令人呕吐地有问题。
脱离顺序的执行依然在争论当中, 虽然几周之前这个话题就几乎没啥声音了。
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 用户。同时也确保了你的库能够在 Node 的早期版本中正常工作。
(如果你使用的是 TypeScript 或者其它最终转译成 JS 的语言,那么就转译成 CJS 吧。)
(注意,给 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。
就像这样:
"exports": { "require": "./index.js", "import": "./esm/wrapper.js" }注意:添加一个 exports 映射永远要作为“语义化版本控制中的主要层级”的重大变化。 默认情况下,你的用户能够进入你的包,然后 require() 任何他们想要的代码,甚至是你想要变成内部层的文件。exports 映射确保了用户只能 require/import 你刻意暴露出来的文件。
这是一个好的东西了!但是这也是一个重大变化。
(如果你跟着你的用户进行 import 或者 require() 你的模块里的其他文件,你也可以分开来设置入点。具体请查阅 ESM 的 Node 文档。
*始终要在导出映射目标中包含文件扩展名。*写成 "index.js" 而不是 "index" 或者一个类似 "./build" 的目录。
如果你遵循了上述的方针,你的用户就会很安分。一切都会变得很安分。