Node.js, TC-39, and Modules

James M Snell

Katherina Miao(翻译)

原文链接


本周我第一次参加了 TC-39 会议。可能有人还不知道,TC-39 是 ECMA 工作组的编号,是他们定义了 ECMAScript 语言(或者更常见的“JavaScript”)。这个工作组敲定(通常是纠结的)并通过了 JavaScript 语言的细枝末节,以确保 JavaScript 编程语言不断发展并继续满足开发人员的需求。

我本周参加 TC-39 会议的原因非常简单:TC-39 定义的一种较新的 JavaScript 语言 - 即模块 - 导致 Node.js 核心团队遇到了一些困扰。我们(主要是Bradley Farias - Twitter@bradleymeck)一直试图弄清楚如何以最好的方式使 Node.js 支持 ECMAScript 模块(ESM),又不会弄出乱子。

问题实际上并不是我们无法按照目前规范定义的方式在 Node.js 中实现 ESM,而是如果完全按照规范的要求做意味着需要减少 Node.js 预期功能,开发者只能得到次优体验。我们非常希望确保 Node.js 中的 ESM 实现最优又可用。由于这个问题比较复杂,为了得到最有效的解决方案,我们与 TC-39 成员坐下来面谈。幸运的是,我认为我们取得了一些重大进展。

为了能更好的解释当前的问题和我们的计划,让我花一些时间来解释一些引起了我们最关注的基本问题。

然而,首先说明:以下很多内容比起实际代码情况做了过分简化。这主要是为了提供概述,而不是深入讨论模块系统。

然后,再次说明:这里的一切都是基于我自己对 TC-39 对话的看法。完全有可能对一些细节有错误的理解,并且完全有可能在继续同 TC-39 讨论后,事情最终会与我在此描述的内容大不相同。我写这篇文章只是为了记录正在讨论的内容。

ECMAScript模块与CommonJS:或者……什么是模块?


事实证明,Node.js 和 TC-39 对“模块”是什么,如何定义以及如何将它们加载到内存和使用中,有着截然不同的想法。

几乎从开始,Node.js 就有了一套模块系统,源自相当松散定义的规范“CommonJS”。

clipboard.png

简单来说,是由一个 JavaScript 文件(诸如函数和变量)导出的标识以供另一个 JavaScript 文件使用。 在 Node.js 中使用require()函数完成的加载。当在 Node.js中调用require(“foo”)时,会执行以下特定的步骤。

clipboard.png

第一步是将标识“foo”解析为指向 Node.js 可识别的内容的绝对文件路径。此解析过程涉及多个内部步骤,本质上是遍历本地文件系统去匹配原生模块(Node.js 自带模块),JavaScript文件或JSON文档。解析步骤的结果是一个绝对文件路径,“foo”指向的内容可以从该文件路径加载到 Node.js 中并使用。

加载完全取决于解析步骤生成的绝对文件路径指向的内容。例如,如果解析路径指向的内容是 Node.js 原生模块,则加载过程会将引用共享库动态关联到当前 Node.js 进程。如果指向的是 JSON 文件或 JavaScript 文件,则在验证文件存在后,文件的内容将被读入内存。值得注意的是,JavaScript Loading(加载)与 JavaScript Evaluating(执行)不同。 前者严格处理将文件的字符串内容拉入内存,而后者处理将该字符串传递给 JavaScript VM 进行解析和执行。

如果加载的内容是 JavaScript 文件,目前的 Node.js 会假定该文件是 CommonJS 模块。接下来的行为是至关重要的,并且经常被应用 Node.js 的开发者误解。在将加载的 JavaScript 字符串传递给 JavaScript VM 进行执行之前,字符串会被将包装在函数内。

例如下面文件“foo.js”:

1
2
const m = 1;
module.exports.m = m;

实际上 Node.js 会把它当作一个函数函数去执行:

1
2
3
4
function (exports, require, module, __filename, __dirname) {
const m = 1;
module.exports.m = m;
}

然后,Node.js 使用 JavaScript 运行时来执行此函数。Node.js 模块中常用的各种“global”变量(如“exports”,“module”,“_ _ filename”和“__dirname”)不是传统 JavaScript 意义上的实际全局变量。相反,它们是函数参数,当调用函数时,它们的值由 Node.js 提供给包装函数。

这个包装函数本质上是一个工厂方法。exports 对象是标准的 JavaScript 对象。包装函数将函数和属性赋值到该 exports 对象。一旦包装函数返回,则 exports 对象被缓存,然后作为require()方法的返回值返回。

