Module Expressions S2
中文标题:模块表达式
- 阶段: Stage 2
- 状态: 进行中
- ECMAScript 版本: —
- 同步时间: 2026年8月26日
- English original · 官方仓库
该提案引入了模块表达式,这是一种新的语法,可求值为 Module 对象,旨在解决跨 realm 共享代码的挑战,例如在 Web Workers 或 Worklets 中。它提供了一种内联定义模块的方法,可以异步导入、结构化克隆,并与 Worklets 和 Worker 构造函数等 HTML API 集成。
以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。
模块表达式
模块表达式(以前称为“模块块”)是 Surma、Daniel Ehrenberg 和 Nicolò Ribaudo 的工作。这是大量合作和先前工作的成果,最著名的是 Daniel Ehrenberg、Justin Fagnani 的 Inline Modules 提案以及 Domenic 和 Surma 的 Blöcks 提案。
问题空间
每当开发人员尝试在 JavaScript 中使用多线程时——无论是 Web Workers、Service Workers、像 CSS Paint API 这样的 Worklets,甚至其他窗口——他们都会遇到一些问题。JavaScript 固有的单线程设计阻止了内存的共享(SharedArrayBuffer 除外),从而导致函数和代码的共享受阻。今天,在 JavaScript 中,“在另一个线程中运行此函数”的典型范例只能通过变通方法实现,而这些变通方法本身带有显著的缺点。
将这种模式引入 JavaScript 的库(例如 ParllelJS 或 Greenlet)会诉诸于函数的字符串化,以便能够将代码从一个 realm 发送到另一个 realm,并重新解析它(通过 eval 或 blob 化)。
这不仅破坏了闭包捕获值的能力,而且使 CSP 成为问题,并可能使路径解析(想想 import() 或 fetch())意外地表现,因为在某些浏览器中,数据 URL 和 blob URL 被认为位于不同的主机上。
此外,任何从单独文件加载代码的 API 都难以被采用(参见 Web Workers 或 CSS Painting API),即使使用它们有显著的好处。强制开发人员将代码放入单独的文件不仅是一个经常被引用的主要 DX 障碍,而且在捆绑器(其主要目的是将尽可能多的代码放入一个文件)的时代尤其困难。
任何希望使用这些 API 之一的库还面临另一个额外的挑战。如果您将库发布到 npm,并且用户希望通过像 unpkg.com 这样的 CDN 使用它,那么单独的文件现在来自不同的源。即使有正确的 CORS 头,源也将保持不同,这会影响路径以及作为结果的二级资源的解析方式(如果它们能被解析的话)。
还有一个长期存在的问题是,JavaScript 无法以一种可以跨 realms 共享的方式来表示“任务”,而不必处理_至少_上述问题之一。这阻止了任何尝试构建超越主线程的 web 调度器(类似 GCD),而这是调度器的主要可用性优势之一。
模块表达式旨在通过向语言及其与 HTML 标准的集成中引入一个侵入性最小的补充,来显著改善这种情况。
高级概述
模块表达式是模块内容的语法:它们求值为一个 Module 对象。
导入 Module 对象需要是异步的,因为 Module 对象可能从网络导入其他模块。Module 对象可以被多次导入,但会被缓存在模块映射中,并返回对同一模块命名空间的引用。
Module 对象只能通过动态 import() 导入,而不能通过 import 语句导入,因为无法使用说明符字符串来寻址它们。
相对导入语句将相对于_外部_模块的路径进行解析。这在从不同文件或不同 realms 导入 Module 对象时尤其重要。
语法细节
由于 module 不是 JavaScript 中的关键字,因此 module 之后不允许换行。
HTML 集成
(HTML 集成正在进行中,见 此 PR。)
HTML 规范中有 4 个主要的 Module 对象集成点:
Worklets
Worklets(例如 CSS Painting API 或 Audio Worklet)使用 addModule() 模式将单独的文件加载到 Worklet 上下文中:
该提案旨在类似于 Worker 构造函数调整 addModule,使其接受 Module 对象。
结构化克隆
Module 对象是可结构化克隆的,允许它们通过 postMessage()(发送到 Workers、ServiceWorkers 甚至其他窗口)发送。
import.meta.url
import.meta 继承自模块块_语法上_所在的模块。这对于在模块块跨 realms 共享(例如发送到 worker)时,使模块块及其包含的相对路径按预期行为特别有用(如果不是必需的话):
Worker 构造函数
new Worker() 目前只接受 worker 文件的路径。该提案最初旨在让其直接接受 Module 对象(对于 {type: "module"} workers)。目前这被搁置,以支持 Ben Kelly 正在进行的 Blank Worker 提案。
Realm 交互
模块表达式的行为类似于函数表达式:它们捕获声明它们的 Realm。这意味着 Module 对象将始终只被求值一次,即使从多个 realms 导入也是如此:
但是,它们不能闭包捕获模块外部的任何词法作用域变量:这使得很容易克隆它们,并将它们重新附加到不同的 realm。
例如,结合 ShadowRealm 提案,模块表达式可以允许语法上本地的代码在另一个 realm 的上下文中执行:
与 workers 一起使用
离线程调度器的最基本版本是运行一个接收、导入和执行模块块的 worker:
也许也可以将 Module 对象存储在 IndexedDB 中,但这更有争议,因为持久化代码可能存在安全风险。
与 CSP 的集成
内容安全策略(CSP)有两个与模块块相关的控制项
- 关闭
eval,这也关闭了其他解析 JavaScript 的 API。eval默认是禁用的。 - 限制允许用于源的 URL 集合,这也禁用了导入数据 URL。默认情况下,该集合是无限的。
模块已经允许满足无-eval 条件:由于模块是通过 fetch 获取的,因此无论通过 new Worker() 还是 ShadowRealm.prototype.importValue,它们都不会被视为来自 eval。模块表达式遵循这一点:由于它们与周围的 JavaScript 代码一起在语法中解析,因此它们不能成为注入攻击的载体,并且不会受到此条件的阻止。
源列表限制随后应用于模块。模块表达式的语义基本上等同于 data: URL,区别在于它们将始终被视为在源列表中(因为它是已作为脚本加载的资源的一部分)。
优化潜力
希望模块表达式可以像被多次导入的普通模块一样易于优化。例如,一个希望是,在某些引擎中,模块块的字节码只需要生成一次,即使它被结构化克隆并在不同 Realms 中多次重新创建。但是,类型反馈和 JIT 优化代码可能应该为每个重新创建模块块的 Realm 单独维护,否则一个模块的使用会污染另一个模块。
工具支持
模块表达式可以被转译成数据 URL,或者转译成单独文件中的模块。任何一种转换都保持语义不变。
命名模块和打包。
此提案只允许匿名模块块。还有其他针对命名模块_包_(具有与每个 JS 模块的说明符对应的 URL)的提案,包括 module declarations 提案和 Web Bundles。请注意,为了实现广告拦截器,打包存在需要解决的重大隐私问题;请参阅 来自 Brave 的关注。
TC39 第 3 阶段审查员
- Jordan Harband (Coinbase)
- Leo Balter (Salesforce)
- Guy Bedford (OpenJS Foundation)
- Kris Kowal (Agoric)
- Jack Works (Sujitech)
常见问题解答
你能闭包捕获变量吗?你能引用模块表达式外部的值吗?
不能。就像包含 ES 模块的单独文件一样,你只能引用全局作用域和导入其他模块。
你能_静态地_导入其他模块吗?
能。就像使用单独的文件一样。这是完全有效的:
模块表达式能帮助解决捆绑问题吗?
乍一看,模块表达式似乎可以为这样的简单场景提供捆绑格式:
然而,在_一般_情况下,模块需要相互引用。为此,模块表达式需要能够闭包捕获变量,但它们不能:
为了解决捆绑问题,我们正在研究一个单独的 module declarations 提案。使用该提案,上面的代码可以重写为:
Blöcks 怎么样?
Blöcks 已被归档。由于多种原因,模块表达式可能更适合 JavaScript:
- Blöcks 试图引入一种新型函数。两者都意味着你可以闭包捕获/捕获该作用域外部的值。我们试图在 Blöcks 中允许这样做(因为这是预期的),结果证明这是一个棘手的问题。
- 相反,模块已被工具、引擎和开发人员充分探索、规范和理解。我们在 Blöcks 中必须担心的许多问题都通过模块领域的先前工作自然解决(例如,模块只能引用全局作用域并进行导入)。
- 模块已有缓存机制。
什么是 Module ?
模块表达式求值为新的 Module 类的实例,类似于函数表达式求值为 Function 类的实例。
此提案引入的 Module 类非常有限,但 Compartments 提案 正在研究扩展其功能。
模块表达式会被缓存吗?
这取决于“缓存”的含义。模块表达式与对象字面量具有相同的行为。这意味着每次求值模块块时,都会创建一个新的模块块。
但是,Module 对象像任何其他模块一样参与模块映射。因此,每个表达式块只能有一个实例,除非它被结构化克隆。
TypeScript 怎么样?
我们听到了 TypeScript 团队的担忧,即对模块表达式中的全局对象进行类型化可能很困难。不幸的是,这是 TypeScript 更宏观模式的一部分:
众所周知,定义 TypeScript 文件应在何种作用域中执行(主线程 vs worker vs service worker)很困难,这通常通过拥有多个 tsconfig.json 文件和组合项目来解决。在这种情况下,在不同 TS 项目之间共享代码就更难了。
在向 Worker 通信时,您已经需要对 event.data 强制类型,以便为通信通道添加类型。
总而言之,很难判断模块表达式使类型化情况变得更糟或更复杂的程度。
尽管如此,我们正在考虑这个问题,并正在与 TypeScript 团队进行早期讨论,以寻找可能的解决方案,例如使用 TS 语法为模块块的全局对象类型添加注释,例如 module<GlobalInterface> { }
我们真的应该允许使用模块表达式创建 workers 吗?
在我看来:是的。要求 worker 位于单独的文件中是关于 worker 为何难以采用的最突出的反馈之一。这就是为什么许多人诉诸于 Blob URL 或 Data URL,从而带来各种困难,尤其是与路径和 CSP 相关的困难。这里的风险是人们开始不考虑其成本地创建大量 worker,但我认为降低 worker 作为重要性能原语的采用门槛的好处大于风险。我们有正在进行的讨论关于这个话题。
示例
Greenlet
如果您了解 Jason Miller’s 的 Greenlet(或我的 Clooney),那么模块表达式将是此类主线程外调度器库的完美构建块。