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

    中文标题:模块表达式

    提案概览
    提案速览

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

    Note

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

    模块表达式

    模块表达式(以前称为“模块块”)是 SurmaDaniel EhrenbergNicolò Ribaudo 的工作。这是大量合作和先前工作的成果,最著名的是 Daniel EhrenbergJustin FagnaniInline Modules 提案以及 DomenicSurmaBlöcks 提案。

    问题空间

    每当开发人员尝试在 JavaScript 中使用多线程时——无论是 Web Workers、Service Workers、像 CSS Paint API 这样的 Worklets,甚至其他窗口——他们都会遇到一些问题。JavaScript 固有的单线程设计阻止了内存的共享(SharedArrayBuffer 除外),从而导致函数和代码的共享受阻。今天,在 JavaScript 中,“在另一个线程中运行此函数”的典型范例只能通过变通方法实现,而这些变通方法本身带有显著的缺点。

    将这种模式引入 JavaScript 的库(例如 ParllelJSGreenlet)会诉诸于函数的字符串化,以便能够将代码从一个 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 APIAudio 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’sGreenlet(或我的 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 }
          );
        });
      };
    }