浅尝 Rust 玩转 WebAssembly & JS

avatar
FE @字节跳动

本文从实际小 demo 入手,从 rust 如何编译 wasm,rust 利用 wasmtime 加载并运行 wasm,再到 rust + webAssembly for JS,帮助读者浅尝三者之间的交互能力,对它们有初步理解。

Rust 编译 webAssembly

Rust 直接支持将 webAssembly 作为编译目标,目前常用有两种 wasm32-wasiwasm32-unknow-unknow;其中目标 wasm32-wasi 目标更严格,符合 wasi 标准库。

cargo new hello
cd hello
// build wasm
 cargo build --target wasm32-wasi

通过以上命令,可快速构建出 target/wasm32-wasi/debug/hello.wasm文件,为了在非浏览器环境运行 webAssemlby 文件,可通过 Bytecode Alliance 推出的 wasmtime runtime 来运行 wasm。

instance wasmtime

curl https://wasmtime.dev/install.sh -sSf | bash

安装成功 wasmtime cli 后,通过 wasmtime -h 查看使用方式,可执行以下命令:

为了方便测试,也可使用 cargo wasi,执行 cargo wasi run 命令运行 wasm 文件。

首先,安装一下 cargo-wasi,如果安装失败,可升级 rust 最新版本再重新下载即可:

cargo install cargo-wasi

Rust 使用 webAssembly

对于一个 wasm 文件在 rust 中如何使用呢?比如对于这样的 log.wat:

// log.wat
(module
  (import "" "log" (func $log (param i32)))
  (import "" "double" (func $double (param i32) (result i32)))
  (func (export "run")
    i32.const 0
    call $log
    i32.const 1
    call $log
    i32.const 2
    call $double
    call $log
  )
)

要在 rust 程序中运行它的话,cargo.toml需添加 wasmtime crate 依赖;利用它来加载并编译出wasm 模块,将得到 wasm 导出函数并执行。

我们可直接新建 examples 文件夹下添加 log.rs 文件:

use std::error::Error;
use wasmtime::*;

struct Log {
    integers_logged: Vec<u32>,
}
fn main() -> Result<(), Box<dyn Error>> {
    // Compiling module...
    let engine = Engine::default();
    let module = Module::from_file(&engine, "examples/log.wat")?;
    // import func_wrap
    let mut linker = Linker::new(&engine);
    linker.func_wrap("", "double", |param: i32| param * 2)?;

    linker.func_wrap("", "log", |mut caller: Caller<'_, Log>, param: u32| {
        print!("log: {:}\n", param);
        caller.data_mut().integers_logged.push(param);
    })?;

    let data = Log {
        integers_logged: Vec::new(),
    };
    // create instance
    let mut store = Store::new(&engine, data);
    let instance = linker.instantiate(&mut store, &module)?;
    // get exports
    let run = instance.get_typed_func::<(), (), _>(&mut store, "run")?;
    // calling export
    run.call(&mut store, ())?;
    print!("logged integers:{:?}\n", store.data().integers_logged);
    Ok(())
}

接着,运行 cargo run --example log, 得到运行结果正确:

Rust + WebAssembly for JS

我们知道 rust 可以很容易编译成 webAssembly,那是否可以直接在 JS 中使用呢?webAssembly 与 js 又是如何完成交互,最终能否实现 js 可调用 rust 函数,rust 反过来可调用 js 函数吗?

首先,可以先了解一下 webAssembly js 之间是如何传递多种类型值的。

目前因为 webAssembly 只支持两种类型: 整数 和 浮点数,如果 webAssembly 需要将不同类型的值导入或导出函数,比如将字符串传入到 wasm 中,必须要经过一系列的转换步骤:

  1. 在 JS 端,需要把字符串编码成数字(使用 TextEncoder API)

  1. 将这些数字放入到 webAssembly 线性内存中,得到一串数字数组

  1. 将字符串第一个字母的数组索引传递给 WebAssembly 函数

  1. 在 webAssembly 侧,利用该索引指针提取数字

以上只是字符串类型的处理步骤,面对更复杂的类型需要更繁杂的胶水代码逻辑处理两者之间的数据类型转换,但是,我们可以使用wasm_bindgen 来避免编写这些胶水代码,通过它将自动创建所需的代码(在双方)以使更复杂的类型工作。

Rust 导出函数到 JS

使用 wasm_bindgen 可以将 rust 导出到 JS 的胶水代码,为了方便本地运行,还需要使用 wasm-pack 一站式构建和使用 rust 生成的 WebAssembly 。

举个例子,快速创建一个 greet lib

