Module Declarations S2
中文标题:模块声明
- 阶段: Stage 2
- 状态: 进行中
- ECMAScript 版本: —
- 同步时间: 2026年8月26日
- English original · 官方仓库
该提案为 JavaScript 添加了一种命名内联模块的语法,允许将多个模块打包到单个文件中。它解决了大量小模块文件的高加载成本,以及打包器在虚拟化 ES 模块语义时面临的复杂性问题。模块声明拥有自己的顶层词法作用域,可以被静态导入,也可以被导出以在外层模块之外使用。
以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。
JavaScript 模块声明
JavaScript 模块声明(先前称为“模块片段”)是一种命名内联 JS 模块的语法,可用于将多个模块打包到单个 JavaScript 文件中。
状态
阶段:2
提案发起人:
- Daniel Ehrenberg(Bloomberg)
- Nicolò Ribaudo(Igalia,与 eyeo 和 Bloomberg 合作)
动机
JavaScript 开发者经常将代码编写在许多小模块中,并且 ECMAScript 模块(ESM,在 ES6/ES2015 中引入)作为源格式的采用率很高。然而,许多小文件——无论是在 Web、服务器还是其他环境中——在加载性能方面都有很高的成本。因此,开发者使用称为“打包器”(bundler)的工具来在一个(或几个)脚本或模块中模拟多个 ES 模块。一些例子有 webpack、rollup、Parcel 和 esbuild。
打包器需要完全虚拟化 ES 模块语义,这给它们的实现增加了大量复杂性,而且随着新的模块特性(如 顶层 await)的出现,这种成本会随着时间的推移而增加。它还在运行时性能方面有成本,因为引擎需要处理虚拟化的代码,并且它们无法看到之前的模块结构。例如,模块可以作为划分代码以进行并行字节码生成的方便点,但如果打包器将所有内容变成一个大的脚本或模块,今天 JS 引擎很难看到这种结构。
一些更通用的打包格式,如 资源包,相比纯 JS 打包系统有显著优势,因为开发者在实际中需要组合的不仅仅是 JavaScript。基于 fetch 级别的资源实现预计会有一定程度开销,这可能适合图像、WebAssembly 和 CSS。然而,JavaScript 在模块数量上的“膨胀”往往远高于其他资源,因此一种专用纯 JS 格式可以更便宜地在模块级别(而不是网络级别)进行结合。
该提案为 JavaScript 添加了一种语法,允许在一个文件中包含多个 JavaScript 模块。这种格式可以被打包器用作输出,执行开销低,这样打包器就不必模拟那么多,JS 引擎也能看到正在发生的事情。它对于 JavaScript 开发者直接编写也很方便,并且应该以低开销融入现有工作流程。
示例
包含多个模块声明的这个模块,通过 script 标签从 HTML 文件中引用:
模块声明如果被导出,也可以在定义它们的文件之外使用。
另一方面,countModule 模块没有被导出,因此不能以这种方式使用。
语法
ModuleDeclaration 是一个新的非终结符,可以出现在模块的顶层(如 import 和 export 语句),或者脚本的顶层。注意,与 模块表达式 的情况一样,模块声明与包含它们的模块之间没有共享的词法作用域;它们只是并排存在,就像从不同 URL 获取的模块一样。
模块声明可以嵌套在其他模块声明内部。
语义
- 模块声明可以被静态导入。
- 模块声明只有在被显式导出时,才能在包含它们的模块之外使用。
- 每个模块声明有自己的顶层词法作用域。它们之间没有共享作用域。
如果一个模块声明被多次导入,返回的是同一个模块“实例”,就像在单独的 JS 文件中声明的模块一样。换句话说,模块声明是单例。
HTML 集成
注意:以下内容旨在为 HTML/Web 平台提供细节,但其他在适当情况下旨在与 Web 类似(例如 Node.js)的平台也可能希望遵循这些设计。
import.meta.url
模块声明内部的 import.meta.url 是外层模块的模块说明符。例如,
上述代码将输出 https://example.com/xyz.js。
模块声明内的相对模块说明符按照与在外层模块中定义时相同的方式解析。此行为与通过 new URL(moduleSpecifier, import.meta.url) 计算的结果相同。
此行为源于为 模块表达式 提议的语义。
常见问题
该提案是否解决了关于打包的隐私担忧?
Brave 曾表达担忧认为打包可能被用来让服务器更容易地重新映射 URL,这不利于阻止跟踪等隐私技术。该提案的表达能力比 Web Bundles 小得多,因此这些问题风险不大:
JS 模块包仅限于同源 JS,因此其范围类似于目前使用 webpack 和 rollup 等流行打包器所做的工作,并不会增加更多能力。虽然可以轮换/打乱声明标识符,但将包含多个模块声明的整个外层模块视为一个单元是合理的,内容拦截器要么针对全部内容,要么不针对任何内容。
Mozilla 的 Martin Thompson 曾表示倾向于基于 URL 的打包方案,这些 URL 能准确标识资源的身份。由于模块声明不能直接加载,而只能通过外层模块加载,并且外层模块通过其 URL 获取,因此身份得到了清晰表示。(TODO:与 MT 确认)
为什么要有模块声明,而不是只关注通用资源包?
模块声明提供了资源包加载行为的一个非常有限的功能子集。
以下是资源包与模块声明的逐点比较:
- 级别:资源包加载使新资产在“fetch”/网络级别可用,涉及浏览器中更大范围的部分。相比之下,模块声明被限制在模块加载机制中,范围更为有限。
- 类型:模块声明只能包含 JavaScript,但资源包可以包含任何 MIME 类型的资源。
- 元数据:资源包甚至可以支持 Content-Type 之外的其他标头来提供有关单个响应的更多信息,而模块声明没有任何语法来表达这些元数据。
- 安全/隐私考虑:因为 JS 模块包只影响 JavaScript 的加载方式,并且做的事情与今天打包器所做的等价,因此几乎不需要担心额外的安全/隐私面。相比之下,资源包的情况更为复杂。
- HTTP 缓存:模块声明与包含它们的外层模块一起缓存在 HTTP 缓存中。浏览器无法只请求它缺少的声明。相比之下,资源包加载将资源划分为多个块,并且只加载缓存中不存在的块。
- 版本控制/缓存破坏:资源包加载允许轮换块 ID 以导致某些资源即使存在于缓存中也被重新加载,从而无需更改 URL。相比之下,模块声明不提供这样的机制,因此需要在其他级别上提供解决方案。
- 解析性能:资源包易于解析,因为其二进制格式与负载明确分层,并且注重细节以高效支持流式和随机访问。相反,模块声明需要按顺序解析整个 JS 文件,不支持随机访问(只有在我们削弱早期错误语义的情况下,流式处理才有可能)。
- 每资源开销:由于资源包加载发生在浏览器中更广泛的级别,每个资源都会经过更多的代码路径(例如,渲染器内部数据结构、内容拦截等)。这使得它们更难优化。相比之下,模块声明经过更具体的代码路径,因此可能更容易优化每资源开销。
- 复杂性:模块声明是一种非常简单的机制。知道如何读写 JavaScript 的工具和引擎可以逐步更新以支持它们。资源包是一个更重的任务,但带来了某些好处作为交换。
我(Dan Ehrenberg)目前的假设是,为了获得最佳性能,模块声明应该嵌套在资源包内部。这样,资源包的表现力可以与模块声明的低每资源开销相结合:今天在资产数量方面的大部分“膨胀”是 JS 模块,因此针对这种情况提供一个专门的解决方案是有意义的,它可以包含在 JS 引擎内部。接下来的计划是开发原型实现(无论是在浏览器中还是在构建工具中),以在落地之前验证这一假设。
为什么将这个提案和模块表达式作为两个独立的东西,而不是一个共同的语言特性?
模块表达式 提案引入了内联模块的表达式形式。作为表达式,它们本质上是动态的:它们可以通过 import() 或 new Worker() 导入,但不能通过静态导入语句导入。
模块声明解除了这一限制:如果它们出现在模块的顶层,就可以被静态导入。这使得模块声明比模块表达式更有用于打包。更多背景请参见此 FAQ。
我们将这两个特性作为独立的提案来开发,因为模块声明在静态导入语句方面带来了额外的复杂性,而模块表达式不需要;模块表达式也有自己的动机,独立于模块声明。模块声明继承了模块表达式的不同设计决策,因此该提案当前形态的推进取决于模块表达式的发展。
下一步
该提案的计划是在未来的 TC39 会议上将其提交为 Stage 1,并原型化它与资源包加载结合使用,用于 Web 平台上的高性能原生打包解决方案。