Dynamic Modules S1
中文标题:动态模块
- 阶段: Stage 1
- 状态: 进行中
- ECMAScript 版本: —
- 同步时间: 2026年8月26日
- English original · 官方仓库
该提案向 ECMAScript 262 引入了动态模块记录,允许像 Node.js 这样的宿主环境以编程方式创建模块,而无需源码文本。它通过允许在执行时定义导出绑定,解决了从 CommonJS 和类似旧格式提供命名导出的需求。该提案处于早期阶段,v8 原型实现正在开发中。
以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。
ECMAScript 提案:动态模块
背景
ECMAScript 262 模块的语义基于抽象模块记录,但目前仅通过源码文本模块记录提供了这些记录的一种实现。
过去曾提出过各种类型的动态模块记录,从最初支持与 CommonJS 的斑马条纹关系的动态模块记录,到草稿模块修改器提案,该提案旨在与模块注册表 API 一起支持。
本规范定义了一种新的动态模块记录,它可以通过编程方式由宿主环境创建和定义,以支持非源码文本模块记录的树序执行。此外,这些动态模块记录支持在执行时迟定义命名导出绑定,以支持旧式模块格式执行的命名导出。
动机
Node.js 等宿主环境需要定义不是基于源码文本而是以编程方式创建的模块记录。
Node.js 的示例包括内置模块(虽然它们仍然是 CommonJS)、原生插件、JSON 文件和 CommonJS。目前 Node.js 在内部生成源码文本来处理这些情况,这种方法很笨拙。
此外,在 Node.js 中为 CommonJS 实现命名导出支持目前被阻塞,因为源码文本包装方法要求在执行前就知道导出的名称。
提议的解决方案
所采用的方法是定义一个实现抽象模块记录所有预期字段和方法的动态模块记录。
DynamicModuleRecord 在执行时调用宿主钩子 HostEvaluateDynamicModule。
此外,模块执行被允许在执行期间通过提供的 SetDynamicExportBinding 具体方法来定义命名导出绑定。
由于动态模块记录本身不能有任何依赖项,因此不可能在执行前提前访问绑定。如果在执行后仍有任何绑定未初始化,则抛出 ReferenceError,实际上将这个验证阶段从实例化阶段移到了执行后阶段。
注意:动态模块记录没有依赖项。 这为单一的单向边界提供了支持,而无需对图内的其他模块进行进一步的传递依赖,避免了循环引用行为。这与 WASM 或二进制 AST 的集成不同,后者可以与现有图有进一步的传递依赖,因此需要它们自己的抽象模块记录实现。
ECMAScript 262 变更
此处的规范依赖于 ECMA-262 本身的一些小改动,这些改动目前在 PR https://github.com/tc39/ecma262/pull/1306 中跟踪。
说明性示例
考虑源码文本模块记录 "main.js":
其中 'fs' 是一个被实现为动态模块记录的 CommonJS 模块。
规范中采取以下粗略的步骤:
'fs'被实例化,对于动态模块记录来说,除了簿记之外,这基本上是一个空操作。'main.js'被实例化。readFile绑定被初始化,此时在'fs'的词法环境中为其创建一个新的词法绑定。fs命名空间异质对象被创建,带有一个导出名称readFile。'fs'被执行,调用HostEvaluateDynamicModule钩子。宿主然后可以使用第三方加载器执行 CommonJS 模块,并使用SetDynamicExportBinding具体方法设置命名导出绑定的词法环境值。 例如,它也可以在此刻定义一个writeFile绑定。- 如果此时
'fs'有任何绑定未初始化,则抛出ReferenceError。例如,如果有import { missing } from 'fs'。 - 如果
fs命名空间异质对象被定义(它在现有规范中被惰性创建),则此时会将任何新的导出名称添加到该对象中。这里不用担心提前访问,因为用户代码在此之前不可能读取该对象。 'main.js'被执行,并访问readFile绑定以及fs.readFile命名空间绑定。此时所有绑定都保证已定义。
虽然不指定具体实现,但定义 'fs' 的宿主 API 可能看起来像这样:
处理命名空间和星号导出
需要对一些规范进行修改,以支持来自动态模块的命名空间星号导出。
考虑以下示例:
lib.js
main1.js
main2.js
'main1.js' 可以很好地支持,因为 ResolveExport 具体方法将确保为 dynamicMethod 提供一个占位符,然后在执行时验证。
另一方面,对于 'main2.js',命名空间对象在实例化期间创建时不知道来自 'dynamic-module' 的导出名称列表。
为了支持这一点,我们引入了一些簿记来跟踪引用了动态模块记录的星号导出的任何命名空间异质对象。
命名空间最初在没有任何导出的情况下创建,一旦命名空间星号导出的所有动态模块都执行完毕,然后命名空间被最终确定并设置其导出名称。
未实例化的循环边缘情况
唯一可能观察到未执行的动态模块的情况是特殊的循环情况,例如以下:
a.mjs
b.mjs
在上面的示例中,导入 a.mjs 将导致 b.mjs 在 a.mjs 和 dynamic 之前执行。因此,命名空间对象在此中间阶段将没有导出,可以认为这是在模块命名空间导出上的一种 TDZ。
重要的是,部分填充的导出永远不会可见,这样我们就不会得到依赖于运行时的导出可见性行为。 以前针对这种情况实现了抛出行为,但错误不容易调试。此外,在许多有效情况下,这种可观察性永远不会被看到。
常见问题解答
为什么不支持常量绑定?
这些绑定实际上表现得像 let 绑定。常量绑定将提供实用价值,如果有办法修改它们,但目前在这个规范方法中这是不可能的。
为什么不支持提升的函数绑定?
不需要为动态模块提供提升的绑定,因为它们总是会在消费者之前执行。
这是否意味着宿主将根据用户请求定义命名导出?
虽然导出绑定是根据导入者惰性创建的,但任何未显式初始化的导出绑定都会在执行后立即抛出。
此外,HostEvaluateDynamicModule 钩子明确要求不依赖于任何关于已导入绑定的导入者提示。
通过确保 HostEvaluateDynamicModule 独立地填充导出,并在执行后为未定义的绑定抛出 ReferenceError,尽管这里支持迟定义,我们继续保证导出名称的明确特性。
为什么这必须是主机方法?
可以考虑使用 Reflect 风格的接口来公开程序化模块创建,这个提案可以为此奠定基础。
但目标是创建一个解决当前迫切需求的最小提案,而不是一个有自己的设计问题的全新 JS API。
这样的功能当然可以在此基础上构建,例如与未来的任何注册表 API 结合。
动态模块记录不能定义在 ECMA-262 之外吗?
为了支持按树序执行且导出名称可能在执行时定义的动态模块,根本不可能使用源码文本包装方法——需要一个新的引擎 API 来支持这一点。
这个动态模块记录规范采取了许多步骤,这些步骤是否会被 ECMA-262 模块语义支持还不清楚:
- 我们允许在动态模块上调用
ResolveExport具体方法时定义 let 风格的导出绑定占位符,以确保在实例化期间可用。 - 我们将动态模块的导出名称验证从实例化阶段移到执行后阶段。
- 我们可能会在命名空间异质对象创建后向其扩展新的导出名称(从规范的角度来看)。
- 为了确保动态模块在其导入者之前执行,我们需要在某些特定的循环引用边缘情况下在 ES 实例化算法中抛出引用错误。
- 为了处理需要此扩展的命名空间异质对象的簿记,我们向
GetExportNames添加了一个新参数来跟踪请求模块。
此外,这项工作可能为未来公开基于 Reflect 的动态模块 API 提供基础,如前所述。
规范
实现
v8 中的一个初始实现已经在 https://github.com/v8/v8/compare/master...guybedford:dynamic-modules 开发,并且正在接受进一步的审查和反馈。欢迎合作。