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/proposal-module-expressions.md.
  • 简体中文
  • Module Expressions S2

    中文标题:模块表达式

    提案概览
    提案速览

    该提案引入了模块表达式,这是一种新的语法,可求值为 Module 对象,旨在解决跨 realm 共享代码的挑战,例如在 Web Workers 或 Worklets 中。它提供了一种内联定义模块的方法,可以异步导入、结构化克隆,并与 Worklets 和 Worker 构造函数等 HTML API 集成。

    Note

    以下 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 被认为位于不同的主机上。

    import greenlet from 'greenlet'
    
    const API_KEY = "...";
    
    let getName = greenlet(async username => {
      // 看起来 API_KEY 在此闭包中可访问,但由于 greenlet 的工作方式,它并不是。
      let url = `https://api.github.com/users/${username}?key=${API_KEY}`
      let res = await fetch(url)
      let profile = await res.json()
      return profile.name
    });

    此外,任何从单独文件加载代码的 API 都难以被采用(参见 Web Workers 或 CSS Painting API),即使使用它们有显著的好处。强制开发人员将代码放入单独的文件不仅是一个经常被引用的主要 DX 障碍,而且在捆绑器(其主要目的是将尽可能多的代码放入一个文件)的时代尤其困难。

    任何希望使用这些 API 之一的库还面临另一个额外的挑战。如果您将库发布到 npm,并且用户希望通过像 unpkg.com 这样的 CDN 使用它,那么单独的文件现在来自不同的源。即使有正确的 CORS 头,源也将保持不同,这会影响路径以及作为结果的二级资源的解析方式(如果它们能被解析的话)。

    还有一个长期存在的问题是,JavaScript 无法以一种可以跨 realms 共享的方式来表示“任务”,而不必处理_至少_上述问题之一。这阻止了任何尝试构建超越主线程的 web 调度器(类似 GCD),而这是调度器的主要可用性优势之一。

    模块表达式旨在通过向语言及其与 HTML 标准的集成中引入一个侵入性最小的补充,来显著改善这种情况。

    高级概述

    模块表达式是模块内容的语法:它们求值为一个 Module 对象。

    let mod = module {
      export let y = 1;
    };
    let moduleExports = await import(mod);
    assert(moduleExports.y === 1);
    
    assert(await import(mod) === moduleExports);  // 缓存在模块映射中

    导入 Module 对象需要是异步的,因为 Module 对象可能从网络导入其他模块。Module 对象可以被多次导入,但会被缓存在模块映射中,并返回对同一模块命名空间的引用。

    Module 对象只能通过动态 import() 导入,而不能通过 import 语句导入,因为无法使用说明符字符串来寻址它们。

    相对导入语句将相对于_外部_模块的路径进行解析。这在从不同文件或不同 realms 导入 Module 对象时尤其重要。

    语法细节

    PrimaryExpression :  ModuleExpression
    
    ModuleExpression : `module` [no LineTerminator here] `{` ModuleBody? `}`

    由于 module 不是 JavaScript 中的关键字,因此 module 之后不允许换行。

    HTML 集成

    (HTML 集成正在进行中,见 此 PR。)

    HTML 规范中有 4 个主要的 Module 对象集成点:

    Worklets

    Worklets(例如 CSS Painting API 或 Audio Worklet)使用 addModule() 模式将单独的文件加载到 Worklet 上下文中:

    CSS.paintWorklet.addModule("./my-paint-worklet.js");

    该提案旨在类似于 Worker 构造函数调整 addModule,使其接受 Module 对象。

    结构化克隆

    Module 对象是可结构化克隆的,允许它们通过 postMessage()(发送到 Workers、ServiceWorkers 甚至其他窗口)发送。

    import.meta.url

    import.meta 继承自模块块_语法上_所在的模块。这对于在模块块跨 realms 共享(例如发送到 worker)时,使模块块及其包含的相对路径按预期行为特别有用(如果不是必需的话):

    // main.js
    const mod = module {
    	export async function main(url) {
    		return import.meta.url;
    	}
    }
    const worker = new Worker("./module-executor.js");
    worker.postMessage(mod);
    worker.onmessage = ({data}) => assert(data == import.meta.url);
    
    // module-executor.js
    addEventListener("message", async ({data}) => {
    	const {main} = await import(data);
    	postMessage(await main());
    });

    Worker 构造函数

    new Worker() 目前只接受 worker 文件的路径。该提案最初旨在让其直接接受 Module 对象(对于 {type: "module"} workers)。目前这被搁置,以支持 Ben Kelly 正在进行的 Blank Worker 提案。

    Realm 交互

    模块表达式的行为类似于函数表达式:它们捕获声明它们的 Realm。这意味着 Module 对象将始终只被求值一次,即使从多个 realms 导入也是如此:

    let mod = module { export let x = true; };
    
    let ns = await import(module);
    let ns2 = globalThisFromDifferentRealm.eval("m => import(m)")(module);
    
    assert(ns === ns2);

    但是,它们不能闭包捕获模块外部的任何词法作用域变量:这使得很容易克隆它们,并将它们重新附加到不同的 realm。

    例如,结合 ShadowRealm 提案,模块表达式可以允许语法上本地的代码在另一个 realm 的上下文中执行:

    globalThis.flag = true;
    
    let mod = module {
      export let hasFlag = !!globalThis.flag;
    };
    
    let m = await import(mod);
    assert(m.hasFlag === true);
    
    let realm = new ShadowRealm();
    let realmHasFlag = await r1.importValue(mod, "hasFlag");
    assert(realmHasFlag === false);

    与 workers 一起使用

    离线程调度器的最基本版本是运行一个接收、导入和执行模块块的 worker:

    let workerModule = module {
      onmessage = async function({data}) {
        let mod = await import(data);
        postMessage(mod.default());
      }
    };
    
    let worker = new Worker({type: "module"});
    worker.addModule(workerModule);
    worker.onmessage = ({data}) => alert(data);
    worker.postMessage(module { export default function() { return "hello!" } });

    也许也可以将 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 模块的单独文件一样,你只能引用全局作用域和导入其他模块。

    你能_静态地_导入其他模块吗?

    能。就像使用单独的文件一样。这是完全有效的:

    const m = module {
      import myApiWrapper from "./api-wrapper.js";
    
      await someTopLevelAwaitLogic();
    }

    模块表达式能帮助解决捆绑问题吗?

    乍一看,模块表达式似乎可以为这样的简单场景提供捆绑格式:

    const countModule = module {
      let i = 0;
    
      export function count() {
        i++;
        return i;
      }
    };
    
    const uppercaseModule = module {
      export function uppercase(string) {
        return string.toUpperCase();
      }
    };
    
    const { count } = await import(countModule);
    const { uppercase } = await import(uppercaseModule);
    
    console.log(count()); // 1
    console.log(uppercase("daniel")); // "DANIEL"

    然而,在_一般_情况下,模块需要相互引用。为此,模块表达式需要能够闭包捕获变量,但它们不能:

    const countModule = module {
      let i = 0;
    
      export function count() {
        i++;
        return i;
      }
    };
    
    const uppercaseModule = module {
      export function uppercase(string) {
        return string.toUpperCase();
      }
    };
    
    const combinedModule = module {
      const { count } = await import(countModule);
      const { uppercase } = await import(uppercaseModule);
    
      console.log(count()); // 1
      console.log(uppercase("daniel")); // "DANIEL"
    };
    
    // ReferenceError,因为我们无法闭包捕获 countModule 或 uppercaseModule!

    为了解决捆绑问题,我们正在研究一个单独的 module declarations 提案。使用该提案,上面的代码可以重写为:

    module countModule {
      let i = 0;
    
      export function count() {
        i++;
        return i;
      }
    }
    
    module uppercaseModule {
      export function uppercase(string) {
        return string.toUpperCase();
      }
    }
    
    module combinedModule {
      import { count } from countModule;
      import { uppercase } from uppercaseModule;
    
      console.log(count()); // 1
      console.log(uppercase("daniel")); // "DANIEL"
    }

    Blöcks 怎么样?

    Blöcks 已被归档。由于多种原因,模块表达式可能更适合 JavaScript:

    • Blöcks 试图引入一种新型函数。两者都意味着你可以闭包捕获/捕获该作用域外部的值。我们试图在 Blöcks 中允许这样做(因为这是预期的),结果证明这是一个棘手的问题。
    • 相反,模块已被工具、引擎和开发人员充分探索、规范和理解。我们在 Blöcks 中必须担心的许多问题都通过模块领域的先前工作自然解决(例如,模块只能引用全局作用域并进行导入)。
    • 模块已有缓存机制。

    什么是 Module ?

    模块表达式求值为新的 Module 类的实例,类似于函数表达式求值为 Function 类的实例。

    此提案引入的 Module 类非常有限,但 Compartments 提案 正在研究扩展其功能。

    模块表达式会被缓存吗?

    这取决于“缓存”的含义。模块表达式与对象字面量具有相同的行为。这意味着每次求值模块块时,都会创建一个新的模块块。

    const arr = new Array(2);
    for(let i = 0; i < 2; i++) {
      arr[i] = module {};
    }
    console.assert(arr[0] !== arr[1]);
    console.assert(await import(arr[0]) !== await import(arr[1]));

    但是,Module 对象像任何其他模块一样参与模块映射。因此,每个表达式块只能有一个实例,除非它被结构化克隆。

    const m1 = module{};
    const m2 = m1;
    console.assert(await import(m1) === await import(m2));

    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),那么模块表达式将是此类主线程外调度器库的完美构建块。

    import greenlet from "new-greenlet";
    
    const func =
      greenlet(
        module {
          export default async function (endpoint, token) {
            const response = await fetch(endpoint, {headers: {"Authorization": `Bearer ${token}`}});
            const json = await response.json();
            /* ... 对 json 进行更昂贵的处理 ... */
            return json;
          }
        }
      );
    const result = await func("/api", "secretToken");
    使用 Worker 池实现 `new-greenlet`
    // new-greenlet
    
    // 这是每个 worker 中运行的代码。它通过 postMessage 接受
    // 一个模块块作为要运行的异步“任务”。
    const workerModule = module {
      addEventListener("message", async ev => {
        const {args, module} = ev.data;
        const {default: task} = await import(module);
        const result = await task(...args);
        postMessage(result);
      });
    };
    
    // 将被分配一个将 worker 放回池中的函数
    let releaseWorker;
    
    // 池本身使用流实现为队列。
    // 流实现了池中最困难的部分;将推与拉解耦。
    // 即在没有可用 worker 时返回一个请求的 promise,
    // 并在没有等待的请求时存储返回的 worker。
    const workerQueue = new ReadableStream(
      {
        workersCreated: 0,
        start(controller) {
          releaseWorker = w => controller.enqueue(w);
        },
        // 仅当有人请求 worker 且队列中没有 worker 时,
        // 才会调用 `pull()`。我们将其用作创建新 worker 的信号,
        // 前提是我们低于阈值。
        // `pull()` 的返回值实际上无关紧要,它只是一个我们
        // _可能_用于入队的信号。如果我们不这样做,promise 将保持未决状态,
        // 并在下次调用 `controller.enqueue()` 时 resolve。
        async pull(controller) {
          if (this.workersCreated >= navigator.hardwareConcurrency) {
            return;
          }
          controller.enqueue(
            new Worker({type: "module", name: `worker${this.workersCreated}`})
              .addModule(workerModule)
          );
          this.workersCreated++;
        },
      },
      {highWaterMark: 0}
    ).getReader();
    
    // 返回一个可用 worker 的 promise。如果池中没有 worker,
    // 它将等待一个。
    function getWorker() {
      return workerQueue.read().then(({ value }) => value);
    }
    
    export default function greenlet(args, module) {
      return function(...args) {
        return new Promise((resolve) => {
          const worker = await getWorker();
          worker.postMessage({ args, module });
          worker.addEventListener(
            "message",
            (ev) => {
              const result = ev.data;
              resolve(result);
              releaseWorker(worker);
            },
            { once: true }
          );
        });
      };
    }