我们这次讨论需要理解的关键概念是,在执行包装函数之前,无法预先确定 CommonJS 模块将导出哪些标识。

这是 CommonJS 模块和 ECMAScript 模块之间的关键区别。CommonJS 模块是在执行包装函数时动态的定义了模块的 exports 对象,而 ESM 的导出是在词法上定义的。也就是说,ESM 导出的标识是 JavaScript 代码在实际执行之前,通过解析时得到的。

例如下面这个简单的ECMAScript模块:

1
export const m = 1;

这段代码在执行之前解析完成时,会创建一个名为 Module Record(模块记录)的内部结构。在此模块记录中记录了许多关键信息,包括模块导出标识的静态列表。这些关键字是由解析器查找 export 关键字识别出的。由于缺少更好的描述术语,模块记录中的标识基本上是指向尚不存在的事物的指针。仅在构建此模块记录之后才实际执行模块代码。虽然这里的许多细节我还没有描述清楚,但关键的是在执行之前就确定了 ESM 所导出的标识。

使用ECMAScript模块时使用 import 语句:

1
import {m} from “foo”;

这段代码基本上说,「我将使用模块“foo”导出的“m”标识」。

此语句是一个词法语句,用于在解析代码时在导入脚本和“foo”模块之间建立关联。编写 ECMAScript 模块规范的方式,必须在执行任何代码之前验证关联 - 这意味着实现必须在两个 JavaScript 文件执行前确保标识“m”正由“foo”导出。

对于熟悉强类型面向对象编程语言(如Java或C ++)的人来说,这应该很容易熟悉,因为它类似于通过接口处理对象。导出的标识在执行前进行验证和关联,如果在执行步骤中标识实际上未满足,则会抛出错误。

对于 Node.js,挑战在于,当“foo”不是具有词汇定义的导出集的 ESM,而是具有动态定义的导出集的 CommonJS 模块时。具体来说:当我import {m} from "foo"时,ESM 目前要求在执行之前确定 m 由“foo”导出; 但正如我们已经看到的那样,由于“foo”是一个 CommonJS 模块,因此直到执行之后才能确定 m 被导出。最终结果是,从 CommonJS 模块的具名导入和导出这个 ECMAScript 模块的一个至关重要的特性,在当前定义的 ESM 规范下是不可能实现的。

这不是特别理想,因此我们(Node.js人员)回到 TC-39 询问是否可以在规范中进行一些更改。起初,我们有点不太好意思询问。然而事实证明,TC-39 非常关心 Node.js 能否够有效地实现 ESM,并且为了在 Node.js 环境中更好的运行,正在对规范进行一些更改。

操作顺序

我们提出的一个具体改动是考虑动态定义的模块。基本上,当我import {m} from "foo"时,发现 “foo”不是具有词法定义导出的 ESM,比起抛出错误并结束进程(这是规范当前所做的),将“foo”和导入脚本放入一种中间挂起状态,推迟对导入标识的验证,直到可以执行动态模块的代码。执行后,可以完成 CommonJS 模块的 Module Record(模块记录)并验证导入关联。对 ECMAScript 模块标准的这项修改可以使 CommonJS 模块的具名导出和导入正常运行(尽管有一些关于循环依赖边缘情况的问题)。

让我们来看几个例子。

我有一个依赖于 ESM A 的应用程序,A 又依赖于 CommonJS 模块 B。

clipboard.png

我的应用(myapp.js)的代码:

1
2
const foo = require('A').default
foo()

A的代码:

1
2
3
4
import {log} from "B"
export default function() {
log('hello world')
}

B的代码:

1
2
3
module.exports.log = function(msg) {
console.log(msg);
}

当我运行node myapp.js时,对require('A')的调用将检测到正在加载的东西是 ESM(参见下面有关了解如何进行此检测)。Node.js 不会使用当前用于 CommonJS 模块的包装函数加载模块,而是使用 ECMAScript 模块规范来解析,初始化和执行“A”。当解析“A”的代码并创建模块记录时,它将检测到“B”不是 ESM,因此验证由“B”导出的步骤将处于挂起状态。然后,ESM 加载程序将开始执行阶段。首先将会使用现有的 CommonJS 包装函数来执行B,其结果将被传递回 ESM 加载程序以完成模块记录的构建。其次,它将使用完成的模块记录执行“A”的代码。

如何切换依赖项的顺序。 假设 A 是 CommonJS 而 B 是 ESM。在这里,不需要额外的特殊操作,因为如上例所示,可以require()一个 ESM。

