本文来自 AirCloud 的知乎投稿:https://zhuanlan.zhihu.com/p/104299612
可以用于开发 WebAssembly 的语言比较多,笔者之前也尝试过 AssemblyScript、C++、Rust,相对来说,使用 Rust 开发在开发效率和便捷性、包体积大小等方面还是有很大优势的,因此,笔者也建议使用 Rust 来作为 WebAssembly 的开发语言。
Rust 开发 WebAssembly 非常方便,实际上官方周边文档已经比较全面和友好了,而这篇文章主要有两个目的:
本文的目标读者:
本文看后,读者可以基本掌握:
在开始开发之前,我们可以先大致了解下 Rust+webassembly 能干些什么:
我们的第一个目标,肯定是希望能最快看到 hello-world,接下来我们需要一步步操作: 安装 wasm-pack,wasm-pack 是将 Rust 打包成 wasm 的命令行工具:
curl https://Rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
然后我们需要安装 cargo-generate,后续在样板代码生成的时候需要:
cargo install cargo-generate
接下来,我们需要建立一个项目,这里我们可以使用 create-wasm-app 这个工具,做前端开发的同学想必对这类 create-xx 应该比较熟悉了。
我们可以直接使用 npm init 目录来生成一个样板库,并安装依赖:
npm init wasm-app ./myRust && cd myRust && npm i
这个时候我们可以使用 npm start 来运行代码并且在浏览器访问了,这个时候,我们应该可以看到一个 alert 弹框。
不过当我们看入口代码发现,这个样板库中的 wasm 部分,是直接引入的一个 npm 包:
import * as wasm from "hello-wasm-pack";
这显然是不能符合我们的需求的,因为我们是需要开发 Rust 部分代码的,而不仅仅是前端引入。
这个时候,我们可以借助 wasm-pack-template,在项目目录下建立一个 Rust wasm 项目文件:
cargo generate --git https://github.com/Rustwasm/wasm-pack-template.git --name hello
这样我们所有跟 Rust 有关的 wasm 文件都放在 hello 下面。
我们可以在 hello/src/lib.rs 下面随便修改一点 greet 函数的内容(应该只有一行,随便改),然后运行 wasm-pack build
接下来我们修改我们 js 代码的引入:
import * as wasm from "./hello/pkg/hello";
接下来我们再运行 npm start,应该能看到预期的内容了。
于是,我们已经搭建好了一个方便的 Rust -> wasm 的运行环境,这个环境虽然相对简陋无法直接在实际项目中使用,但对于我们调试和建立起对代码开发的认知,已经足够。
这里的内容是我们在日常开发中使用比较多的,我们的 wasm 模块大多作为 JS 的 enhancment,自然少不了与 JS 的代码交互,这里我们对此进行分析。
如果是需要暴露在 JS 中调用的函数,我们只需要使用 wasm_bindgen 过程宏即可,一个最简单的例子:
#[wasm_bindgen]
pub fn get_version() -> i32 {
1
}
这个函数经过 wasm-pack 打包之后,可以直接挂到 wasm 模块实例上,当然,我们打包后的代码还会生成一个 js wrapper(所有的 wasm 函数,都会有对应的 js wrapper 函数供调用方使用),最后的返回结果类似如下:
/**
* @returns {number}
*/
export function get_version() {
var ret = wasm.get_version();
return ret;
}
wasm_bindgen 可以通过传递参数来实现更加复杂的功能,本文章暂不展开,具体可以参考这里。
我们可以在 Rust 层调用 js 几乎任意的函数,只需声明即可,例如调用 js 中的 console.log:
#[wasm_bindgen]
extern {
#[wasm_bindgen(js_namespace = console)]
pub fn log(s: &str);
}
其原理是,在工具链解析的时候会在 js wrapper 层生成一个对应的函数,然后这个对应的函数会在 wasm 实例化的时候通过 importObject 传递进去(参考这里的参数传递)。
export const __wbg_log_20c778ed882114c1 = function(arg0, arg1) {
console.log(getStringFromWasm0(arg0, arg1));
};
在了解值传递的过程前,我们需要知道:
因此,如果 wasm 需要传递值给 js,也是写入到线性内存的某处,给 JS 读取:
如果需要使用 JSON 序列化来返回对象给 JS,我们需要修改我们的 cargo.toml 的相关依赖和 features:
wasm-bindgen = { version = "0.2.58", features = ["serde-serialize"] }
serde = { version = "1.0.80", features = ["derive"] }
serde_derive = "^1.0.59"
然后在代码中调用:
#[derive(Serialize, Deserialize)]
pub struct Dog {
index: i32
}
#[wasm_bindgen]
pub fn get_dog() -> JsValue {
let dog = Dog {
index: 10
};
JsValue::from_serde(&dog).unwrap()
}
一般情况而言,我们在 Rust 中是没有办法返回 struct 等一些复杂的数据结构给 js 的,不过,我们也可以通过实现相关 trait 来完成返回一个 struct:
pub struct Duck {
index: i32
}
impl wasm_bindgen::describe::WasmDescribe for Duck {
fn describe() {
u32::describe()
}
}
impl wasm_bindgen::convert::IntoWasmAbi for Duck {
type Abi = u32;
fn into_abi(self) -> u32 {
self.index as u32
}
}
#[wasm_bindgen]
pub fn get_version() -> Duck {
Duck {
index: 4
}
}
我们可以看出,这样其实还是比较麻烦的,而且效率也不高,所以我们应该尽量减少复杂数据结构的传递。
Rust 使用 JS 传递的值,对于简单类型(数字、字符串)来说,其流程一般是:
我们可以看到,这种转化特别是字符串的转化,还是比较麻烦的,而实际上我们在一个 wasm 模块中,有的时候并不需要把 js 侧的内容完全拷贝过去,也不会直接使用到 js 的变量,而只是暂时存起来供后面调用,实际上后面也是调用 js 的函数调用,这里流程大概是:
实际上根本不需要把整个对象放到 Rust 中。
另外有的时候,我们没有办法也不能把一个 js 对象完全传递给 Rust wasm模块中(例如一个 dom 对象),所以,在 Rust wasm 中实际上还有一种 js 变量的“借用”机制, 下面我们来对此进行分析。
我们的 demo 场景是在 Rust 中操作一个 dom 并写入 innerHTML,代码如下:
实际上,getElementById 这些过程在 Rust 侧做也都是可以的,但是这里我们为了突出重点,进行了简化。
// Rust:
#[wasm_bindgen]
pub fn set_dom_inner(dom: HtmlElement) {
dom.set_inner_html("This is from Rust");
}
// js:
wasm.set_dom_inner(document.getElementById('wasm'));
// html:
<p id="wasm"></div>
这个代码中的 Rust 部分,编译出来的 js-wrapper 代码如下:
/**
* @param {any} dom
*/
export function set_dom_inner(dom) {
wasm.set_dom_inner(addHeapObject(dom));
}
这里我们可以看到,其并没有通过一番转化直接把dom“传递进去”(实际上也没法这样做),而是调用了 addHeapObject :
function addHeapObject(obj) {
if (heap_next === heap.length) heap.push(heap.length + 1);
const idx = heap_next;
heap_next = heap[idx];
if (typeof(heap_next) !== 'number') throw new Error('corrupt heap');
heap[idx] = obj;
return idx;
}
const ret = getObject(arg0).createElement(getStringFromWasm(arg1, arg2));
let index = addHeapObject(ret);
这个函数,实际上就是保持住这个对象的引用,防止在 js 侧被垃圾清除,同时传递给 Rust 侧一个索引,在 Rust 层直接存储这个索引即可( Rust 会生成一个 JsValue 结构体,用来存储这个 u32 的索引)。
当然了,这个时候我们还是有一个问题,既然这个变量被挂到了 heap 上,那肯定也有一个清除机制,否则就是内存泄漏了。
清除机制当然是有的:
function dropObject(idx) {
if (idx < 36) return;
heap[idx] = heap_next;
heap_next = idx;
}
// heap 这里其实是一个链表,把所有为空的串起来了。这样被解除引用了的,会被垃圾回收
function takeObject(idx) {
const ret = getObject(idx);
dropObject(idx);
return ret;
}
export const __wbindgen_object_drop_ref = function(arg0) {
takeObject(arg0);
};
在 wasm 侧,通过调用 import 进来的 __wbindgen_object_drop_ref 最终调用 dropObject 进行清除,__wbindgen_object_drop_ref 的调用是在对应对象在 Rust 析构的时候进行(上面的 dom 对象传递到 rust 后,就是一个 JsValue struct):
pub struct JsValue {
idx: u32,
_marker: marker::PhantomData<*mut u8>, // not at all threadsafe
}
// many other things...
impl Drop for JsValue {
#[inline]
fn drop(&mut self) {
unsafe {
// We definitely should never drop anything in the stack area
debug_assert!(self.idx >= JSIDX_OFFSET, "free of stack slot {}", self.idx);
// Otherwise if we're not dropping one of our reserved values,
// actually call the intrinsic. See #1054 for eventually removing
// this branch.
if self.idx >= JSIDX_RESERVED {
__wbindgen_object_drop_ref(self.idx);
}
}
}
}
注:以上这些内容。wasm-pack 工具链都会帮助我们自动完成
比较遗憾的是,目前 WebAssembly 还没有办法直接进行断点调试,也没有办法从 panic! 中恢复(来自官方团队:in wasm panics always get translated into aborts, so you can't catch them)。
目前我们能做的事情有:
借助以上功能,实际上我们已经可以编写出比较稳妥的 wasm 包了。
在 Rust 中使用 console 对象上的方法和使用任何 JS 对象的方法一样,实际上非常简单:
#[wasm_bindgen]
extern {
#[wasm_bindgen(js_namespace = console)]
pub fn log(s: &str);
#[wasm_bindgen(js_namespace = console)]
pub fn info(s: &str);
#[wasm_bindgen(js_namespace = console)]
pub fn warn(s: &str);
#[wasm_bindgen(js_namespace = console)]
pub fn error(s: &str);
}
直接使用上面的 log 需要传递字符串引用,比较繁琐,我们可以实现一个声明宏来完成这个事情:
macro_rules! log {
($($t:tt)*) => (log(&("[W]".to_string() + &format_args!($($t)*).to_string())))
}
为了在 Rust 中捕获 panic,我们需要用到 console_error_panic_hook 这个库,然后我们在某个提供给 JS 的初始化函数中调用 console_error_panic_hook::set_once(); 或者提供给一个单独的函数给 JS 调用。
实际上,console_error_panic_hook 这个函数的代码非常少,在实际项目中,也可以自行修改源码,将项目中需要的 panic 处理通用化。
上面提到我们在 Rust 中虽然能捕获到 panic!,但是此时也只能做“通知”而不能恢复了,而在实际的编码中我们使用的更多的应该是 Result,一个简单的例子如下:
// Rust:
#[wasm_bindgen]
pub fn return_error() -> Result<i32, JsValue>{
return Err("This is a Js Value".into());
}
// js:
try {
wasm.return_error();
} catch(e) {
console.log('catch error:', e);
}
// catch error: This is a Js Value
我们还可以借助一些 Rust 错误处理库比如 error_chain 等,来更完善地返回错误。