• 开始日期: 2018-01-08
  • RFC PR: (请留空)
  • 跟踪问题: (请留空)

摘要

添加 #[wasm_bindgen] 的功能,用于处理、加载和处理对本地 JS 文件的依赖关系。

  • 现在可以使用 module 属性显式导入文件

    
    # #![allow(unused_variables)]
    #fn main() {
    #[wasm_bindgen(module = "/js/foo.js")]
    extern "C" {
        // ...
    }
    #}
  • 现在可以使用 inline_js 属性内联导入 JS 模块

    
    # #![allow(unused_variables)]
    #fn main() {
    #[wasm_bindgen(inline_js = "export function foo() {}")]
    extern "C" {
        fn foo();
    }
    #}
  • --browser 标志被重新用于为浏览器生成 ES 模块,而 --no-modules 已被弃用,取而代之的是此标志。

  • --nodejs 不会立即支持本地 JS 代码段,但将在将来支持。

动机

wasm-bindgen 的目标是实现 Rust 和 JS 之间的轻松互操作。虽然编写自定义 Rust 代码非常容易,但实际上编写自定义 JS 并将其与 #[wasm_bindgen] 关联起来非常困难(请参阅 rustwasm/wasm-bindgen#224)。#[wasm_bindgen] 属性目前仅支持从 ES 模块导入函数,但即使这样,支持也受到限制,并且只是假设 ES 模块字符串存在于最终的应用程序构建步骤中。

目前,没有一种可组合的方式可以让一个 crate 拥有它构建的一些辅助 JS,这些 JS 最终会无缝地包含到最终构建的应用程序中。例如,rand crate 无法轻松地包含本地 JS(也许是为了检测它应该使用哪种随机性 API),除非对最终的工件施加严格的要求。

从自定义 JS 文件进行导入的符合人体工程学支持似乎也是像 stdweb 这样的框架构建类似 js! 的宏所必需的。这涉及在编译时生成 JS 代码段,这些代码段需要包含到最终的包中,而这正是此新属性旨在实现的功能。

利益相关者

此 RFC 的一些主要利益相关者是

  • #[wasm_bindgen] 的用户
  • 希望为其 crate 添加 wasm 支持的 crate 作者。
  • stdweb 作者
  • 捆绑器 (webpack) 和 wasm-bindgen 集成人员。

这里的大多数人都会被抄送至 RFC,并且随时欢迎联系更多人!

详细说明

此提案涉及许多移动部件,所有这些部件都旨在协同工作,为将本地 JS 文件包含到最终的 #[wasm_bindgen] 工件中提供一个简化的故事。我们将在下面逐个查看每个部分。

新的语法功能

这里提出的最面向用户的更改是对 #[wasm_bindgen]module 属性的重新解释以及 inline_js 属性的添加。现在可以使用它们来导入本地文件并定义本地导入,如下所示


# #![allow(unused_variables)]
#fn main() {
#[wasm_bindgen(module = "/js/foo.js")]
extern "C" {
    // ... definitions
}

#[wasm_bindgen(inline_js = "export function foo() {}")]
extern "C" {
    fn foo();
}
#}

第一个声明表示函数和类型块等都从 /js/foo.js 文件导入,该文件相对于当前文件,并以 crate 根目录为根目录。第二个声明将 JS 内联列为字符串文字,而 extern 块描述了内联模块的导出。

建议使用以下规则来解释 module 属性。

  • 如果字符串以指向 cargo 构建目录的绝对路径的平台特定表示形式开头(由 $OUT_DIR 标识),则该字符串将被解释为输出目录中的文件路径。这适用于在构建过程中生成 JS 文件的构建脚本。

  • 如果字符串以 /./../ 开头,则它被视为指向本地文件的路径。如果不是,则它将按原样作为 ES 模块导入传递。

  • 所有路径都相对于当前文件解析,就像 Rust 自己的 #[path]include_str! 等一样。但是,目前尚不清楚我们如何实际对相对文件执行此操作。因此,所有路径都必须以 / 开头。当 proc_macro 具有稳定的 API(或者我们以其他方式弄清楚如何操作)时,我们可以开始允许以 ./../ 为前缀的路径。

这有望大致匹配程序员的期望以及浏览器和捆绑器中已有的约定。

inline_js 属性并非真正用于通用开发,而是用于过程宏的一种方式,这些过程宏目前无法依赖 $OUT_DIR 的存在来生成要导入的 JS。

导入的 JS 格式

所有导入的 JS 都必须使用 ES 模块语法编写。最初,JS 必须手动编写,不能进行后处理。例如,JS 不能使用 TypeScript 编写,也不能由 Babel 或类似工具编译。

例如,一个库可能包含


# #![allow(unused_variables)]
#fn main() {
// src/lib.rs
#[wasm_bindgen(module = "/js/foo.js")]
extern "C" {
    fn call_js();
}
#}

伴随

// js/foo.js

export function call_js() {
    // ...
}