对于绝大多数基本用例,此加载模型应该可以正常工作。但当模块之间存在依赖循环时就变得有些棘手。任何使用过具有循环依赖关系的 CommonJS 模块的人都知道,根据加载这些模块的顺序,有一些相当奇怪的边缘情况会逐渐增加。在 CommonJS 模块和 ESM 之间存在依赖循环的情况下,将存在许多相同类型的问题。

clipboard.png

myapp.js 的代码与上面的保持一致。但是,A 依赖 B,而 B 又依赖 A。

A的代码:

1
2
3
const b = require('B')
exports.b = b.foo()
exports.a = 1

B的代码:

1
2
import {a} from "A"
export const foo () => a

为了说明这个问题我们使用了非常刻意的例子。这种类型的循环非常难以实现,因为关联和执行 ESM“B”时,标识“a”尚未被 CommonJS 模块“A”定义和输出。这类实例不得不被视作参考错误。

但是,如果我们将B的代码更改为:

1
2
import A from "A"
export foo () => A.a

循环依赖有效,因为使用 import 语句导入 CommonJS 模块时,module.exports 对象将成为default导出。在这种情况下,ESM 代码关联到 CommonJS 模块的默认导出而不是标识。

更简洁地说:只有在 ESM 和 CommonJS 模块之间没有依赖循环时,来自 CommonJS 模块的具名导入才有效。

由 CommonJS 和 ESM 之间的差异引起的另一个限制是,在初始执行之后对 CommonJS 导出的任何变化都不能作为具名导入使用。例如,假设 ESM A 依赖于 CommonJS 模块B。

clipboard.png

假设 B 的代码为:

1
2
3
module.exports.foo = function(name, key) {
module.exports[name] = key
}

当“A”导入“B”时,唯一可用作具名导入的导出标识将是默认符号和“foo”。 调用 foo 函数时添加到module.exports的符号都不能作为具名导入使用。但是,它们可以通过默认导出获得。例如,以下内容应该可以正常工作:

1
2
3
4
import {foo} from "B"
import B from "B"
foo("abc", 123)
if (B.abc === 123) { /** ... **/ }

require() vs import

关于 require() 和 import 有一个非常清楚的区别:虽然可以使用require()加载 ESM,并且可以使用 import 导入 CommonJS 模块,但是不可能在 CommonJS 模块中使用 import 语句; 默认情况下,ESM 中将不支持 require()。

换句话说,如果我有一个 CommonJS 模块A,则无法使用以下代码,因为 import 语句将无法在 CommonJS 中使用:

1
2
const b = require('B')
import c from "C"

如果您在 CommonJS 模块中操作,加载和使用 ESM 的正确方法是使用 require:

1
2
const b = require('B')
const c = require('C')

在 ESM 中,只有在特地导入 require 的情况下,require()才可用且可用。用于导入 require 的确切声明尚未确定,但基本上它将是这样的:

1
2
import {require} from "nodejs"
require("foo")

但是,因为可以直接从 CommonJS 模块导入,所以应该没有太多理由这样做。

除此之外:Node.js 人员有许多其他问题,例如 ESM 的加载是否必须是异步的,这需要在整个依赖过程中使用 Promise。TC-39 向我们保证(以及允许上述变更)加载不必是异步的。这是一个好消息。

关于 import()

我们给 TC-39 提出了一个引入一个新的import()函数提议。这与上面示例中显示的import语句明显不同。请考虑下面的例子:

1
2
import {foo} from "bar"
import("baz").then((module)=>{/*…*/}).catch((err)=>{/**…*/})

第一个导入语句是词法。如前所述,在解析代码时会对其进行处理和验证。另一方面,import() 函数在执行时处理。它还导入 ESM(或CommonJS模块),但是与目前 Node.js 的 require() 方法一样,在执行阶段完成处理。 然而,与 require() 不同,import() 返回一个 Promise,允许(但不强制)底层模块的加载完全异步执行。

因为 import() 函数返回一个 Promise,所以可以使用await import("foo")之类的东西。 但是,重要的是要注意,在 TC-39中,import() 远未完成,尚未成熟。现在完全不确定 Node.js 是否能够使用 import() 函数实现完全异步加载。

是CommonJS 还是 ESM 的检测

无论代码是否使用 require(),import 或 import() 来加载模块,都需要先检测以什么方式导入,以便Node.js 可以以适当的方式加载和处理它。

传统上,require() 函数的 Node.js 实现依赖于文件扩展名来区分如何加载不同类型的内容。例如,.node 文件作为本机模块加载,.json 文件只是通过 JSON.parse 传递,*.js 文件作为 CommonJS 模块处理。

