Deferring Module Evaluation S3
中文标题:延迟模块求值
- 阶段: Stage 3
- 状态: 进行中
- ECMAScript 版本: —
- 同步时间: 2026年8月26日
- English original · 官方仓库
该提案引入了一种新的导入语法:import defer * as ns from "module",它会加载模块及其依赖项,但不会立即对其求值。当访问返回的命名空间对象的属性时,会触发同步的按需求值。这可以在应用程序初始化期间避免不必要的 CPU 工作,而无需强制调用方采用异步 API 或更改现有模块 API。
以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。
延迟模块求值
之前称为“惰性模块初始化”
状态
推动者:Nicolò Ribaudo
作者:Yulia Startsev、Nicolò Ribaudo 和 Guy Bedford
阶段:3
幻灯片:
- 2021-01 - 第 1 阶段(会议记录)
- 2022-11 - 再次尝试:延迟模块求值(会议记录)
- 2023-07 - 用于阶段 2 的延迟导入求值(会议记录)
- 2024-04 - 用于阶段 2.7 的延迟导入求值
- 2024-06 - 用于阶段 2.7(第二次)的延迟导入求值
- 2025-01 - 用于阶段 3 的
import defer
背景
JS 应用程序可能会变得非常大,以至于不仅加载,甚至执行它们的初始化脚本也会产生显著的性能成本。通常,这种情况发生在应用程序生命周期的后期——通常需要进行侵入式修改才能使其更高效。
加载性能是一个重要且关键的改进领域,涉及用于避免瀑布式请求的预加载技术,以及用于惰性加载模块的动态 import()。
但即使使用这些技术解决了加载性能问题,执行性能仍然存在开销——由于代码本身的编写方式,初始化期间会出现 CPU 瓶颈。
动机
避免不必要的执行是 Node.js CommonJS 模块系统中众所周知的优化手段,在加载争用和执行争用之间的差距较小。Node.js 应用程序中的常见模式是重构代码,以便按需动态 require:
为了性能优化被重写为:
使用者仍然获得相同的 API,但在初始化期间可以更高效地使用文件系统和 CPU。
对于 ES 模块,我们通过动态 import() 解决了这个问题中的惰性加载部分。
对于同样的示例,我们可以编写:
这避免了在应用程序初始化期间使网络和 CPU 成为瓶颈,但这种技术仍然存在一些问题:
-
它实际上并没有解决延迟执行的问题,因为在这种场景下发送网络请求通常会导致性能回归而非改进。因此,为了实现高效的延迟执行,同时避免触发请求瀑布,仍然需要单独的网络预加载步骤。
-
它强制所有函数及其调用者进入异步编程模型,而不一定反映程序的真实意图。这导致所有调用点都必须更新到新模型,而且如果不破坏现有 API 使用者的 API,就无法做到这一点。
问题陈述
延迟模块的同步求值可能是一种理想的新原语,可以避免应用程序初始化期间不必要的 CPU 工作,而无需从模块 API 使用者的角度进行任何更改。
动态导入并不能很好地解决这个问题,因为它通常必须与预加载步骤结合使用,并强制所有函数不必要地异步化,而没有提供仅延迟同步求值工作的能力。
提案
该提案引入一种新的语法形式的导入,它只会返回一个命名空间外来对象。使用时,模块及其依赖项不会被执行,但会被完整加载到“可执行就绪”的状态,然后才认为模块图已加载完成。
仅在访问此模块的属性时,才会执行(如果需要)求值操作。
这样,模块命名空间外来对象就像模块求值的代理,实际上具有 [[Get]] 行为,在返回定义的绑定之前触发同步求值。
API 将使用以下语法,遵循由 源阶段导入 提案建立的阶段模型:
语义
这些导入仍会参与深度图加载,以便在执行之前将它们完全填充到模块缓存中,然而该导入的模块将不会被求值。
当访问结果模块命名空间对象的属性时,如果尚未执行,则会为该模块发起一次新的顶层执行。
这样,延迟模块求值导入就充当执行图中一个新的顶层执行节点,就像动态导入一样,只不过它是同步执行的。
目前正在考虑可能的扩展,例如延迟再导出,但它们未包含在当前版本的提案中。
顶层 await
对延迟模块命名空间对象的属性访问必须是同步的,因此不可能延迟使用顶层 await 的模块的求值。当使用 import defer 语法导入模块时,其异步依赖项以及它们自身的传递依赖项会被立即求值,只有图中的同步部分会被延迟。
考虑以下示例,其中 a 是顶层入口点:
由于 d 使用了顶层 await,d 及其依赖项不能被延迟:
- 初始求值将执行
b、e、d和a。 - 之后,访问
c.value将触发f和c的执行。
粗略草图
如果将模块加载和初始化的组成部分拆分出来,我们可以大致勾勒出预期的语义:
⚠️ 以下示例未考虑循环依赖
实现
- engine262: https://github.com/nicolo-ribaudo/engine262/tree/defer-eval
- webpack: https://github.com/webpack/webpack/pull/16567
- Babel: https://babeljs.io/docs/babel-plugin-proposal-import-defer
问答
直接惰性绑定发生了什么?
该提案的初始版本包含通过命名导出进行延迟求值的直接绑定访问:
其中延迟求值只会在 访问 feature 绑定时发生。
这种方法存在许多复杂性,因为它在语言中引入了一种新型的执行点,需要仔细研究。
这种方法仍可能在该提案或其扩展中以各种方式进行研究,但通过首先关注模块命名空间外来对象方法,可以保持语义简单且符合标准 JS 技术。
当加载才是瓶颈时,优化执行真的有好处吗?
虽然在 Web 上加载时间确实是最主要的因素,但重要的是要考虑到许多大型应用在初始化主应用图时可能会阻塞 CPU 大约 100 毫秒。
数秒级别的加载时间常常成为性能优化工作的焦点,这当然是一个重要的问题空间,但当网络问题解决后,在初始化期间释放主事件循环的问题仍然是一个关键问题,对于大型应用来说,目前还没有简单的解决方案。
其他语言中是否有先例?
这些编程语言的标准库包含相关功能:
- Ruby 的
autoload,与require相对,后者与 JS 的import工作方式相同 - Clojure 的
import - 大多数 LISP 环境
我们的方法与 Emacs Lisp 的方法非常相似,并且通过对数十亿 Stack Overflow 帖子的手动分析可以清楚看出,这对普通开发者来说是最直接的方式。
为什么不在 ModuleInstance 上支持同步求值 API
在模块表达式和 compartments 的 ModuleInstance 对象上提供同步求值 API,可以为模块的同步求值提供 API,这可能与这种延迟求值方法兼容,但只有为该用例提供清晰的语法解决方案,才能跨依赖边界和打包器得到支持,从而将避免不必要初始化工作的全部好处带给更广泛的 JS 生态系统。
在当前 JS 中,我们能做些什么来近似这种行为?
我们能得到的最接近的近似如下:
然而,这种解决方案并未涵盖延迟加载惰性图子模块,也无法达到我们期望的特性。
为什么 import defer * 给出的命名空间对象与 import * 不同?
对于_已经求值_并在求值期间抛出错误的模块,其模块命名空间对象在属性访问时不会重新抛出错误:
延迟命名空间对象则不同。如果模块在求值期间抛出错误,deferredNamespace.foo 将总是抛出该求值错误:
在本提案之前,访问在求值期间抛错的模块的命名空间对象是极其罕见的。然而,随着 import defer 声明,这种情况变得更加常见。main2.js 中的 import defer 如果只在 module-that-throws2 已经被其他东西加载时才报错,则会出现竞态条件;相反,错误将总是延迟到访问该命名空间对象时才抛出。由于 ns2.a 必须抛出错误,即使 module-that-throws2 已经求值,因此它不能是与 import * 相同的命名空间对象。
我们考虑过(并否决了)另一种方法,即总是抑制命名空间属性访问时的求值错误,这样在上面的示例中,ns2.a 将保证_永不_抛出错误,因此不会受到可能已经触发 module-that-throws 求值的无关模块的影响。
为什么不复用导入属性(import * as ns from "mod" with { defer: true })?
我们选择使用“导入修饰符”而不是属性,原因有二:
- 导入属性会影响模块_是什么_,但不能改变 ECMAScript 模块行为的基本语义:它们类似于向导入的 URL 添加查询参数,只不过属性由运行环境而非服务器处理。例如,
with { type: "json" }的行为就像是导入的模块是一个包装了export default JSON.parse(` ... 文件内容 ... `);的 JavaScript 文件。import defer改变了命名空间对象的行为(使它们具有副作用,而在此提案之前,对命名空间对象的属性访问不能触发任何副作用):它不能被表达为包装/修改过的“经典”ECMAScript 模块。 - 与 源阶段导入提案 一起,我们正在公开模块加载的多个“阶段”。我们确定的阶段是:根据指定符解析模块、获取模块(这两者都发生在宿主中,而不是在 ECMA-262 中)、将模块附加到其执行和解析上下文、将模块相互链接,最后执行它们。我们使用
import修饰符来表示处理到其中一个阶段为止的模块,而不必一直走到完成执行。这些修饰符比导入属性提供了更多保证:虽然import "x" with { attr1: "val" }和import "x" with { attr2: "val2" }可能是两个完全不同的模块,但import source s from "x"、import defer * as ns from "x"和import "x"都保证加载相同的模块,并且无论该模块临时暂停在哪个“阶段”(然后从该阶段继续),它最多只会被执行一次。