请注意,js/foo.js 使用 ES 模块语法导出 call_js 函数。当从 Rust 调用 call_js 时,它将调用 foo.js 中的 call_js 函数。

通过依赖关系传播

file 属性的目的是与依赖关系无缝协作。使用 #[wasm_bindgen] 构建项目时,你不应该需要知道你的依赖关系是否使用本地 JS 代码段!

#[wasm_bindgen] 宏将在编译时读取提供的文件的内容(如果有)。此文件将以 wasm-bindgen 特定的格式序列化到 wasm-bindgen 自定义部分中。由 rustc 生成的最终 wasm 工件将在其自定义部分中包含所有引用的 JS 文件内容。

wasm-bindgen CLI 工具将提取所有这些 JS 并将其写入文件系统。生成的 wasm 文件(或 wasm-bindgen 生成的 shim JS 文件)将使用相对导入导入所有发出的 JS 文件。

更新 wasm-bindgen 输出模式

wasm-bindgen 今天有几种输出生成模式。这些输出模式主要围绕模块与非模块以及模块的定义方式展开。此 RFC 建议我们更多地从这种模式转向环境,例如与 node.js 兼容的代码与与浏览器兼容的代码(这不仅仅涉及模块格式)。这意味着,在环境支持多个模块系统或模块系统是可选的(浏览器支持 es 模块和非模块)的情况下,wasm-bindgen 将选择它认为最适合的模块系统,只要它与该环境兼容即可。

wasm-bindgen 的当前输出模式是

  • 默认 - 默认情况下,wasm-bindgen 发出的输出假设 wasm 模块本身是 ES 模块。这将自然地与本身是 ES 模块的自定义 JS 代码段协同工作,因为它们只是本地输出目录中找到的图中的更多模块。此输出模式目前仅可由 Webpack 等捆绑器使用,默认输出无法在 Web 浏览器或 Node.js 中加载。

  • --no-modules - wasm-bindgen--no-modules 标志与 ES 模块不兼容,因为它旨在通过 <script> 标签包含,而 <script> 标签不是模块。此模式与今天一样,如果上游 crate 包含本地 JS 代码段,则将无法正常工作。

  • --nodejs - wasm-bindgen 的此标志指示输出应针对 Node.js 定制,特别是使用 CommonJS 模块约定。在此模式下,wasm-bindgen 最终将使用 Rust 中的 JS 解析器将本地导入的 JS 模块的 ES 语法重写为 CommonJS 语法。

  • --browser - 目前,此标志与默认输出模式相同,只是输出针对浏览器环境略微定制(例如,假设 TextEncoder 是环境可用的)。

    此 RFC 建议重新利用此标志(将其破坏),改为生成一个可以在 Web 浏览器中本地加载的 ES 模块,但除此之外,它与今天 --no-modules 的接口类似,如下所述。

此 RFC 建议重新考虑这些输出模式,如下所示

目标环境CLI 标志模块格式用户体验如何加载本地 JS 代码段?
没有捆绑器的 Node.js --nodejs Common.js require() 主 JS 粘合文件主 JS 粘合文件 require() crate 的本地 JS 代码段。
没有捆绑器的 Web --browser ES 模块 指向主 JS 粘合文件的 <script>,使用 type=module import 语句会导致对 crate 的本地代码段的额外网络请求。
有捆绑器的 WebES 模块 指向主 JS 粘合文件的 <script>Bundler 将 crate 的本地代码片段链接到主 JS 粘合文件中。除了 wasm 模块本身之外,没有额外的网络请求。

值得注意的是,就 wasm-bindgen 而言,使用和不使用 bundler 的浏览器几乎相同:唯一的区别是,如果我们假设使用 bundler,我们可以依赖 bundler 为我们提供 wasm-as-ES-module 的 polyfill。请注意,这里的 --browser 今天相对来说有很大的不同,因此将是一个重大更改。人们认为 --browser 的使用量很小,我们可以摆脱这种改变,但欢迎对此提出反馈!

--no-modules 标志不再适用,因为 --browser 用例旨在取代它。请注意,此 RFC 提案目前只让面向 bundler 和面向浏览器的模式支持本地 JS 代码片段,同时为最终在 Node.js 中支持本地 JS 代码片段铺平道路。--no-modules 最终也可以像 Node.js 一样支持(一旦我们解析 JS 文件并重写导出内容),但这里建议从 --no-modules 迁移到 --browser

--browser 输出目前被认为导出一个初始化函数,该函数在被调用后,返回的 promise 被解析(就像今天的 --no-modules 一样),将导致所有导出内容在被调用时生效。在 promise 解析之前,所有导出内容在被调用时都会抛出错误。

依赖其他 JS 文件的 JS 文件

此 RFC 的一个棘手之处是,当本地 JS 代码片段依赖于其他 JS 文件时。例如,您的 JS 代码可能如下所示

// js/foo.js

import { foo } from '@some/npm-package';
import { bar } from './bar.js'

// ...

