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-asset-references.md.
  • 简体中文
  • Asset References S1

    中文标题:资产引用

    提案概览
    提案速览

    该提案引入了 asset 语句,用于创建模块标识的一等引用而无需加载它们,支持延迟加载和外部加载器。提案包括AssetReference对象的语义、外部资源管理等使用场景的动机,以及考虑的替代方案。

    Note

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

    资产引用

    本仓库包含一项提案,旨在添加语法以获取模块标识的一等引用,而无需实际加载或初始化该模块。这也可以扩展到其他资产类型。这是一种访问相对于当前模块的资产的方式。该提案在 2018年11月达到Stage 1

    本提案建立在 动态导入提案 之上。

    语法

    asset Foo from "foo";

    该语法与 import 语句形式类似,但只允许单一标识符形式(不允许解构)。asset 后不允许换行。它允许静态声明一个相对于当前模块的资产的弱依赖。

    该“资产”可以是另一个 ECMAScript 模块。与 import 不同,asset 语句并不实际加载其他模块。它只是一个对它的引用。该引用可以传递给任何动态 import 调用以实际加载它,并异步解析为模块实例。

    async function Bar() {
      let foo = await import(Foo);
    }

    资产不一定非得是 ECMAScript 模块。它也可以被传递给一个外部加载器,该加载器可以将其解析为图像、CSS、字体或程序所需的任何其他资源。

    语义

    asset 语句为标识符的名称创建了一个 const 绑定。模块说明符在概念上会被解析为与在 import 中使用时相同的规范 URL、文件路径或标识。然而,如果在该模块尚未在其他地方加载的话,它不会实际触发该模块的加载或初始化。

    该绑定会被立即初始化为一个空对象,其原型是 AssetReference.prototype。每个模块语句都会创建一个新对象,以避免创建隐式的回传通道。这也允许实现将实际的规范解析推迟到以后进行。例如,在 Node.js 中,规范化可能是一个需要磁盘访问的昂贵操作。

    该对象的内部插槽 [ReferencingModule] 会被设置为当前模块。内部插槽 [AssetSpecifier] 会被设置为静态模块说明符。

    import(AssetReference)

    如果一个资产引用被传递给动态 import() 调用,那么它会使用该资产引用的 [ReferencingModule][AssetSpecifier] 内部插槽来调用 HostImportModuleDynamically

    这允许如果该模块尚未被加载,则可以动态加载它。

    注意:资产引用不必来自与 import() 调用所在的同一模块。这允许第三方模块成为实际发起加载的一方。

    AssetReference

    AssetReference 是一个构造函数,如果被调用则抛出错误。AssetReference.prototype 是一个空对象,其 constructor 字段设置为 AssetReference

    此时它实际上只是一个不透明对象,但将来 ECMA262(甚至可能是宿主环境)可以为其扩展更多方法或字段。

    加载器

    资产引用也可以被传递给由宿主环境定义的模块加载器,以执行更高级的优化。例如同步测试其是否已加载、从缓存条目中清除它等。

    动机

    在这种情况下,特殊语法是必要的,因为说明符是相对于正在执行的模块的,这导致了两个问题:

    • 这不能在库级别构建,因为无法从 import() 调用中暴露相关信息。需要该语法的原因与需要 import() 的原因相同。
    • 如果你想通过传递某些上下文将逻辑外部化到库中,那么将上下文从模块传递到该库必须非常便捷。

    从另一个文件导入

    如果我们只考虑 ECMAScript,而不考虑对宿主环境的额外访问,主要用例是使用库来延迟导入和重新导入的时间。

    资产引用使我们能够从外部库执行导入。

    import ResourceManager from "my-library/resource-manager";
    asset utility from "utility";
    export async function foo() {
      let u = await ResourceManager.load(utility);
      return u.bar();
    }

    这使外部资源管理器负责在涉及 I/O 时发生的复杂交互。例如:

    • 传递额外参数或设置加载环境。
    • 处理 I/O 错误并优雅地回退。
    • 如果第一次失败,则重试获取。
    • 显示一个界面,要求用户连接到 WiFi,并在点击按钮后重试。
    • 根据可用的资源选择一个或多个可能的资源。

    所有这些都可以通过使用动态 import 实现,但由于这些导入局限于本地文件,很多逻辑被提升到调用函数中。一等引用让我们能够为加载行为创建抽象。

    CSS/图片加载

    从 JavaScript 文件引用额外资源,如图片文件、CSS 文件、字体文件,是一种常见的模式。然而,由于没有标准做法,实现方式多种多样。

    Webpack 中,这是通过使用 import 来引用它们,就像它们是 JavaScript 模块一样。它们的导出根据不同的约定定义。例如,CSS 文件会作为副作用插入到 HTML 文档中。图片和字体文件将其 URL 导出为字符串。

    这有一个缺点,即在模块中无法对何时加载单个文件或如何使用它们进行自定义控制。然而,由于没有其他标准方法相对于模块引用文件,我们只能这样。

    通过资产引用,web 平台可以将它们添加到 URL.createObjectURL() 等功能中。这样它们就可以作为文件与任何 web API 一起使用。

    asset Logo from "./logo.gif";
    async function loadLogo() {
      let img = document.createElement("img");
      img.src = URL.createObjectURL(Logo);
      return img;
    }

    这也让库控制图像如何加载,是否先解码,失败时重试,或回退到替代图像。

    asset Logo from "./logo.gif";
    import ResourceManager from "my-library/resource-manager";
    function loadLogo() {
      return ResourceManager.loadImage(Logo);
    }

    require.resolve

    Node.js 有一种 解析 说明符为规范文件路径的机制。

    这后来被打包工具所采用。Webpack 有 require.resolverequire.resolveWeak。然而,在这种情况下,静态打包工具的限制又提出了进一步的要求。

    1. 返回值是不透明的“模块 ID”,而不是文件路径。
    2. 说明符必须定义为内联字面量,以便静态解析依赖图。

    这些 ID 随后用于与运行时模块加载器/缓存交互,例如 require.cache

    即使人们大多转向完全 ECMAScript 兼容的模块方法,这些用例仍然需要这些环境保留本地作用域的 require 小技巧和伪静态字符串解析。

    其他打包工具要么没有这个机制,要么实现方式略有不同。这是一个亟待标准化的缺失部分。

    与加载器或 Realm API 交互

    根据宿主环境的不同,加载器可以向库和框架等用户代码暴露。

    AssetReference 可用于与加载器交互,在其他情况下需要规范化名称时使用。

    它可以用于检查注册表,看模块记录是否已经被获取或初始化。这用于仅当模块已在进行中时才有条件导入。有时用于防止 UI 在确定能同步初始化之前进行初始化。你可以从缓存中同步获取记录,如果已经初始化,这有时是性能上的好处或避免生成临时加载指示器 UI。

    AssetReference 可用于清除缓存中的特定条目,前提是宿主允许。这在编写单元测试时通常很有用。

    它可以用于实现单个模块的热重载,并将其链接到特定的资产资源。例如用于开发模式工具。

    此提案实际上并没有定义加载器允许对资产引用做什么,但我们期望这些提案将利用此提案。

    静态资产说明符

    静态说明符严格来说不是必需的,因为在 web 浏览器中运行时不需为某些东西解析它。然而,即使它不会成为标准,打包工具无论如何都会要求它。要编写可互操作的代码,你需要知道如何编写它,因此要么这是一个伪标准,要么我们可以提供显式语法来明确这一约束。

    打包

    通过提供静态说明符,打包器和打包工具等工具可以知道某个资源将在某个时候被需要,尽管不必急切加载。这意味着它可以用它来替换 URL 为打包工具内部的内容,和/或将其打包到同一个 ECMAScript 文件中、tar 文件中,或通过 HTTP/2 请求流式传输。

    类型化说明符

    如果资产指向一个模块,那么类型系统如 Flow 和 TypeScript 可以将类型与静态说明符关联起来。

    // 类型系统知道该文件将导出签名。例如 {bar: number}
    // 它可以为其指定类型。例如 AssetReference<Module<{bar: number}>>
    asset Foo from "foo.js";
    async function load() {
       // 类型系统现在知道这是错误的:
      let foo: {bar: string} = await import(Foo);
    }

    如果这是一个任意字符串,其含义和可跟踪性就变得不那么精确。

    限制每个模块的资产访问

    处理任意 URL 时,在像 SES 这样的小型沙盒环境中很难限制访问。通过提供一种符合惯例的方式来引用相对于模块的资产,我们提供了一种创建仅由该模块拥有的引用的方式,直到显式传递出去,例如传递给加载库。同一个加载库可以被两个模块使用,而不会让两个模块都能访问同一个资产。

    更改调度优先级

    web 平台中可能有关于调度优先级的提案。在这些情况下,此 API 可以用来更改加载此模块的优先级。

    可能的补充

    资源提示

    我们可能应该允许省略标识符。

    asset "foo";

    这在运行时是一个无操作,但会给静态打包器工具一个提示,即某个资源将被需要,并用其他方式获取或初始化。

    动态资产解析

    主要形式应该是静态形式,它与 import 语句形式类似,是更常见的形式。它为打包器和安全扫描器提供了一种标准语法形式,保证接收静态字符串。它比打包器今天在动态导入基础上发明的非标准半静态要求更容易解释。

    静态形式也可以被浏览器或 web 打包工具用来提前给出提示,预加载进一步的依赖。

    asset Foo from "foo"; // 静态定义的依赖
    async function Bar() {
      await import(Foo); // 延迟加载
    }

    然而,仍然有完全动态解析 URL 有用的场景,对于这些用例,我们可以添加这个额外的语法。

    let assetReference = import.resolve("./foo" + fileExtension);

    替代方案

    模块语法

    本提案的早期版本使用了 module 上下文关键字,并且仅限于资产引用。

    module Foo from "foo";

    这将为嵌套模块的想法提供一些兼容性。

    module "foo" {
      export default function() { };
    }
    
    import Foo from "foo";
    // 或
    import("foo");

    另一种建模方式:

    module Foo {
    
    }

    获取对另一个文件中的嵌套模块的引用:

    module { Foo } from "foo";

    将这些问题混为一谈可能不太好,并且仍然留下了支持除模块之外资产的空白。

    使用字符串而不是 AssetReference 对象

    一个可能的设计是返回一个完全解析的规范路径的字符串,而不是对象。这是 require.resolve 传统上所做的。

    这存在许多问题。它泄露了太多环境实现细节。并非每个环境都会解析为 URL 路径。例如,Webpack 使用生成的 ID。Node.js 使用文件路径(在 Unix/Windows 环境之间也不同)。

    另一个问题是,并非每个环境都能同步解析规范 URL。例如,Node.js 需要搜索文件系统。因此返回值不能保证此时是规范的。

    字符串会诱导手动操作字符串路径。这可能导致一系列安全问题,这些安全相关问题可能允许访问任意路径。

    另一个问题是字符串没有与之关联的生命周期。如果资产有一些与之关联的临时内存表示,则无法在垃圾回收时清理它,因为字符串可以被重新创建。这是 URL.createObjectURL() API 所面临的问题。

    使用 Symbol 而不是 AssetReference 对象

    我们可以使用 Symbol 而不是字符串或对象。这不会遭受字符串的大多数问题。它必须是新的 Symbol,以避免同步规范化它。

    这存在一些垃圾回收语义不明确的问题。Symbol 不应需要垃圾回收语义。

    这也意味着我们将来无法在这些对象上放置实例字段、属性或原型方法。这似乎不符合 JavaScript 的习惯。

    import.meta

    即使在今天,你也可以通过将 import.meta 传递给库函数来进行相对解析,因为宿主实现可能会暴露 url 字段。

    import {load} from "library";
    load(import.meta, "../relative/path");

    这还不够,因为无法访问宿主加载器的实际解析算法。

    之前一个想法是在宿主环境中暴露 import.meta 上的 resolve

    let Foo = import.meta.resolve("foo");

    该命名空间由宿主环境定义,没有可移植的方式来表达这个依赖。

    这两种解决方案都没有鼓励以静态方式定义实际依赖,以便打包器可以使用。

    备选语法

    如果语法不允许上下文关键字 asset,我们可能可以使用 import asset Foo from "foo"; 代替。这是最坏的情况。人们会不惜使用自定义加载器和伪语法来避免像这样频繁使用的不必要的长语法。当出现不同的伪语法时,这可能会破坏这个功能的价值。

    使用场景

    Node.js 资源加载

    我们希望此机制可用于加载和返回资源而不将其加载到 JS 内存中,而是留在 C++ 实现中进行流管理。

    可能的 API:

    asset Image from "myimage.jpg";
    
    export handleRequest(request) {
      request.respondWith(new Response(Image));
    }

    React 模块加载

    在 React 中,当前使用组件的方式是将其同步导入父组件。然后父组件将此引用传递给 createElement 来使用该组件。

    import {createElement} from "react";
    import MyOtherComponent from "other-component";
    
    function MyComponent() {
      return createElement(MyOtherComponent, {
        some: "data"
      });
    }

    在 React 中,我们将使用此机制让 UI 库控制其他组件的加载时机。

    推荐的编写 React 组件的方式是声明资产引用而不是直接导入它:

    import {createElement} from "react";
    asset MyOtherComponent from "other-component";
    
    function MyComponent() {
      return createElement(MyOtherComponent, {
        some: "data"
      });
    }

    这样,UI 库可以自由选择何时加载组件,控制其调度,传递全局配置选项(如认证/取消),如果可用则同步解析,清除其缓存条目,失败时重试等。

    目前使用标准能做到的最佳方式是:

    import {createElement, createWrapper} from "react";
    const MyOtherComponent = createWrapper(() => import("other-component"));
    
    function MyComponent() {
      return createElement(MyOtherComponent, {
        some: "data"
      });
    }

    然而,这存在许多问题。字符串不能保证是静态的。虽然它确实允许框架控制加载和重新加载,但它不提供与底层缓存互操作或同步解析现有模块的方式。它也不提供添加认证或其他选项的方式。

    如果我们最终为 import 添加第二个参数,可能我们可以将一些配置从库转发到 import,但这最终会导致更多样板代码。

    const MyOtherComponent = createWrapper(options => import("other-component", options));

    对于常见的导入操作来说,这是大量的样板代码,而且仍然无法提供所有所需的功能。

    使用现代工具时,不幸的是简单地回退到非标准的 require.resolve 是更好的选择。

    const MyOtherComponent = require.resolve("other-component");

    Deno 资源缓存

    远程第三方模块使用 URL 导入,并在编译时缓存到文件系统中。然而,这些模块不能将非代码资产包含在依赖图中。此类资产必须在运行时下载。这损害了启动性能,并要求赋予程序 网络权限,或者需要额外的构建步骤来执行这些操作。

    本提案允许远程模块声明对相对定位的非代码资产的静态依赖,这意味着它们可以像 JS/TS 模块一样在编译时获取和缓存。

    // 这是一个远程模块 https://deno.land/x/foo@0.1.0/mod.ts。
    // 它依赖于 https://deno.land/x/foo@0.1.0/plugins/foo.so。
    
    asset fooPlugin from "./plugins/foo.so";
    
    Deno.openPlugin(fooPlugin);
    
    export function foo(): void {
      Deno.core.dispatch(Deno.core.ops()["op_foo"], argUi8);
    }