从 JavaScript 到 Rust 再回来:一个 wasm-bindgen 故事

最近,我们见证了 WebAssembly 的 惊人的编译速度加速 JS 库 以及 生成更小的二进制文件。我们甚至 制定了高级计划,旨在增强 Rust 和 JavaScript 社区以及其他 Web 编程语言之间的互操作性。正如那篇 之前文章 所述,我想深入探讨一个具体组件的细节,即 wasm-bindgen

如今,WebAssembly 规范 仅定义了四种类型:两种整数类型和两种浮点数类型。然而,大多数情况下,JS 和 Rust 开发人员都在使用更丰富的类型!例如,JS 开发人员经常与 document 进行交互以添加或修改 HTML 节点,而 Rust 开发人员则使用诸如 Result 之类的类型来处理错误,几乎所有程序员都使用字符串。

仅仅局限于 WebAssembly 当前提供的类型将过于限制,而这正是 wasm-bindgen 存在的意义。wasm-bindgen 的目标是为 JS 和 Rust 类型提供桥梁。它允许 JS 使用字符串调用 Rust API,或者 Rust 函数捕获 JS 异常。wasm-bindgen 消除了 WebAssembly 和 JavaScript 之间的阻抗不匹配,确保 JavaScript 可以高效且无样板地调用 WebAssembly 函数,而 WebAssembly 也可以对 JavaScript 函数执行同样的操作。

wasm-bindgen 项目在其 README 中有更多描述。为了开始学习,让我们深入研究一个使用 wasm-bindgen 的示例,然后探索它还能提供什么。

你好,世界!

这是一个经典的例子,学习新工具的最佳方法之一是探索其等效于打印“Hello, World!”的功能。在本例中,我们将探索一个 示例,它正是这样做的——在页面上显示“Hello, World!”的警报框。

这里目标很简单,我们想定义一个 Rust 函数,该函数在给定名称的情况下,将在页面上创建一个对话框,显示 Hello, ${name}!。在 JavaScript 中,我们可能将此函数定义为

export function greet(name) {
    alert(`Hello, ${name}!`);
}

不过,本示例的注意事项是,我们希望用 Rust 来编写它。这里已经有很多我们必须处理的步骤

  • JavaScript 将调用 WebAssembly 模块,即 greet 导出。
  • Rust 函数将接受一个字符串作为输入,即我们要问候的 name
  • Rust 内部将使用插值创建一个包含该名称的新字符串。
  • 最后,Rust 将使用它创建的字符串调用 JavaScript 的 alert 函数。

首先,我们创建一个全新的 Rust 项目

$ cargo new wasm-greet --lib

这将在一个新的 wasm-greet 文件夹中进行初始化,我们将在该文件夹中工作。接下来,我们将使用以下信息修改我们的 Cargo.toml(Rust 中相当于 package.json 的文件)

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"

我们暂时忽略 [lib] 部分,但下一部分声明了对 wasm-bindgen crate 的依赖关系。这里包含了我们在 Rust 中使用 wasm-bindgen 所需的所有支持。

接下来,该编写一些代码了!我们将用以下内容替换自动生成的 src/lib.rs

#![feature(proc_macro, wasm_custom_section, wasm_import_module)]

