战争中的节点模块:为什么CommonJS和ES模块无法相处

2020-08-06 12:17:35

在节点14中,现在有两种脚本:旧式CommonJS(CJS)脚本和新式ESM脚本(又名MJS)。CJS脚本使用REQUIRED()和EXPORTS;ESM脚本使用IMPORT和EXPORT。

ESM和CJS是完全不同的动物。从表面上看,ESM看起来与CJS非常相似,但它们的实现却有天壤之别。一只是蜜蜂,另一只是杀人黄蜂。

可以从ESM调用CJS,反之亦然,但这很麻烦。

您不能要求()ESM脚本;您只能导入ESM脚本,如下所示:从';foo';导入{foo}。

CJS脚本可以使用异步动态导入()来使用ESM,但是与同步要求相比,这是一个麻烦。

ESM脚本可以导入CJS脚本,但只能使用“default import”语法import_from';lotash';,而不能使用“命名import”语法import{Shuffle}from';lotash';,如果CJS脚本使用命名导出,这会很麻烦。

ESM脚本可能需要()cjs脚本,即使是命名导出,但通常不值得这么麻烦,因为它需要更多的样板,最糟糕的是,像webpack和Rollup这样的捆绑程序不知道/不知道如何使用使用Required()的ESM脚本。

CJS是默认模式;您必须选择加入ESM模式。您可以通过将脚本从.js重命名为.mjs来选择加入ESM模式。或者,您可以在Package.json中设置";type";:";module";,然后可以通过将脚本从.js重命名为.cjs来退出ESM。(您甚至可以通过在其中放置一行{";type";:";module";}Package.json文件来调整单个子目录。)

这些规则是痛苦的。更糟糕的是,对于许多用户,特别是Node的新手来说,这些规则是无法理解的。(不用担心,我将在本文中对它们全部进行解释。)。

许多Node生态系统的观察人士猜测,这些规则是由于领导层的失败,甚至是对ESM的敌意。但是,正如我们将看到的,所有这些规则都有一个很好的理由,这将使未来很难打破这些规则。

自从Node诞生以来,Node模块就被编写为CommonJS模块。我们使用Required()来导入它们。在实现供其他人使用的模块时,我们可以定义导出,可以通过设置mode.exports.foo=';bar';来定义“命名导出”,也可以通过设置mode.exports=';baz&39;来定义“默认导出”。

下面是一个使用命名导出的CJS示例,其中blah.cjs有一个名为foo的导出。

下面是一个CJS示例,其中blah.cjs设置默认导出。默认导出没有名称;使用Required()的模块定义自己的名称。

在ESM脚本中,导入和导出是语言的一部分;与CJS一样,它们对命名导出和默认导出有两种不同的语法。

下面是一个名为exports的ESM示例,其中blah.mjs有一个名为foo的导出。

下面是一个ESM示例,其中blah.mjs设置默认导出。就像在CJS中一样,默认导出没有名称,但是使用IMPORT的模块定义了自己的名称。

在CommonJS中,Required()是同步的;它不返回承诺或回调。Required()从磁盘(或者甚至从网络)读取,然后立即运行脚本,该脚本本身可能会执行I/O或其他副作用,然后返回在mode.exports上设置的任何值。

在ESM中,模块加载器以异步阶段运行。在第一阶段,它解析脚本以检测对导入和导出的调用,而无需运行导入的脚本。在解析阶段,ESM加载器可以立即检测到命名导入中的拼写错误,并抛出异常,而无需实际运行依赖项代码。

在下一阶段,ESM模块加载器然后异步下载并解析您导入的任何脚本,然后再下载您的脚本导入的脚本,构建依赖项的“模块图”,直到最终找到不导入任何内容的脚本。最后,允许执行该脚本,然后允许运行依赖于该脚本的脚本,依此类推。

ES模块图中的所有“同级”脚本都是并行下载的,但它们是按顺序执行的,这是由加载器规范保证的。