如上所述,这些导入将无法正常工作。我们打算明确说明这是此设计的一个初始限制。我们目前还不支持 JS 代码片段之间的导入,但我们最终应该能够做到。

从长远来看,为了支持 --nodejs,我们需要某种程度的 JS ES 模块解析器。一旦我们可以解析导入本身,#[wasm_bindgen] 在扩展期间就可以相对轻松地加载传递性包含的文件。例如,在上面的文件中,我们将 ./bar.js 包含到 wasm 自定义部分中。在这个未来的世界中,我们只需要在最终输出工件被发出时重写 ./bar.js(如果有必要)。此外,随着 wasm-packwasm-bindgen 中对 NPM 包的支持(未来的目标),我们可以验证 package.json 中的条目是否与找到的导入相匹配。

访问 wasm 内存/表

与 wasm 模块交互的 JS 代码片段通常需要与与 wasm 模块关联的 WebAssembly.MemoryWebAssembly.Table 实例一起工作。此 RFC 提案使用 wasm 本身来传递这些对象,如下所示


# #![allow(unused_variables)]
#fn main() {
// lib.rs

#[wasm_bindgen(module = "/js/local-snippet.js")]
extern {
    fn take_u8_slice(memory: &JsValue, ptr: u32, len: u32);
}

#[wasm_bindgen]
pub fn call_local_snippet() {
    let vec = vec![0,1,2,3,4];
    let mem = wasm_bindgen::memory();
    take_u8_slice(&mem, vec.as_ptr() as usize as u32, vec.len() as u32);
}
#}
// js/local-snippet.js

export function take_u8_slice(memory, ptr, len) {
    let slice = new UInt8Array(memory.arrayBuffer, ptr, len);
    // ...
}

这里使用 wasm_bindgen::memory() 现有的内在函数将内存对象传递到导入的 JS 代码片段中。为了镜像这一点,我们将在 wasm-bindgen crate 中添加 wasm_bindgen::function_table() 作为内在函数,以访问函数表并将其作为 JsValue 返回。

最终,我们可能需要一种更明确的方式来导入内存/表,但目前,这对于表达能力来说应该足够了。

缺点

  • 最初的 RFC 非常保守。它不适用于 --nodejs--no-modules。此外,它最初也不支持 JS 代码片段导入其他 JS。请注意,所有这些都打算在将来得到支持,只是认为现在可能需要比我们现在需要的更多设计。

  • JS 代码片段必须用纯 ES 模块 JS 语法编写。常见的预处理器,如 TypeScript,无法使用。目前尚不清楚如何导入这种预处理的 JS。希望 JS 代码片段足够小,这不会成为太大问题。较大的 JS 代码片段始终可以提取到 NPM 包中并在那里进行后处理。请注意,作者始终可以手动运行 TypeScript 编译器来处理这些用例。

  • 建议弃用相对流行的 --no-modules 标志,转而使用 --browser 标志,该标志本身将相对于今天有一个重大更改。不过,人们认为 --browser 很少使用,因此可以安全地进行更改,并且人们还认为我们希望避免破坏今天 --no-modules 的现状。

  • 本地 JS 代码片段需要用 ES 模块语法编写。这可能是一个有点主观的立场,但它旨在让 wasm-bindgen 更容易添加未来的功能,同时继续与 JS 协同工作。然而,ES 模块系统是整个生态系统中唯一已知的官方标准,因此希望这是一个编写本地 JS 代码片段的明确选择。

基本原理和替代方案

此系统的首要替代方案是类似于 stdweb 中的 js! 宏。这允许在 Rust 代码中直接编写小的 JS 代码片段,然后 wasm-bindgen 将拥有生成适当垫片的知识。此 RFC 提案识别 module 路径而不是这种方法,因为它被认为是一种更通用的方法。此外,它打算 js! 宏可以建立在 module 指令上,包括本地文件路径。wasm-bindgen crate 有一天可能会增长一个类似 js! 的宏,但人们认为最好从更保守的方法开始。

ES 模块的一个替代方案是简单地将所有 JS 代码串联在一起。这样,我们就不必解析任何东西,而是将所有内容都放到一个文件中。然而,这种方法的缺点是,它很容易导致命名空间冲突,并且它还迫使每个人都同意模块格式,并且存在迫使最终产品采用模块格式的风险。

在 wasm-bindgen 时间发出小文件的另一种替代方案是在运行时通过将它们留在 wasm 可执行文件的自定义部分中来解压缩所有文件。然而,这反过来可能会违反某些 CSP 设置(特别是严格的设置)。

未解决的问题

  • 是否有必要在最初支持 --nodejs

  • 是否有必要在最初支持本地 JS 代码片段中的本地 JS 导入?

  • 今天是否有已知的 JS ES 模块解析器?我们是否被迫包含一个完整的 JS 解析器,或者我们可以使用一个只处理 ES 语法的最小解析器?

  • 我们如何处理想要被最终 wasm 文件引用的其他资产,如 CSS、HTML 或图像?