extern crate wasm_bindgen;

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern {
    fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet(name: &str) {
    alert(&format!("Hello, {}!", name));
}

如果你不熟悉 Rust,这可能看起来有点啰嗦,但别担心!wasm-bindgen 项目一直在不断改进,将来肯定不会总是需要这些。最需要注意的部分是 #[wasm_bindgen] 属性,这是一个 Rust 代码中的注释,这里的意思是“请根据需要用包装器来处理”。我们的 alert 函数导入和 greet 函数导出都使用此属性进行了注释。过一会儿,我们将看到幕后发生了什么。

但首先,让我们在浏览器中打开它!让我们编译我们的 wasm 代码

$ rustup target add wasm32-unknown-unknown --toolchain nightly # only needed once
$ cargo +nightly build --target wasm32-unknown-unknown

这将在 target/wasm32-unknown-unknown/debug/wasm_greet.wasm 中生成一个 wasm 文件。如果我们使用诸如 wasm2wat 之类的工具查看该 wasm 文件的内容,可能会有点吓人。事实证明,这个 wasm 文件实际上还没有准备好被 JS 使用!相反,我们需要再执行一个步骤才能使其可用

$ cargo install wasm-bindgen-cli # only needed once
$ wasm-bindgen target/wasm32-unknown-unknown/debug/wasm_greet.wasm --out-dir .

这一步是很多魔法发生的地方:wasm-bindgen CLI 工具会后处理输入的 wasm 文件,使其适合使用。稍后我们将看到“适合”的含义,现在只需知道,如果我们导入新创建的 wasm_greet.js 文件(由 wasm-bindgen 工具创建),我们就可以获得在 Rust 中定义的 greet 函数。

最后,我们需要做的就是用一个打包器将其打包,并创建一个 HTML 页面来运行我们的代码。在撰写本文时,只有 Webpack 4.0 版本 具有足够的 WebAssembly 支持才能开箱即用(尽管它也有一个 Chrome 注意事项)。随着时间的推移,其他打包器也会纷纷效仿。这里我将跳过细节,但你可以按照 Github 仓库中的 示例 配置来操作。如果我们查看内部内容,我们的页面 JS 代码如下

const rust = import("./wasm_greet");
rust.then(m => m.greet("World!"));

…就这样!打开我们的网页,现在应该会显示一个由 Rust 驱动的漂亮的“Hello, World!”对话框。

wasm-bindgen 的工作原理

哇,这可真是一段复杂的“Hello, World!”。让我们深入了解一下幕后发生了什么以及工具是如何工作的。

wasm-bindgen 最重要的方面之一是,它的集成本质上是建立在 wasm 模块只是另一种 ES 模块的概念之上的。例如,在上面的代码中,我们想要一个带有以下签名(在 Typescript 中)的 ES 模块

export function greet(s: string);

WebAssembly 无法原生实现这一点(请记住它目前只支持数字),因此我们依赖 wasm-bindgen 来填补空白。在上面的最后一步中,当我们运行 wasm-bindgen 工具时,你会注意到除了 wasm_greet_bg.wasm 文件之外,还生成了一个 wasm_greet.js 文件。前者是实际的 JS 接口,它执行必要的粘合代码来调用 Rust。*_bg.wasm 文件包含实际的实现和所有编译的代码。

当我们导入 ./wasm_greet 模块时,我们得到了 Rust 代码想要暴露的内容,但目前无法原生实现。现在我们已经了解了集成的运作方式,让我们跟踪脚本的执行过程,看看发生了什么。首先,我们的示例运行了

const rust = import("./wasm_greet");
rust.then(m => m.greet("World!"));

这里我们异步导入我们想要的接口,等待它被解析(通过下载和编译 wasm)。然后,我们调用模块上的 greet 函数。

注意:这里需要异步加载 Webpack 当前的要求,但这种情况可能不会永远存在,而且对于其他打包器来说可能不是必需的。

如果我们看一下 wasm-bindgen 工具生成的 wasm_greet.js 文件,我们会看到类似以下的内容

import * as wasm from './wasm_greet_bg';

// ...

export function greet(arg0) {
    const [ptr0, len0] = passStringToWasm(arg0);
    try {
        const ret = wasm.greet(ptr0, len0);
        return ret;
    } finally {
        wasm.__wbindgen_free(ptr0, len0);
    }
}

export function __wbg_f_alert_alert_n(ptr0, len0) {
    // ...
}

注意:请记住,这是未经优化的生成代码,可能并不美观或小巧!在用 LTO(链接时优化)和 Rust 中的发布版本进行编译,并在经过 JS 打包器管道(压缩)后,它应该会小得多。

这里我们可以看到 wasm-bindgen 为我们生成了 greet 函数。幕后,它仍然在调用 wasm 的 greet 函数,但它使用指针和长度而不是字符串来调用。有关 passStringToWasm 的更多细节,请查看 Lin Clark 的之前文章。除了 wasm-bindgen 工具为我们处理了这些内容之外,所有这些都是你必须自己编写的样板代码!稍后我们会谈到 __wbg_f_alert_alert_n 函数。

深入一层,下一个感兴趣的内容是 WebAssembly 中的 greet 函数。要查看它,让我们看看 Rust 编译器看到了什么代码。请注意,与上面生成的 JS 包装器类似,你在这里没有编写 greet 导出符号,而是 #[wasm_bindgen] 属性会生成一个垫片,它为你进行转换,即

pub fn greet(name: &str) {
    alert(&format!("Hello, {}!", name));
}

#[export_name = "greet"]
pub extern fn __wasm_bindgen_generated_greet(arg0_ptr: *mut u8, arg0_len: usize) {
    let arg0 = unsafe { ::std::slice::from_raw_parts(arg0_ptr as *const u8, arg0_len) }
    let arg0 = unsafe { ::std::str::from_utf8_unchecked(arg0) };
    greet(arg0);
}

这里我们可以看到我们的原始代码 greet,但 #[wasm_bingen] 属性插入了这个奇怪的函数 __wasm_bindgen_generated_greet。这是一个导出的函数(用 #[export_name]extern 关键字指定),它接受 JS 传递的指针/长度对。它在内部将指针/长度转换为 &str(Rust 中的字符串),并转发给我们定义的 greet 函数。

换句话说,#[wasm_bindgen] 属性会生成两个包装器:一个在 JavaScript 中,用于将 JS 类型转换为 wasm 类型,另一个在 Rust 中,用于接收 wasm 类型并将其转换为 Rust 类型。

好的,让我们看一下最后一组包装器,即 alert 函数。Rust 中的 greet 函数使用标准的 format! 宏创建一个新字符串,然后将其传递给 alert。请记住,当我们声明 alert 函数时,我们使用 #[wasm_bindgen] 声明了它,所以让我们看看 rustc 为此函数看到了什么

fn alert(s: &str) {
    #[wasm_import_module = "__wbindgen_placeholder__"]
    extern {
        fn __wbg_f_alert_alert_n(s_ptr: *const u8, s_len: usize);
    }
    unsafe {
        let s_ptr = s.as_ptr();
        let s_len = s.len();
        __wbg_f_alert_alert_n(s_ptr, s_len);
    }
}

现在,这不太是我们写的,但我们多少可以看出它是如何组合在一起的。alert 函数实际上是一个薄包装器,它接受 Rust 的 &str,然后将其转换为 wasm 类型(数字)。这会调用我们上面看到的 __wbg_f_alert_alert_n 函数,但有一个奇怪的地方是 #[wasm_import_module] 属性。

在 WebAssembly 中,所有函数的导入都来自特定的模块,由于 `wasm-bindgen` 基于 ES 模块构建,所以这也会被解释为 ES 模块的导入!虽然 `__wbindgen_placeholder__` 模块并不实际存在,但它表明该导入将由 `wasm-bindgen` 工具重写,以便从我们生成的 JS 文件中导入。

最后,我们来看一下生成的 JS 文件,它包含:

export function __wbg_f_alert_alert_n(ptr0, len0) {
    let arg0 = getStringFromWasm(ptr0, len0);
    alert(arg0)
}

哇!原来幕后发生了很多事情,从 JS 中的 `greet` 到浏览器中的 `alert`,经历了一条相对较长的链路。不过不用担心,`wasm-bindgen` 的关键之处在于它隐藏了所有这些基础设施!你只需要编写带有少量 `#[wasm_bindgen]` 注解的 Rust 代码。然后你的 JS 可以像使用另一个 JS 包或模块一样使用 Rust。

`wasm-bindgen` 还能做什么呢?

`wasm-bindgen` 项目的范围很广,我们在这里没有足够的时间详细介绍所有内容。探索 `wasm-bindgen` 功能的最佳方法是查看 [示例目录](https://github.com/alexcrichton/wasm-bindgen/tree/master/examples),其中包含从像上面我们看到的 [Hello, World!](https://github.com/alexcrichton/wasm-bindgen/tree/master/examples/hello_world) 到 [完全用 Rust 操作 DOM 节点](https://github.com/alexcrichton/wasm-bindgen/tree/master/examples/dom) 的各种示例。

从高层次来看,`wasm-bindgen` 的功能包括:

  • 导入 JS 结构、函数、对象等,并在 wasm 中调用它们。你可以在结构体上调用 JS 方法并访问属性,这使得你编写的 Rust 代码在所有 `#[wasm_bindgen]` 注解都关联起来后,会有一种“原生”的感觉。

  • 将 Rust 结构体和函数导出到 JS。这样,JS 不再局限于只使用数字,而是可以导出一个 Rust `struct`,它会在 JS 中变成一个 `class`。然后,你就可以传递结构体,而不必仅仅传递整数。[smorgasboard](https://github.com/alexcrichton/wasm-bindgen/tree/master/examples/smorgasboard) 示例很好地展示了支持的互操作性。

  • 允许其他杂项功能,例如从全局范围(即 `alert` 函数)导入,使用 Rust 中的 `Result` 捕获 JS 异常,以及一种模拟在 Rust 程序中存储 JS 值的通用方法。

如果你想了解更多功能,请关注 [问题追踪器](https://github.com/alexcrichton/wasm-bindgen/issues)!

`wasm-bindgen` 的下一步计划是什么?

在我们结束之前,我想花一点时间描述一下 `wasm-bindgen` 的未来愿景,因为我认为这是该项目当前最激动人心的方面之一。

支持不仅仅是 Rust

从第一天起,`wasm-bindgen` CLI 工具就被设计为支持多种语言。虽然 Rust 是目前唯一支持的语言,但该工具被设计为也可以接入 C 或 C++。`#[wasm_bindgen]` 属性会在输出的 `*.wasm` 文件中创建一个自定义部分,`wasm-bindgen` 工具会解析并随后移除这个部分。这个部分描述了要生成哪些 JS 绑定以及它们的接口。这个描述与 Rust 无关,所以 C++ 编译器插件也可以轻松创建这个部分并让 `wasm-bindgen` 工具进行处理。

我发现这个方面尤其令人兴奋,因为我认为它可以让像 `wasm-bindgen` 这样的工具成为 WebAssembly 和 JS 集成的标准实践。希望它能对所有编译到 WebAssembly 的语言都有益,并能被捆绑器自动识别,从而避免上面几乎所有配置和构建工具的需求。

自动绑定 JS 生态系统

如今,使用 `#[wasm_bindgen]` 宏导入功能时,其中一个缺点是你必须手动编写所有内容,并确保没有错误。这有时会是一个单调乏味(且容易出错)的过程,非常适合自动化。

所有 Web API 都是用 [WebIDL](https://heycam.github.io/webidl/) 指定的,从 [WebIDL 生成 `#[wasm_bindgen]` 注解](https://github.com/alexcrichton/wasm-bindgen/issues/42) 应该相当可行。这意味着你将不再需要像上面那样定义 `alert` 函数,而只需要编写类似以下内容:

#[wasm_bindgen]
pub fn greet(s: &str) {
    webapi::alert(&format!("Hello, {}!", s));
}

在这种情况下,`webapi` crate 可以完全从 Web API 的 WebIDL 描述中自动生成,从而保证没有错误。

我们甚至可以更进一步,利用 TypeScript 社区的出色工作,[从 TypeScript 生成 `#[wasm_bindgen]` 注解](https://github.com/alexcrichton/wasm-bindgen/issues/18)。这将允许自动免费绑定 npm 上所有具有 TypeScript 的包!

比 JS DOM 更快的性能

最后,但并非最不重要的,在 `wasm-bindgen` 的视野中:超快的 DOM 操作——许多 JS 框架的圣杯。如今,对 DOM 函数的调用必须通过代价高昂的垫片,因为它们需要从 JavaScript 转换为 C++ 引擎实现。然而,有了 WebAssembly,这些垫片将不再需要。众所周知,WebAssembly 类型良好……而且,它确实有类型!

`wasm-bindgen` 代码生成从一开始就被设计为与未来的 [主机绑定提案](https://github.com/WebAssembly/host-bindings) 相兼容。一旦它成为 WebAssembly 中的功能,我们就可以直接调用导入的函数,而无需任何 `wasm-bindgen` 的 JS 垫片。此外,这将使 JS 引擎能够积极优化 WebAssembly 操作 DOM,因为调用类型良好,不再需要 JS 调用所需的参数验证检查。到那时,`wasm-bindgen` 不仅可以简化对更丰富类型(如字符串)的操作,而且还会提供最佳的 DOM 操作性能。

总结

我个人发现 WebAssembly 令人难以置信地令人兴奋,不仅因为社区,还因为快速取得的巨大进步。`wasm-bindgen` 工具拥有光明的未来。它正在让 JS 和 Rust 等语言之间的互操作性成为一流的体验,同时还为 WebAssembly 的持续发展提供长期益处。

尝试使用 [wasm-bindgen](https://github.com/alexcrichton/wasm-bindgen) 试试,[打开一个 issue](https://github.com/alexcrichton/wasm-bindgen/issues) 来提出功能需求,并以其他方式 [参与](http://fitzgeraldnick.com/2018/02/27/wasm-domain-working-group.html) Rust 和 WebAssembly 的发展!

关于 Alex Crichton

Alex 是 Rust 核心团队的成员,自 2012 年底开始参与 Rust 的开发。如今,他正在帮助 WebAssembly Rust 工作组让 Rust+Wasm 的体验尽可能好。Alex 还帮助维护 Cargo(Rust 的包管理器)、Rust 标准库以及 Rust 的发布和 CI 基础设施。

Alex Crichton 的更多文章…


4 条评论

  1. Jim Duey

    这很酷。我很好奇你如何设想这与 C/C++ 协同工作?某种宏?编译器黑客?

    2018 年 4 月 10 日 凌晨 1:49

  2. KyuWoo Choi

    不错的文章。这篇文章让我对 Rust 和 WASM 感兴趣。
    我写了一个韩语翻译版本。
    如果您允许,我会留下翻译链接。

    韩语翻译:https://bit.ly/2EDuX5g

    2018 年 4 月 11 日 凌晨 3:55

  3. Honghe

    似乎新发布的 1.0 版本(草案,最后更新于 2018 年 4 月 11 日)引入了 4 种类型?

    * 字节
    * 整数
    * 浮点数
    * 名称

    2018 年 4 月 18 日 下午 7:38

  4. Serhii

    您好,我在使用 wasm-bindgen 时遇到问题。当我尝试构建“cargo +nightly build –target wasm32-unknown-unknown”时,出现以下错误:编译 proc-macro2 v0.3.6
    error[E0433]:无法解析。在 `proc_macro` 中找不到 `Group`。
    完整跟踪:https://codepen.io/SerhiiBIlyk/pen/WJQbzJ?editors=1010(在 HTML 部分)

    2018 年 4 月 21 日 凌晨 6:57

本文评论已关闭。