ESM改变了JavaScript中的很多东西。默认情况下,ESM脚本使用严格模式(使用严格模式),它们的this不引用全局对象,作用域的工作方式不同,等等。

这就是为什么即使在浏览器中,<;script>;标记默认情况下也是非ESM的;您必须添加type=";module";属性才能选择进入ESM模式。

将缺省值从CJS切换到ESM将是向后兼容性的重大突破。(Node的热门新替代品Deno将ESM作为默认选项,但结果是,其生态系统正在从头开始。)。

CJS不能要求()ESM的最简单原因是ESM可以执行顶级等待,但CJS脚本不能。

顶层等待允许我们在“顶层”异步函数之外使用AWAIT关键字。

ESM的多相装载机使ESM有可能实现顶层等待,而不会使其成为“步枪”。引用V8团队的博客文章:

您可能已经看过Rich Harris的臭名昭著的要点,它最初概述了一些关于顶级等待的问题,并敦促JavaScript语言不要实现该特性。一些具体的问题包括:

·顶级等待可能会阻止执行。·顶级等待可能会阻止获取资源。·CommonJS模块不会有明确的互操作故事。

·由于兄弟姐妹能够执行任务,因此没有明确的屏蔽。·顶级等待发生在模块图的执行阶段。此时,所有资源都已获取并链接。没有阻塞获取资源的风险。·顶级等待仅限于[ESM]模块。显然不支持脚本或CommonJS模块。

因为CJS不支持顶层等待,所以甚至不可能将ESM顶层等待转换到CJS中。您将如何在CJS中重写此代码?

在这个帖子中,关于如何要求()ESM有一场激烈的辩论。(请在评论之前阅读整个帖子和相关讨论。)。

这很令人沮丧,因为绝大多数ESM脚本不使用顶层等待,但是,正如一位评论者在该帖子中所写的那样,“我不认为以某些特性不会被使用的笼统假设来设计系统是一条可行的道路。”

回顾那次对话,看起来我们短期内不可能需要()ESM!

目前,如果您正在编写CJS,并且希望导入ESM脚本,则必须使用异步动态导入()。

我是…。我想可以,只要你们没有任何出口产品。如果您确实需要进行一些导出,则必须改为导出承诺,这可能会给您的用户带来极大的不便:

除非CJS脚本无序执行,否则ESM无法导入命名的CJS导出。

这是因为CJS脚本在执行时计算它们的命名导出,而ESM的命名导出必须在解析阶段计算。

对我们来说幸运的是,有一个变通办法!解决方法很烦人,但完全可行。我们只需要像这样导入CJS脚本:

这没有什么真正的缺点,支持ESM的CJS库甚至可以为我们提供自己的ESM包装器来封装这个样板。

一些人建议在ESM进口之前执行CJS进口,这是无序的。这样,CJS命名的导出可以与ESM命名的导出同时计算。

如果白酒和啤酒最初都是CJS,将白酒从CJS更改为ESM会将顺序从白酒、啤酒更改为啤酒、白酒,如果啤酒依赖于首先执行的白酒中的某些东西,这将是令人作呕的问题。

无序处决仍在争论中,尽管这场对话似乎在几周前大多已告吹。

还有一种替代方案,它不需要无序执行或包装器脚本,称为动态模块。

在ESM规范中,导出器静态定义所有命名导出。在动态模块下,导入器将在导入中定义导出名称。ESM加载程序最初只会相信动态模块(CJS脚本)会提供所有需要的命名导出,如果它们不满足约定,则稍后会抛出异常。

不幸的是,动态模块需要TC39语言委员会批准一些JavaScript语言更改,但他们不会批准。

具体地说,ESM脚本可以*从';./foo.cjs';导出,这意味着重新导出foo导出的所有名称。(这称为“星级导出”。🤩)。

不幸的是,当我们从动态模块启动导出时,加载程序无法知道正在导出什么。