// create lib
cargo new greet --lib
cd greet
// crago.toml add wasm-bindgen dep and set crate-type = cdylib
[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"

src/lib.rs 文件中,添加如下代码:

use wasm_bindgen::prelude::*;
// Link to or import external code.
#[wasm_bindgen]
extern "C" {
    fn alert(s: &str);
}

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

执行 wasm-pack build ,可得到如下构建结果:

其中 greet_bg.js 文件就是 wasm 与 js 交互使用的胶水代码,可以分析一下看出,它基本符合前面提到的 wasm 和 js 之间的传值步骤。

JS 导入 rust 编译出的 webAssembly 模块

由于 wasm-pack 默认的构建目标是 bundler 的,它适合在 webpack 等打包器中加载;所以可以利用 webpack 来本地运行 wasm,可在根目录下增加 package.jsonwebpack.config.jsindex.js文件,代码详情可在 wasm-bindgen examples 中查看。

在终端,先执行 npm i 之后,运行 npm run serve ,打开 http://localhost:8080/ ,发现 rust 导出的函数在浏览器上立即生效,至此发 JS 运行 Rust 函数流程还是挺简单的。

Rust 导出结构到 JS

上述发现将 Rust 函数导入到 JS 中可正常调用,那 Rust 结构导入到 JS 中,它也可以作为 JS 类使用;

我们在 src/lib.rs 文件下添加一些 struct 代码:

// src/lib.rs
#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = console)]
    fn log(s: &str);
}
macro_rules! console_log {
    ($($t:tt)*) => (log(&format_args!($($t)*).to_string()))
}
#[wasm_bindgen]
pub struct Foo {
    internal: i32,
    name: String,
}

#[wasm_bindgen]
impl Foo {
    #[wasm_bindgen(constructor)]
    pub fn new(val: i32, name: String) -> Foo {
        Foo {
            internal: val,
            name,
        }
    }
    pub fn get(&self) -> i32 {
        self.internal
    }
    pub fn get_name(&self) -> String {
        self.name.clone()
    }
    pub fn set(&mut self, val: i32) {
        self.internal = val;
    }
    pub fn add(&mut self, val: i32) -> i32 {
        let res = self.internal + val;
        console_log!("from rust log: add rerult = {}", res);
        res
    }
}

再执行一次 wasm-pack build ,可以在 greet_bg.js 胶水代码中看到导出了 export class Foo 供 JS 端使用,修改一下 index.js 代码,运行一下结果:

Rust 调用 JS API、Web API

开发人员是否可以在 Rust 程序中使用 JS API 和 Web API 呢?答案是可以的,需要用到 web_sys cratejs_sys crate 两个 crate 包,通过对应的文档描述,它们分别对齐了 web 标准 api 和 js 标准 api,开发人员可以直接通过写 rust 完成 JS 操作,并且是在浏览器上执行 webAssembly ,对于需要复杂计算能力的应用可以获得不小的性能提升。

举个例子,通过 rust 直接操作 dom 元素,新建 cargo new --lib web_dom,在 cargo.toml 增加依赖以及修改 src/lib.rs 文件代码:

// cargo.toml
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "0.2"

[dependencies.web-sys]
version = "0.3"
features = ['Document',
  'Element',
  'HtmlElement',
  'Node',
  'Window',]
// src/lib.rs
use wasm_bindgen::prelude::*;
use web_sys::window;

#[wasm_bindgen(start)]
pub fn add_dom() -> Result<(), JsValue> {
    let window = window().expect("no global `window` exists");
    let document = window.document().expect("should have a document on window");
    let body = document.body().expect("document should have a body");

    let p = document.create_element("p")?;
    p.set_inner_html("hello from rust");
    body.append_child(&p)?;

    Ok(())
}

执行 wasm-pack build --target web 得到可直接在网络浏览器中加载的构建产物,新建index.html 文件,导入 pkg/web_dom.js 执行 init 函数;

<html>
  <head>
    <meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
  </head>
  <body>
    <script type="module">
      import init from "./pkg/web_dom.js";
      async function run() {
        await init();
      }
      run();
    </script>
  </body>
</html>

Vscode 可下载 Live Server 插件,右键打开index.html文件,可以看到 body 下新增 P元素:

关于更多使用wasm-bindgenjs-sysweb-syscrates 示例,可以查看 examples 自行试验。

Rust + WebAssembly+JS 小游戏

通过以上可大致了解 rustWebAssemblyJS 三者之间的相互关系,接下来,我们可以跟着 Rust Conway's Game 步骤,在本地完成一个 rust + webAssembly 处理小游戏计算,JS canvas 渲染小游戏的 demo,体验一下三者结合起来的魅力。