随着 ESM 的引入,需要一种机制来区分 CommonJS 模块和 ESM。下面建议几种方法。

一个方案是确保可以将 JavaScript 文件非常明确地解析为 ESM 或其他内容。换句话说,当我解析一些JavaScript 时,它是否是一个 ESM 应该在解析结果中明确可知。这种方法被称为“明确的语法”。不幸的是,它可能会显得有点棘手。

另一个已经考虑的提议是向 package.json 文件添加 meta 元数据中。如果 package.json 文件中有某个特定值,则该模块将作为 ESM 而不是 CommonJS 模块加载。

第三个提议是使用新的文件扩展名(*.mjs)来标识 ECMAScript 模块。这种方法与 Node.js 目前的做法最接近。

例如,假设我有一个应用程序脚本 myapp.js 和一个单独文件中定义的 ESM 模块。

使用明确的语法方法,Node.js 应该能够解析第二个文件中的 JavaScript 并自动确定这是一个 ESM。使用这种方法,ESM 文件可以使用 *.js 文件扩展名,就可以正常运行了。然而,正如我所说,这种方法很难正确的使用,并且存在许多难以实现的边缘情况。

使用 package.json 方法,ESM 要么必须捆绑在它自己的目录中(本质上是它自己的包),要么根目录中必须有一个 package.json,它包含一些元数据,表明 JavaScript 包含在一个 ESM 文件中,或者说就是一个 ESM。由于需要对 package.json 进行额外处理,因此这种方法不太理想。

使用 .mjs 文件扩展方法,ESM 代码被放入像 foo.mjs 这样的文件中。在 Node.js 将说明符解析为绝对文件名后,它将查看文件扩展名,就像它当前已经使用原生模块和 JSON 文件一样。如果它看到 .mjs 文件扩展名时,会选择将内容作为 ESM 加载并处理。但是,如果是 *.js,它将回退并将内容作为 CommonJS 模块去加载。

以目前的情况来看,*.mjs 文件扩展名看起来是最可行的选项,除非所有用于明确语法的各种边缘情况都可以解决。

幂等的担忧

一般来说,多次调用require('foo')将返回完全相同的模块实例。但是,返回的对象是可变的,模块可以通过 monkeypatching(猴子补丁)某个方法或标识来修改其他模块,或者通过完全替换功能来修改其他模块。这种类型的东西在 Node.js 生态系统中非常普遍。

例如,假设 myapp.js 有两个依赖项 A 和 B。这两个依赖项都是 CommonJS 模块。为了扩展功能,A 也依赖 B。

myapp.js 的代码:

1
2
3
const A = require('A')
const B = require('B')
B.foo()

A 的代码

1
2
3
4
5
6
const B = require(‘B’)
const foo = B.foo
B.foo = function() {
console.log('intercepted!')
foo()
}

B的代码

1
2
3
module.exports.foo = function() {
console.log('foo bar baz')
}

在这种情况下,在A中调用require('B')返回的结果与 myapp.js 中调用的require('B')不同。

使用 ECMAScript 模块时,这种跨模块的猴子补丁并不那么容易。原因有两个:A)导入模块在执行之前完成关联,B)导入必须是幂等的 - 每次在给定的上下文中调用导入时,总是返回完全相同的不可变符号集。实际上说,这意味着当使用具名导入时,ESM A 不能轻易地猴子补丁 ESM B。

此规则的效果等同于 myapp.js 这样写:

1
2
3
4
const B = require('B')
const foo = B.foo
const A = require('A')
foo()

这里,模块 A 仍在 修改 B 模块的foo,但由于在修改之前抓取了对 foo 的引用,因此对foo()的调用将调用原始函数,而不是修改后的函数。在 ESM 中将无法导入 A 修改后的 B 模块返回。

在这种幂等规则导致问题的存在多种场景。主要的例子有模拟、APM和用于测试的间谍。幸运的是,可以通过多种方式解决此限制。一种方法是在加载阶段添加钩子,以允许包装 ESM 的导出。另一个是 TC-39 允许加载的 ESM 在加载后进行更换。这里正在考虑几种机制。好消息是,拦截 ESM 与截取 CommonJS 模块不同,这是可以实现的。

更多的工作

还有很多额外的工作要做,不管怎么说,上面讨论的所有内容不是最终的结论。有许多细节需要解决,结果可能会看起来非常不同。重要的是 Node.js 和 TC-39 正在共同努力解决所有这些问题,朝这正确的方向迈出一步是大家都乐意看到的。