动态模块星形导出也会给规范遵从性带来问题。例如,当omg和bbq都导出相同名称的export wtf时,应抛出export*from';omg';;export*from';bbq&;。允许名称由用户/消费者定义意味着需要以某种方式对此验证阶段进行后期处理/忽略。

动态模块的支持者建议禁止动态模块中的明星导出,但TC39拒绝了这一提议。一位TC39成员将此建议称为“语法中毒”,因为动态模块会“毒害”星形导出。

(在我看来,我们已经生活在一个语法毒药的世界里了。在节点14中,命名的导入是有毒的,而在动态模块下,星形导出是有毒的。由于命名导入非常常见,而星形导出相对较少,因此动态模块将减少生态系统中的语法毒害。)。

这可能不是动态模块之路的尽头。摆在桌面上的一个建议是让所有Node模块成为动态模块,甚至是纯ESM模块,放弃Node中的ESM多相加载器。令人惊讶的是,这不会有任何用户可见的效果,除了启动性能可能稍差之外;ESM多阶段加载器是为在速度较慢的网络上加载脚本而设计的。

但我并不觉得幸运。动态模块的Github问题最近结束了,因为去年没有关于动态模块的讨论。

还有一个想法正在酝酿之中,那就是尽最大努力尝试解析CJS模块以检测它们的导出,但是这种方法永远不可能在100%的情况下起作用。(最新的公关只适用于NPM上排名前1000的模块中的62%。)。由于启发式方法非常不可靠,节点模块工作组的一些成员对此表示反对。

默认情况下,Required()不在ESM脚本的作用域中,但是您可以非常容易地将其取回。

这种方法的问题在于它没有真正的帮助;它实际上比仅仅执行默认的导入和析构需要更多的代码行。

另外,像webpack和Rollup这样的捆绑者不知道如何处理这个CreateRequire模式。那有什么意义呢?

如果您现在维护的库需要支持CJS和ESM,请帮您的用户一个忙,并遵循这些指导原则来创建在CJS和ESM中非常有效的“双包”。

这是为了方便您的CJS用户。这还可以确保您的库可以在较旧版本的Node中工作。

(如果您正在用打字稿或其他转换为JS的语言编写,请转换为CJS。)。

(请注意,为CJS库编写ESM包装器很容易,但不可能为ESM库编写CJS包装器。)。

将ESM包装器放在ESM子目录中,与一行Package.json文件放在一起,该文件显示{";type";:";module";}。(您可以将包装器文件重命名为.mjs,这在节点14中可以很好地工作,但是有些工具不能很好地处理.mjs文件,所以我更喜欢使用子目录。)。

避免重复翻转。如果您正在从TypeScript转换,您可以同时转换为CJS和ESM,但这会带来一个风险,即用户可能会意外地同时导入您的ESM脚本并要求()单独使用您的CJS。(例如,假设一个库omg.mjs依赖index.mjs,而另一个库bbq.cjs依赖index.cjs,然后您同时依赖omg.mjs和bbq.cjs。)。

Node通常会删除模块的重复数据,但是Node不知道您的CJS和ESM是“相同的”文件,所以您的代码将运行两次,同时保留两个库状态的副本。会导致各种奇怪的虫子。

请注意:添加导出映射始终是“半个主要”的突破性更改。默认情况下,您的用户可以进入您的软件包并要求()任何他们想要的脚本,即使是您想要作为内部文件的文件也是如此。导出映射确保用户只能要求/导入您有意公开的入口点。

(如果您允许用户导入或要求()模块中的其他文件,您也可以为这些文件设置单独的入口点。有关详细信息,请参阅ESM上的节点文档。)。

始终在导出映射目标中包含文件扩展名。请参阅";index.js";,而不仅仅是";index";或类似";./build";的目录。

如果您遵循这些指导原则,您的用户就会没事。一切都会好起来的。