For AI agents: the complete documentation index is available at /tc39-atlas/llms.txt, the full documentation bundle is available at /tc39-atlas/llms-full.txt, and this page is available as Markdown at /tc39-atlas/proposals/year/pending/proposal-defer-import-eval.md.
  • 简体中文
  • Deferring Module Evaluation S3

    中文标题:延迟模块求值

    提案概览
    提案速览

    该提案引入了一种新的导入语法:import defer * as ns from "module",它会加载模块及其依赖项,但不会立即对其求值。当访问返回的命名空间对象的属性时,会触发同步的按需求值。这可以在应用程序初始化期间避免不必要的 CPU 工作,而无需强制调用方采用异步 API 或更改现有模块 API。

    Note

    以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。

    延迟模块求值

    之前称为“惰性模块初始化”

    状态

    推动者:Nicolò Ribaudo

    作者:Yulia Startsev、Nicolò Ribaudo 和 Guy Bedford

    阶段:3

    幻灯片:

    背景

    JS 应用程序可能会变得非常大,以至于不仅加载,甚至执行它们的初始化脚本也会产生显著的性能成本。通常,这种情况发生在应用程序生命周期的后期——通常需要进行侵入式修改才能使其更高效。

    加载性能是一个重要且关键的改进领域,涉及用于避免瀑布式请求的预加载技术,以及用于惰性加载模块的动态 import()

    但即使使用这些技术解决了加载性能问题,执行性能仍然存在开销——由于代码本身的编写方式,初始化期间会出现 CPU 瓶颈。

    动机

    避免不必要的执行是 Node.js CommonJS 模块系统中众所周知的优化手段,在加载争用和执行争用之间的差距较小。Node.js 应用程序中的常见模式是重构代码,以便按需动态 require:

    const operation = require('operation');
    
    exports.doSomething = function (target) {
      return operation(target);
    }

    为了性能优化被重写为:

    exports.doSomething = function (target) {
      const operation = require('operation');
      return operation(target);
    }

    使用者仍然获得相同的 API,但在初始化期间可以更高效地使用文件系统和 CPU。

    对于 ES 模块,我们通过动态 import() 解决了这个问题中的惰性加载部分。

    对于同样的示例,我们可以编写:

    export async function doSomething (target) {
      const { operation } = await import('operations');
      return operation(target);
    }

    这避免了在应用程序初始化期间使网络和 CPU 成为瓶颈,但这种技术仍然存在一些问题:

    1. 它实际上并没有解决延迟执行的问题,因为在这种场景下发送网络请求通常会导致性能回归而非改进。因此,为了实现高效的延迟执行,同时避免触发请求瀑布,仍然需要单独的网络预加载步骤。

    2. 它强制所有函数及其调用者进入异步编程模型,而不一定反映程序的真实意图。这导致所有调用点都必须更新到新模型,而且如果不破坏现有 API 使用者的 API,就无法做到这一点。

    问题陈述

    延迟模块的同步求值可能是一种理想的新原语,可以避免应用程序初始化期间不必要的 CPU 工作,而无需从模块 API 使用者的角度进行任何更改。

    动态导入并不能很好地解决这个问题,因为它通常必须与预加载步骤结合使用,并强制所有函数不必要地异步化,而没有提供仅延迟同步求值工作的能力。

    提案

    该提案引入一种新的语法形式的导入,它只会返回一个命名空间外来对象。使用时,模块及其依赖项不会被执行,但会被完整加载到“可执行就绪”的状态,然后才认为模块图已加载完成。

    仅在访问此模块的属性时,才会执行(如果需要)求值操作。

    这样,模块命名空间外来对象就像模块求值的代理,实际上具有 [[Get]] 行为,在返回定义的绑定之前触发同步求值。

    API 将使用以下语法,遵循由 源阶段导入 提案建立的阶段模型:

    // or with a custom keyword:
    import defer * as yNamespace from "y";

    语义

    这些导入仍会参与深度图加载,以便在执行之前将它们完全填充到模块缓存中,然而该导入的模块将不会被求值。

    当访问结果模块命名空间对象的属性时,如果尚未执行,则会为该模块发起一次新的顶层执行。

    这样,延迟模块求值导入就充当执行图中一个新的顶层执行节点,就像动态导入一样,只不过它是同步执行的。

    目前正在考虑可能的扩展,例如延迟再导出,但它们未包含在当前版本的提案中。

    顶层 await

    对延迟模块命名空间对象的属性访问必须是同步的,因此不可能延迟使用顶层 await 的模块的求值。当使用 import defer 语法导入模块时,其异步依赖项以及它们自身的传递依赖项会被立即求值,只有图中的同步部分会被延迟。

    考虑以下示例,其中 a 是顶层入口点:

    // a
    import "b";
    import defer * as c from "c"
    
    setTimeout(() => {
      c.value
    }, 1000);
    // b
    // c
    import "d"
    import "f"
    export let value = 2;
    // d
    import "e"
    await 0;
    // e
    // f

    由于 d 使用了顶层 await,d 及其依赖项不能被延迟:

    • 初始求值将执行 beda
    • 之后,访问 c.value 将触发 fc 的执行。

    粗略草图

    如果将模块加载和初始化的组成部分拆分出来,我们可以大致勾勒出预期的语义:

    ⚠️ 以下示例未考虑循环依赖

    // LazyModuleLoader.js
    async function loadModuleAndDependencies(name) {
      const loadedModule = await import.load(`./${name}.js`); // load is async, and needs to be awaited
      const parsedModule = loadedModule.parse();
      await Promise.all(parsedModule.imports.map(loadModuleAndDependencies)); // load all dependencies
      return parsedModule;
    }
    
    async function executeAsyncSubgraphs(module) {
      if (module.hasTLA) return module.evaluate();
      return Promise.all(module.importedModules.map(executeAsyncSubgraphs));
    }
    
    export default async function lazyModule(object, name) {
      const module = await loadModuleAndDependencies(name);
      await executeAsyncSubgraphs(module);
      Object.defineProperty(object, name, {
        get: function() {
          delete object[name];
          const value = module.evaluateSync();
          Object.defineProperty(object, name, {
            value,
            writable: true,
            configurable: true,
            enumerable: true,
          });
          return value;
        },
        configurable: true,
        enumerable: true,
      });
    
      return object;
    }
    
    // myModule.js
    import foo from "./bar";
    
    etc.
    
    // module.js
    import LazyModule from "./LazyModuleLoader";
    await LazyModule(globalThis, "myModule");
    
    function Foo() {
      myModule.doWork() // first use
    }

    实现

    问答

    直接惰性绑定发生了什么?

    该提案的初始版本包含通过命名导出进行延迟求值的直接绑定访问:

    import { feature } from './lib' with { lazyInit: true }
    
    export function doSomething (param) {
      return feature(param);
    }

    其中延迟求值只会在 访问 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 中,我们能做些什么来近似这种行为?

    我们能得到的最接近的近似如下:

    // moduleWrapper.js
    export default function ModuleWrapper(object, name, lambda) {
      Object.defineProperty(object, name, {
        get: function() {
          // Redefine this accessor property as a data property.
          // Delete it first, to rule out "too much recursion" in case object is
          // a proxy whose defineProperty handler might unwittingly trigger this
          // getter again.
          delete object[name];
          const value = lambda.apply(object);
          Object.defineProperty(object, name, {
            value,
            writable: true,
            configurable: true,
            enumerable: true,
          });
          return value;
        },
        configurable: true,
        enumerable: true,
      });
      return object;
    }
    
    // module.js
    import ModuleWrapper from "./ModuleWrapper";
    // any imports would need to be wrapped as well
    
    function MyModule() {
     // ... all of the work of the module
    }
    
    export default ModuleWrapper({}, "MyModule", MyModule);
    
    // parent.js
    import wrappedModule from "./module";
    
    function Foo() {
      wrappedModule.MyModule.bar() // first use
    }

    然而,这种解决方案并未涵盖延迟加载惰性图子模块,也无法达到我们期望的特性。

    为什么 import defer * 给出的命名空间对象与 import * 不同?

    对于_已经求值_并在求值期间抛出错误的模块,其模块命名空间对象在属性访问时不会重新抛出错误:

    // module-that-throws1
    import * as self from 'module-that-throws1';
    globalThis.ns1 = self;
    export let a = 1;
    throw new Error("oops");
    // main1.js
    import("module-that-throws1").finally(() => {
      console.log(globalThis.ns1.a); // Doesn't throw, logs '1'
    });

    延迟命名空间对象则不同。如果模块在求值期间抛出错误,deferredNamespace.foo 将总是抛出该求值错误:

    // module-that-throws2
    export let a = 1;
    throw new Error("oops");
    // main2.js
    import defer * as ns2 from 'module-that-throws2';
    
    try { ns2.a } catch (e) { console.log(e.message) } // logs "oops"

    在本提案之前,访问在求值期间抛错的模块的命名空间对象是极其罕见的。然而,随着 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 })?

    我们选择使用“导入修饰符”而不是属性,原因有二:

    1. 导入属性会影响模块_是什么_,但不能改变 ECMAScript 模块行为的基本语义:它们类似于向导入的 URL 添加查询参数,只不过属性由运行环境而非服务器处理。例如,with { type: "json" } 的行为就像是导入的模块是一个包装了 export default JSON.parse(` ... 文件内容 ... `); 的 JavaScript 文件。import defer 改变了命名空间对象的行为(使它们具有副作用,而在此提案之前,对命名空间对象的属性访问不能触发任何副作用):它不能被表达为包装/修改过的“经典”ECMAScript 模块。
    2. 源阶段导入提案 一起,我们正在公开模块加载的多个“阶段”。我们确定的阶段是:根据指定符解析模块、获取模块(这两者都发生在宿主中,而不是在 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" 都保证加载相同的模块,并且无论该模块临时暂停在哪个“阶段”(然后从该阶段继续),它最多只会被执行一次。