Rust + WebAssembly 全链路解析
第7章 wasm-bindgen 深入:类型映射与胶水代码
"The purpose of an abstraction is to hide the representation of a data type." — Barbara Liskov
7.1 类型映射全景
wasm-bindgen 为每种 Rust 类型定义了从 WASM 到 JS 的双向转换规则。这些规则不是魔法——每一条都对应一段具体的转换代码,由 #[wasm_bindgen] 宏在编译期生成。理解这些代码,才能理解跨边界调用的开销来源,才能做出正确的 API 设计决策。
所有类型映射的底层实现都依赖两个核心 trait:IntoWasmAbi 和 FromWasmAbi。IntoWasmAbi 定义了 Rust 类型如何转换为 WASM ABI 值(传给 JS 的方向),FromWasmAbi 定义了 WASM ABI 值如何转换回 Rust 类型(从 JS 接收的方向)。原生类型、String、导出结构体、JsValue 等都实现了这两个 trait(str 这类只能借用传入的类型则走下面说的 RefFromWasmAbi)。注意方向别搞反:对导出函数 #[wasm_bindgen] pub fn greet(name: &str) -> String 来说,参数是「从 JS 进来」、返回值是「往 JS 出去」,所以展开后的 shim 调的是 <str as RefFromWasmAbi>::ref_from_abi()(借用型参数走这条,见 src/convert/traits.rs:65)和 <String as ReturnWasmAbi>::return_abi()(同文件 239 行);IntoWasmAbi/FromWasmAbi(19 / 39 行)则用在导入函数那一侧和按值传参上。
这两个 trait 的设计可以直接类比 Serde 的 Serialize/Deserialize——前者是"Rust → 外部"方向,后者是"外部 → Rust"方向。Serde 用 Serializer trait 抽象了外部格式的差异(JSON、YAML、MessagePack 等),wasm-bindgen 用 WasmAbi 类型抽象了 WASM 原生类型的差异(i32、i64、f32、f64)。
graph TD
A[Rust 类型] --> B{WASM 原生类型?}
B -->|i32, u32, i64, u64| C[直接传递]
B -->|f32, f64| C
B -->|bool| D["i32 编码: 0/1"]
B -->|&str, String| E["线性内存 + UTF-8 复制"]
B -->|"&[u8], Vec<u8>"| F["线性内存 + 字节复制"]
B -->|char| G["i32 编码: Unicode 码点"]
B -->|Struct| H["指针 i32 + JS 包装类"]
B -->|Enum C-like| I["u32 判别值 + JS Object.freeze"]
B -->|JsValue| J["externref(Rust 侧存表索引)"]
B -->|"Result<T, E>"| K["Ok→T 的映射, Err→throw"]
B -->|"Option<T>"| L["None→null/undefined"]
B -->|Closure| M["闭包环境指针 + JS Function"]
style C fill:#10b981,color:#333
style E fill:#f59e0b,color:#333
style H fill:#6366f1,color:#fff
style J fill:#dc2626,color:#fff
完整类型映射表
以下是 wasm-bindgen 0.2.118 支持的类型映射的完整参考。「转换成本」一列只给性质——具体纳秒数因引擎、数据大小、机器而异,抄别人的数字没有意义,要自己测(§6.11):
| Rust 类型 | WASM 传递 | JS 类型 | 转换成本 |
|---|---|---|---|
i32 |
i32 |
number |
无 |
u32 |
i32 |
number(>>> 0) |
一次位运算 |
i64 |
i64 |
BigInt |
BigInt 装箱 |
u64 |
i64 |
BigInt |
BigInt 装箱 |
f32 |
f32 |
number |
无 |
f64 |
f64 |
number |
无 |
bool |
i32 |
boolean |
一次比较 |
char |
i32(u32 码点,src/convert/impls.rs:388) |
string(单字符) |
进 WASM 一次 .codePointAt(0)(I32FromStringFirstChar 分支,crates/cli-support/src/js/binding.rs:1135)、出来一次 String.fromCodePoint(同文件 1431 行) |
&str |
i32 + i32(ptr、len) |
string |
malloc + UTF-8 编码复制 + free |
String(返回) |
i32 + i32 或 retptr |
string |
UTF-8 解码复制 + free |
&[u8] |
i32 + i32(ptr、len) |
Uint8Array |
malloc + 字节复制 + free |
Vec<u8>(返回) |
i32 + i32 或 retptr |
Uint8Array |
字节复制 + free |
JsValue |
externref(Rust 侧是 externref 表索引) |
any |
一次表槽位分配;drop 时在 WASM 内部释放槽位 |
Struct |
i32(指针) |
Class 实例 |
仅传指针;需手动 free() |
C 风格 enum |
i32 |
number(挂在 Object.freeze({...}) 常量对象上,crates/cli-support/src/js/mod.rs:5221) |
无 |
Option<T> |
取决于 T | T | undefined | null |
同 T + 一次判空 |
Result<T, E> |
取决于 T | T,出错时 throw |
同 T + 异常路径 |
Closure |
创建时传(数据指针, vtable 指针)两个 i32,之后以 JsValue 交给 JS |
Function |
闭包环境堆分配 + JS 函数对象 |
*const T / *mut T |
i32 |
number |
无 |
JsValue 那一行是 Rust 1.96 默认产物的情况:wasm32-unknown-unknown 默认开启 reference-types,CLI 据此(0.2.94 起按 target-features 自动判断)走 externref 路径,JS 对象直接以 externref 过边界,存进模块自己的 __wbindgen_externrefs 表,分配与释放槽位都在 WASM 内部完成,最终产物里没有 __wbindgen_object_drop_ref 这类导入。只有关掉 reference-types 时才退回旧路径:JS 侧维护一个 heap slab 数组,边界上传 i32 下标,drop 时跨边界调一次 JS。
7.2 原生数值类型
i32 / u32
最简单的映射——WASM 的 i32 直接对应 JS 的 number。Rust 的 i32 和 u32 在 WASM 层面都是 i32(WASM 不区分有符号/无符号),但在 JS 侧的表现不同:
i32在 JS 中可能是负数(如-1=0xFFFFFFFF)u32需要确保 JS 看到的是>>> 0(无符号右移零位,强制转为无符号 32 位整数)
wasm-bindgen 生成的胶水代码:
// Rust: pub fn get_u32() -> u32
export function get_u32() {
const ret = wasm.get_u32();
return ret >>> 0; // 强制无符号:0xFFFFFFFF → 4294967295
}
// Rust: pub fn get_i32() -> i32
export function get_i32() {
return wasm.get_i32(); // 直接返回:0xFFFFFFFF → -1
}
为什么 u32 需要 >>> 0?因为 JS 的 number 类型在内部是 64 位浮点数。当一个 i32 值 0xFFFFFFFF 从 WASM 返回时,JS 会把它当作有符号整数 -1(因为符号扩展)。>>> 0(无符号右移零位)是一个 JS 技巧——它把值强制转为无符号 32 位整数,0xFFFFFFFF >>> 0 = 4294967295。
>>> 0 是 JS 里把数值转成无符号 32 位整数的惯用写法,wasm-bindgen 只是沿用了它。值得注意的是,>>> 0 只对 32 位整数有效——对于 u64,需要完全不同的处理方式(下一节详述)。
从 Rust 侧看,i32 和 u32 的 IntoWasmAbi 实现几乎相同——都是直接传递 i32 值。差异完全在 JS 侧的胶水代码中处理。这体现了 wasm-bindgen 的一个设计原则:在 Rust 侧做最小改写,把类型差异的处理推迟到 JS 侧。这样做的优势是 Rust 侧的代码更容易审计——宏展开后的代码和原始代码结构相近,只是参数和返回值类型做了替换。
i64 / u64
JS 的 number 是 64 位浮点数,精确整数范围只有 53 位(Number.MAX_SAFE_INTEGER = 2^53 - 1)。i64 / u64 无法安全地用 number 表示,所以 wasm-bindgen 把它们映射到 BigInt:
graph LR
A["Rust: i64/u64"] --> B{目标环境?}
B -->|"支持 BigInt<br/>(现代浏览器 + Node 10.4+)"| C["JS: BigInt<br/>零精度损失"]
B -->|"不支持 BigInt<br/>(旧环境)"| D["报错: 不支持"]
style C fill:#10b981,color:#333
style D fill:#dc2626,color:#fff
#[wasm_bindgen]
pub fn big_number() -> u64 {
0x0100_0000_0000_0000 // = 2^56,远超 Number.MAX_SAFE_INTEGER (2^53 - 1)
}
// 胶水里是 return BigInt.asUintN(64, ret):WASM 的 i64 到 JS 一侧本是有符号 BigInt,
// u64 要再按无符号解释一次
const result = big_number(); // 72057594037927936n
typeof result; // "bigint"
需要注意:JS 的 BigInt 和 number 之间不能直接做算术运算——1n + 1 会抛出 TypeError。如果你的 WASM 导出函数返回 i64/u64,JS 调用者需要用 BigInt 语法处理返回值。
f32 / f64
浮点数直接映射为 JS 的 number——因为 JS 的 number 本身就是 64 位浮点数。f32 会被提升为 f64(精度不会损失,因为 f64 包含所有 f32 的值),f64 直接传递。
// Rust: pub fn compute(x: f64) -> f64
export function compute(x) {
return wasm.compute(x); // 直接传递,零转换
}
NaN 的传递也不用担心——Rust 的 NaN 可能带有不同的 payload 位模式,wasm-bindgen 也不做 NaN 规范化,但在 JS 语言层面所有 NaN 都是同一个值(Number.isNaN()、Object.is(x, NaN) 都成立),只有把它写进 Float64Array / DataView 看字节时,才可能看到不同的位模式。
另一个浮点数相关的边界情况是 -0.0。在 IEEE 754 中,+0.0 和 -0.0 是两个不同的值(它们的符号位不同),但 JS 的 Object.is(+0, -0) 返回 false,而 +0 === -0 返回 true。如果 Rust 侧返回 -0.0,JS 侧用 === 比较时无法区分——这在大多数场景下不是问题,但在需要区分正零和负零的算法中(如某些数学函数的分支判断),可能导致逻辑错误。
7.3 bool 与 char
bool
Rust 的 bool 映射为 i32(0 = false,1 = true),JS 侧自然转换为 JS boolean:
// Rust: pub fn is_ready() -> bool
export function is_ready() {
const ret = wasm.is_ready();
return ret !== 0; // i32 → boolean
}
// Rust: pub fn set_flag(flag: bool)
export function set_flag(flag) {
wasm.set_flag(flag); // 原样传入,由引擎按 ToInt32 把 true/false 转成 1/0
}
出方向是一次比较,入方向胶水什么都不做,开销可以忽略。
char
Rust 的 char 是 Unicode 标量值(0 到 0x10FFFF),映射为 i32 传递,JS 侧转换为单字符 string:
// Rust: pub fn get_char() -> char
export function get_char() {
const ret = wasm.get_char();
return String.fromCodePoint(ret); // i32 → 单字符 string
}
// Rust: pub fn is_letter(c: char) -> bool
export function is_letter(c) {
// JS string → codePoint → i32
const char0 = c.codePointAt(0);
// 发布版也会生成这句(binding.rs:763 的 assert_char 不看 --debug):
// 孤立代理项或 ≥ 0x110000 的码点直接抛错,Rust 侧才敢用 from_u32_unchecked
_assertChar(char0);
const ret = wasm.is_letter(char0);
return ret !== 0;
}
出方向的 String.fromCodePoint 每次都要新建一个 JS 字符串。如果 JS 侧本来只关心码点(比如拿去查表、比较),可以直接用 u32 传码点,省掉这次字符串构造和入方向的校验。
7.4 字符串:最复杂的映射
字符串传递是 wasm-bindgen 中开销最大的操作。原因:Rust 的 String 是 UTF-8 编码的字节序列 + 长度 + 容量,存储在线性内存中;JS 的 String 是 UTF-16 编码的不可变值,存储在 JS 堆中。两者编码不同、存储位置不同、生命周期模型不同。
flowchart LR
subgraph "JS 堆"
A["JS String<br/>UTF-16 编码<br/>不可变"]
end
subgraph "WASM 线性内存"
B["Rust String<br/>UTF-8 编码<br/>可变(所有权)"]
end
A -->|"ASCII 快路径:逐字节写入<br/>遇到非 ASCII:TextEncoder.encodeInto()<br/>直接写进 malloc 出的线性内存"| B
B -->|"TextDecoder.decode()<br/>UTF-8 → UTF-16"| D["JS String<br/>(新实例)"]
style D fill:#10b981,color:#333
Rust → JS 方向
#[wasm_bindgen]
pub fn get_name() -> String {
"杨艺韬".to_string()
}
生成的 Rust 侧代码(简化):
// String 的 Abi 是 WasmSlice { ptr: u32, len: u32 }(src/convert/slices.rs:30)——两个 u32,不是一个
#[cfg_attr(target_family = "wasm", export_name = "get_name")]
pub unsafe extern "C-unwind" fn __wasm_bindgen_generated_get_name()
-> wasm_bindgen::convert::WasmRet<
<String as wasm_bindgen::convert::ReturnWasmAbi>::Abi> {
let ret = get_name();
// into_abi() 会 mem::forget 掉这个 String,把裸的 (ptr, len) 交出去,
// 内存的释放责任转移到 JS 侧
<String as ReturnWasmAbi>::return_abi(ret).into()
}
生成的 JS 侧代码(0.2.118 实际产物,辅助函数有简化:实际 getStringFromWasm0 还经过一层 decodeText,累计解码约 2 GiB 后重建 TextDecoder,绕开 Safari 的上限):
// 这一行的构造参数逐字来自 crates/cli-support/src/js/mod.rs:2476
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
function getStringFromWasm0(ptr, len) {
ptr = ptr >>> 0;
return cachedTextDecoder.decode(
getUint8ArrayMemory0().subarray(ptr, ptr + len)
);
}
export function get_name() {
let deferred1_0;
let deferred1_1;
try {
// 开了 multivalue 时 ret 就是 [ptr, len] 两个返回值;
// 没开时先用 __wbindgen_add_to_stack_pointer(-16) 腾一块 retptr,
// 被调方把 ptr/len 写进线性内存,这里再读回来
const ret = wasm.get_name();
deferred1_0 = ret[0];
deferred1_1 = ret[1];
return getStringFromWasm0(ret[0], ret[1]);
} finally {
wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); // (ptr, size, align)
}
}
没有 __wbindgen_strlen 这个东西——长度从来都是和指针一起返回的,不需要额外查询。
完整流程:
- Rust 调用
get_name()得到String String的字节存储在线性内存中(由 Rust 分配器管理)into_abi()把它转成WasmSlice,交出(ptr, len)两个u32——这一步不是 drop,而是把所有权让渡出去(String的into_abi走self.into_bytes().into_abi(),src/convert/slices.rs:336;最终在vector_into_abi里mem::forget)- JS 通过
TextDecoder从线性内存中读取 UTF-8 字节,解码为 JS String - 释放责任落在 JS 侧:
finally里那句__wbindgen_free(ptr, len, 1)才是真正回收这块内存的地方
注意 cachedTextDecoder——wasm-bindgen 缓存了 TextDecoder 实例,避免每次调用都创建新的。fatal: true 意味着如果 UTF-8 字节序列不合法会抛出异常,而不是静默替换为替代字符。
JS → Rust 方向
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
JS 侧生成的代码:
// passStringToWasm0 返回的是 ptr,长度写在模块级变量 WASM_VECTOR_LEN 里
// (完整实现有一条 ASCII 快路径,见 §6.4)
let WASM_VECTOR_LEN = 0;
export function greet(name) {
let deferred2_0, deferred2_1;
try {
const ptr0 = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.greet(ptr0, len0);
deferred2_0 = ret[0];
deferred2_1 = ret[1];
return getStringFromWasm0(ret[0], ret[1]);
} finally {
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
}
}
注意参数那块内存的释放责任在被调方(Rust 侧),方向和返回值正好相反:impl RefFromWasmAbi for str 的 Anchor 是 Box<str>(src/convert/slices.rs:381),shim 里那个锚点局部量在函数结束时 drop,顺手把这块线性内存还给分配器;String 参数则由 Rust 直接接管所有权、同样在函数结束时 drop。所以上面的 JS 里 ptr0 从头到尾没有出现在任何 __wbindgen_free 调用中——deferred2_0/deferred2_1 是返回值的 (ptr, len)。CLI 也只对出向值生成延迟释放:Instruction::DeferFree 仅在 crates/cli-support/src/wit/outgoing.rs:125 和 530 行两处产生,入向参数没有对应指令。
String vs &str vs JsString
wasm-bindgen 支持三种字符串类型,各有适用场景:
| Rust 类型 | 语义 | 跨边界行为 | 适用场景 |
|---|---|---|---|
&str |
借用(不获取所有权) | JS 侧复制到线性内存,shim 返回时由 Rust 侧释放 | 函数参数,只读访问 |
String |
拥有所有权 | JS 侧复制到线性内存,Rust 获取所有权 | 函数参数,需要修改/存储 |
js_sys::JsString |
JS 堆上的 String | 不复制,JS 字符串本身作为 externref 传入(Rust 侧拿到的是 externref 表索引) |
需要和 JS API 交互时 |
use js_sys::JsString;
// 方式一:&str —— 推荐作为函数参数
#[wasm_bindgen]
pub fn parse_input(input: &str) -> i32 {
input.len() as i32
}
// 方式二:String —— 需要获取所有权时使用
#[wasm_bindgen]
pub fn store_name(name: String) {
// name 的所有权转移给 Rust,可以存储到全局状态
}
// 方式三:JsString —— 不复制,字符串始终留在 JS 堆上
#[wasm_bindgen]
pub fn concat_js(a: &JsString, b: &JsString) -> JsString {
a.concat(b) // 直接调 JS 的 String.prototype.concat,不经过线性内存
}
中文字符串的陷阱
UTF-8 编码下,一个常用汉字占 3 字节。JS 的 String.length 返回的是 UTF-16 码元数量(基本多文种平面内的汉字 1 字符 = 1 码元),Rust 的 str.len() 返回的是 UTF-8 字节数。在 WASM 边界上,wasm-bindgen 传递的是字节数而非字符数——这是正确的,但容易让开发者困惑:
const name = "杨艺韬";
name.length; // 3 (UTF-16 码元)
new TextEncoder().encode(name).length; // 9 (UTF-8 字节)
在 wasm-bindgen 的胶水代码中,传递给 WASM 的 len 是 UTF-8 字节数(9),而不是 JS 的 string.length(3)。Rust 侧收到的 &str 的 .len() 也是 9。如果要得到字符数,需要调用 .chars().count()。
这个差异还会影响更复杂的场景——比如 String::insert 和 String::remove 的索引参数在 Rust 中是字节偏移量,不是字符偏移量。如果 JS 调用者用 string.length 计算出的索引传入 Rust 侧做字符串操作,中文字符处会出现字节对齐错误,导致 panic。正确的做法是在 JS 侧也使用字节偏移量(通过 new TextEncoder().encode(str.slice(0, n)).length 计算前 n 个 UTF-16 码元对应的字节偏移量),或者在 Rust 侧提供一个接受字符索引的 API 内部做转换。
7.5 二进制数据
&[u8] vs Vec<u8> vs JsValue (Uint8Array)
三种二进制数据传递方式,性能特征截然不同:
| Rust 类型 | JS 侧表现 | 内存策略 | 适用场景 |
|---|---|---|---|
&[u8] |
Uint8Array |
复制到线性内存,调用后释放 | 函数参数,只读 |
Vec<u8> |
Uint8Array |
复制到线性内存,所有权转移 | 函数参数或返回值 |
js_sys::Uint8Array |
Uint8Array |
不复制,传递句柄索引(§11.4.1) | 需要和 JS API 交互 |
*const u8 + usize |
number |
零复制,传指针 | 高性能场景,手动管理 |
// 方式一:复制传递(默认)
#[wasm_bindgen]
pub fn hash(data: &[u8]) -> Vec<u8> {
// data 是从 JS 复制到线性内存的副本
// 返回的 Vec<u8> 会被复制回 JS
sha256(data).to_vec()
}
// 方式二:零拷贝传递(通过指针)
#[wasm_bindgen]
pub fn hash_at(ptr: *const u8, len: usize) -> Vec<u8> {
let data = unsafe { std::slice::from_raw_parts(ptr, len) };
sha256(data).to_vec()
}
// 方式三:使用 js_sys::Uint8Array —— 参数本身只传一个句柄,不整块复制
#[wasm_bindgen]
pub fn process_array(input: &js_sys::Uint8Array) -> js_sys::Uint8Array {
// 真要在 Rust 里算,还是得把数据取到线性内存
let mut buf = vec![0u8; input.length() as usize];
input.copy_to(&mut buf);
// 处理 buf...
// 注意别用 new_with_byte_offset_and_length 直接在 memory 上开视图返回:
// buf 一出作用域就被释放,视图会指向已回收的内存
js_sys::Uint8Array::from(&buf[..])
}
传递大量数据的优化策略
数据量大时(整张图片、整段音频),复制开销会变得显著。三种优化策略:
graph TD
A["大数据传递"] --> B{优化策略}
B --> C["策略一: 直接操作 memory.buffer<br/>零复制,但需处理 grow"]
B --> D["策略二: 预分配线性内存<br/>Rust alloc → JS 写入 → 传指针"]
B --> E["策略三: 共享内存(SharedArrayBuffer)<br/>多个 Worker 共享同一块 WASM 内存"]
C --> C1["优点: 零复制<br/>缺点: memory.grow 后视图失效"]
D --> D1["优点: Rust 侧管理内存<br/>缺点: 需要 unsafe 代码"]
E --> E2["优点: 跨线程共享<br/>缺点: 需要 atomics 构建 + COOP/COEP 头"]
style C1 fill:#10b981,color:#333
style D1 fill:#f59e0b,color:#333
style E2 fill:#6366f1,color:#fff
-
直接操作
memory.buffer:JS 侧获取Uint8Array视图直接操作 WASM 线性内存,零复制。风险是memory.grow后视图失效——需要每次重新获取。 -
预分配线性内存:Rust 侧暴露一个
alloc(size) -> ptr函数,JS 调用它获取一块 WASM 内存,直接写入数据,然后传指针给处理函数。 -
SharedArrayBuffer共享:JS 和 WASM 本来就共享同一块线性内存(策略一),SharedArrayBuffer解决的是多线程共享——WASM 内存声明为 shared(要开 atomics 构建),它的buffer就是一个SharedArrayBuffer,可以交给多个 Worker 同时访问,不必在线程之间来回复制。前提是页面设置了Cross-Origin-Opener-Policy: same-origin和Cross-Origin-Embedder-Policy: require-corp头,这限制了它的适用范围。已经躺在普通ArrayBuffer里的 JS 数据,仍然要复制一次才能进入 WASM 内存。
7.6 结构体与类
#[wasm_bindgen] struct 在 JS 侧生成一个包装类。类实例持有指向 Rust 对象的 i32 指针:
#[wasm_bindgen]
pub struct ImageProcessor {
width: u32,
height: u32,
buffer: Vec<u8>,
}
#[wasm_bindgen]
impl ImageProcessor {
#[wasm_bindgen(constructor)]
pub fn new(width: u32, height: u32) -> ImageProcessor {
ImageProcessor {
width,
height,
buffer: vec![0; (width * height * 4) as usize],
}
}
pub fn process(&mut self) {
// ... 处理 buffer ...
}
pub fn get_width(&self) -> u32 {
self.width
}
}
生成的 JS 类(简化):
// 无论目标环境如何,CLI 都会生成这个 registry;宿主没有 FinalizationRegistry
// 时退化成一对空函数,逻辑照样跑
const ImageProcessorFinalization = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(ptr => wasm.__wbg_imageprocessor_free(ptr >>> 0, 1));
export class ImageProcessor {
constructor(width, height) {
const ret = wasm.imageprocessor_new(width, height);
this.__wbg_ptr = ret >>> 0;
ImageProcessorFinalization.register(this, this.__wbg_ptr, this);
return this;
}
process() {
wasm.imageprocessor_process(this.__wbg_ptr);
}
get_width() {
return wasm.imageprocessor_get_width(this.__wbg_ptr) >>> 0;
}
__destroy_into_raw() {
const ptr = this.__wbg_ptr;
this.__wbg_ptr = 0;
ImageProcessorFinalization.unregister(this);
return ptr;
}
free() {
const ptr = this.__destroy_into_raw();
wasm.__wbg_imageprocessor_free(ptr, 0);
}
}
// 支持 `using` 语法(显式资源管理)
if (Symbol.dispose) ImageProcessor.prototype[Symbol.dispose] = ImageProcessor.prototype.free;
几个命名规则值得记住(都在 crates/shared/src/lib.rs):释放函数是 __wbg_ + 结构体名小写 + _free(free_function(),231 行);方法导出名是结构体名小写 + _ + 方法名(struct_function_export_name(),249 行)。指针字段叫 __wbg_ptr,不叫 ptr(crates/cli-support/src/js/mod.rs:1587)。
还有一处细节容易被当成手误:_free 的第二个参数在 finalizer 里传 1、在显式 free() 里传 0,这不是笔误。宏生成的 #free_fn(ptr: u32, allow_delayed: u32)(crates/macro-support/src/codegen.rs:312)按这个参数分两条路走——非 0 时只把 JS 持有的那个隐式 Rc 丢掉,值若还被别处引用就继续活着;为 0 时则直接 from_abi 夺走所有权,此时若对象正被借用(比如 &mut self 方法还在栈上)就会 panic。GC 回调不该因为对象正忙而炸掉,所以走宽松的那条。
Rust 结构体字段的可见性
规则很简单,但常被记反:pub 字段自动暴露为 JS 属性(读写都有),非 pub 字段完全不可见。宏在遍历字段时第一行就是 match field.vis { Public(..) => {}, _ => continue }。属性用来微调这个默认行为:readonly 只生成 getter,getter_with_clone 让非 Copy 类型的 getter 走 .clone()(不加的话 String 这类字段会编译报错),skip 则彻底跳过某个 pub 字段。
#[wasm_bindgen]
pub struct Config {
#[wasm_bindgen(getter_with_clone)]
pub name: String, // JS 可以读 config.name,返回 clone 的 String
#[wasm_bindgen(readonly)]
pub version: u32, // JS 可以读 config.version,但不能修改
// 这个字段 JS 完全不可见
internal_state: Vec<u8>,
}
pub 字段的 getter/setter 是自动生成的,别再给它手写同名的 #[wasm_bindgen(setter)] fn set_name——宏能编过,CLI 会报 There can be only one getter/setter definition for `name` in `Config`(0.2.118 实测)。想加校验之类的逻辑,得把字段改成私有,再手写 getter/setter 这一对。
方法接收者类型
Rust 的方法接收者(&self、&mut self、self)在 wasm-bindgen 中有不同的语义和 JS 侧行为:
#[wasm_bindgen]
impl Counter {
// &self — 不可变借用,JS 侧的包装对象仍然可用
pub fn get(&self) -> i32 { self.count }
// &mut self — 可变借用,JS 侧同一时刻只能有一个 &mut 引用
pub fn increment(&mut self) { self.count += 1; }
// self — 消费对象,调用后 JS 侧的包装对象不可用
pub fn into_total(self) -> i32 { self.count }
}
借用检查发生在 Rust 侧而不是 JS 侧:#[wasm_bindgen] struct 的实例被包在 wasm_bindgen::__rt::WasmRefCell 里(src/rt/mod.rs:303),&self 走 borrow()、&mut self 走 borrow_mut(),冲突时 borrow_fail()(同文件 401 行)panic,消息是 recursive use of an object detected which would lead to unsafe aliasing in rust——典型场景是 JS 回调在 &mut self 方法执行期间又回调进同一个对象。JS 侧只做指针层面的处理:调用 into_total(self) 这类消费型方法后 this.__wbg_ptr 被置 0,再调任何方法就会把空指针传进 Rust,被 assert_not_null 拦下,JS 侧抛 Error: null pointer passed to rust(node 实测)。
生命周期问题
JS 不会自动调用 free()。如果 JS 侧的 ImageProcessor 对象被 GC 回收但 free() 没被调用,Rust 侧的内存永远不会释放——WASM 模块中的内存泄漏。
这句话需要打个折扣:0.2.118 生成的类已经自带 FinalizationRegistry 兜底(上面那段代码里的 ImageProcessorFinalization),并不需要什么 --target web 或已废弃的 --weak-refs 标志——那个 flag 从 0.2.91 起就废弃为运行时自动探测,如今在 crates/cli/src/wasm_bindgen.rs:113 上带着 #[arg(long, hide = true)] + #[deprecated] + #[allow(dead_code)],纯属残留。宿主没有 FinalizationRegistry 时这段代码退化成一对空函数(生成模板见 crates/cli-support/src/js/mod.rs:1637)。
但兜底只是兜底:FinalizationRegistry 的回调时机完全由 JS 引擎的 GC 决定,可能延迟很久,也可能在页面卸载前根本不触发。对内存敏感的应用(如大图像处理),仍然要显式 free(),或者用 using 语法借 [Symbol.dispose] 自动释放:
{
using processor = new ImageProcessor(800, 600);
processor.process();
} // 离开作用域自动调用 free()
7.7 Option<T> 映射
Option<T> 在 wasm-bindgen 中的映射取决于 T 的类型:
Option<原生类型>
对于原生数值类型,Option<i32> 使用哨兵值表示 None:
#[wasm_bindgen]
pub fn maybe_int(val: Option<i32>) -> Option<i32> {
val.map(|v| v * 2)
}
JS 侧:
export function maybe_int(val) {
// val 传 undefined/null → Rust 收到 None
// val 传 number → Rust 收到 Some(number)
const ret = wasm.maybe_int(isLikeNone(val) ? 0x100000001 : (val) >> 0);
return ret === 0x100000001 ? undefined : ret;
}
哨兵值的选择很讲究(src/convert/impls.rs):
Option<i32>、Option<u32>、Option<isize>、Option<usize>、Option<f32>的 Abi 不是i32而是f64(src/convert/impls.rs:219那条type_wasm_native_f64_option!正好列的就是这五个),哨兵是F64_ABI_OPTION_SENTINEL = 4294967297_f64(同文件 165 行),也就是 2^32 + 1——源码注释解释得很清楚:它是最小的、任何 32 位原始类型都表示不出来的正整数——整数类型装不下它,而f32在 2^32 到 2^33 之间只能精确表示 2^32 + 512k 这些值,同样表示不出 2^32 + 1,所以对这五种类型都安全。bool、i8、u8、i16、u16、char走另一条路:Abi 是u32,哨兵U32_ABI_OPTION_SENTINEL = 0x00FF_FFFFu32(src/convert/impls.rs:268;i8/u8/i16/u16由 310 行的type_abi_as_u32!统一实现,bool和char在 361、407 行各自单独实现同一个哨兵)——注释说这样选是为了让所有小于 32 位的类型共用同一套 JS 胶水(char::MAX是0x10_FFFF,不会撞上)。
关键是:用 f64 承载 Option<i32> 之后,i32 的取值范围一个都没被牺牲——不存在「某个 Some(x) 表示不出来」的问题。
Option<引用类型>
对于 String、Vec<u8>、JsValue 等引用类型,None 映射为 JS 的 null 或 undefined:
#[wasm_bindgen]
pub fn maybe_string(val: Option<String>) -> Option<String> {
val.map(|s| s.to_uppercase())
}
JS 侧:
export function maybe_string(val) {
// val 是 null/undefined → Rust 收到 None
// val 是 string → Rust 收到 Some(String)
var ptr0 = isLikeNone(val) ? 0 : passStringToWasm0(val, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
var len0 = WASM_VECTOR_LEN;
const ret = wasm.maybe_string(ptr0, len0);
// 返回值 ret[0](ptr)为 0 表示 None;Rust 侧发出这个 0 的是
// src/convert/slices.rs:55 的 null_slice(),它返回 WasmSlice { ptr: 0, len: 0 }
let v2;
if (ret[0] !== 0) {
v2 = getStringFromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
}
return v2;
}
指针值 0(空指针)表示 None,非零指针表示 Some。这和 Rust 内部的 Option<ptr> 表示完全一致——Rust 的 Option<Box<T>> 的 None 就是空指针。
Option<结构体>
#[wasm_bindgen]
pub fn find_user(id: u32) -> Option<User> {
// ...
}
JS 侧返回 User 实例或 undefined。当 Rust 返回 None 时,JS 胶水代码返回 undefined;返回 Some(user) 时,JS 胶水代码创建 User 包装对象。
需要注意的是,JS 侧的 undefined 和 null 都对应 Rust 侧的 None。当 JS 调用者传入 null 时,wasm-bindgen 把它视为 None——这在大多数情况下是正确的行为,但如果你需要区分 null 和 undefined,就不能使用 Option<T>,而需要使用 JsValue 并手动检查。
7.8 Result<T, E> 映射
Result<T, E> 映射为 JS 的异常机制——Ok(T) 正常返回,Err(E) 抛出异常。
Result<T, JsValue>
最常见的模式——用 JsValue 作为错误类型:
#[wasm_bindgen]
pub fn divide(a: i32, b: i32) -> Result<i32, JsValue> {
if b == 0 {
Err(JsValue::from_str("division by zero"))
} else {
Ok(a / b)
}
}
JS 侧(0.2.118 实际产物):
export function divide(a, b) {
const ret = wasm.divide(a, b);
// Rust shim 把 Result 拆成多个返回值:[Ok 的值, Err 的 externref 表索引, is_err]
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return ret[0];
}
WASM 侧并不抛异常:Result<T, E> 的 ReturnWasmAbi(实现见下文「Result 的局限性」)先把 Err(e) 转成 JsValue,连同一个 is_err 标志正常返回,由胶水判断标志、从 externref 表取出错误值再 throw。所以 divide(1, 0) 抛出去的就是字符串 "division by zero" 本身,e.message 是 undefined(node 实测);要抛真正的 Error,用下文的 JsError。
Result<T, E> 其中 E 是结构体
#[wasm_bindgen]
pub struct AppError {
code: i32,
message: String,
}
#[wasm_bindgen]
impl AppError {
#[wasm_bindgen(constructor)]
pub fn new(code: i32, message: String) -> AppError {
AppError { code, message }
}
pub fn code(&self) -> i32 { self.code }
pub fn message(&self) -> String { self.message.clone() }
}
#[wasm_bindgen]
pub fn validate(input: &str) -> Result<String, AppError> {
if input.is_empty() {
Err(AppError::new(1, "input cannot be empty".into()))
} else {
Ok(input.to_uppercase())
}
}
JS 侧 catch 到的错误是一个 AppError 实例(#[wasm_bindgen] struct 自动实现了 From<AppError> for JsValue,胶水里通过 AppError.__wrap 包成类实例),可以调用它的方法(node 实测):
try {
validate("");
} catch (e) {
console.log(e.code()); // 1
console.log(e.message()); // "input cannot be empty"
}
Result 的局限性
约束写在 trait bound 里,一看就清楚(src/convert/impls.rs:738):
impl<T, E> ReturnWasmAbi for Result<T, E>
where
T: IntoWasmAbi,
E: Into<JsValue>,
T::Abi: WasmAbi<Prim3 = (), Prim4 = ()>,
也就是说 E 只需要满足 Into<JsValue>,不必是 JsValue 本身。String、&str、各种数值都实现了 From<_> for JsValue,#[wasm_bindgen] 标注的 struct 也实现了——所以:
// ✅ 支持:String: Into<JsValue>
#[wasm_bindgen]
pub fn parse(input: &str) -> Result<i32, String> { ... }
// ✅ 支持:JsValue 本身
#[wasm_bindgen]
pub fn parse2(input: &str) -> Result<i32, JsValue> { ... }
// ✅ 支持:自定义错误类型(#[wasm_bindgen] struct 或 JsError)
#[wasm_bindgen]
pub fn parse3(input: &str) -> Result<i32, AppError> { ... }
// ❌ 不支持:anyhow::Error 没有 Into<JsValue>,得自己 map_err
#[wasm_bindgen]
pub fn parse4(input: &str) -> Result<i32, anyhow::Error> { ... }
真正想抛一个「像样的 JS Error」时用 JsError(Result<T, JsError>,JsError::new("..."))——它会在 JS 侧构造成真正的 Error 实例(带 message、带栈),而 Err(JsValue::from_str("...")) 抛出去的只是一个裸字符串。
7.9 枚举映射
Rust 的 enum 在 JS 侧有两种表示方式:
C-like 枚举
#[wasm_bindgen]
pub enum Status {
Ok = 0,
Error = 1,
Pending = 2,
}
JS 侧生成一个 Object.freeze 常量映射,还带着「数值 → 名字」的反向映射(0.2.118 实际产物):
export const Status = Object.freeze({
Ok: 0, "0": "Ok",
Error: 1, "1": "Error",
Pending: 2, "2": "Pending",
});
Object.freeze 确保枚举值不可修改——Status.Ok = 42 在严格模式下会抛出 TypeError。但它防不住类型混淆:JS 调用者可以把任意 number 传给期望 Status 的函数,胶水代码不做范围检查。好在兜底在 Rust 侧——宏为枚举生成的 FromWasmAbi::from_abi 是一串判别值比较,全都不匹配时走 wasm_bindgen::throw_str("invalid enum value passed")(crates/macro-support/src/codegen.rs:2359)。所以传 3 会得到一个明确的 JS 异常 Error: invalid enum value passed(node 实测),不是未定义行为。
wasm-bindgen 要求 C-like 枚举的判别值是数字字面量(不能是表达式或常量),而且要放得进 32 位:只要有一个负值就按 i32 处理,否则按 u32(crates/macro-support/src/parser.rs:1833-1842)。判别值可以不连续,但不能重复;不写时和 rustc 一样从 0 开始、在上一个值上加 1。如果 Rust 侧的枚举带有 #[repr(u8)] 或 #[repr(i8)] 等属性,wasm-bindgen 仍然按 u32/i32 传递——因为 WASM 没有小于 32 位的值类型。
带数据的枚举
// ❌ 不支持——wasm-bindgen 不能映射带数据的 enum
#[wasm_bindgen]
pub enum Result {
Ok(i32),
Err(String),
}
wasm-bindgen 不支持带数据的 enum,宏直接报 enum variants with associated data are not supported with #[wasm_bindgen](crates/macro-support/src/parser.rs:1777)——JS 没有等价的类型,TypeScript 的 enum 也只是字符串/数字的映射。替代方案有三种:
graph TD
A["带数据的 Rust enum"] --> B{替代方案}
B --> C["方案一: Result<T, JsValue><br/>最简单,但错误类型受限"]
B --> D["方案二: 多个 struct + 枚举标签<br/>手写判别值,手动匹配"]
B --> E["方案三: Serde 序列化<br/>JSON 字符串或 serde-wasm-bindgen"]
style C fill:#10b981,color:#333
style D fill:#f59e0b,color:#333
style E fill:#6366f1,color:#fff
方案三(Serde 序列化)最灵活但开销最大——要么用 serde_json 把整个枚举值序列化为 JSON 字符串,传到 JS 后再 JSON.parse;要么用 serde-wasm-bindgen 在 Rust 侧逐个字段调 JS API,直接构造出 JS 对象。两条路都要为每个字段付一次转换。适合低频调用的复杂类型;高频场景建议方案一或二。
实际项目中更常见的做法是避免在 WASM 边界上传递带数据的枚举。Rust 侧的内部逻辑可以自由使用 enum,但暴露给 JS 的 API 应该是扁平化的——用多个方法替代一个 match。例如,把 enum Shape { Circle(f64), Rectangle(f64, f64) } 替换为 struct Shape { kind: ShapeKind, ... } + enum ShapeKind { Circle, Rectangle },然后通过 shape.kind() 和 shape.radius() / shape.width() 等方法分别访问数据。这种"扁平化"策略虽然不够 Rust-idiomatic,但在跨边界场景中更实用——它让 JS 调用者不需要理解 Rust 的模式匹配语义。
7.10 闭包与 Fn trait
Rust 闭包传递给 JS 作为回调是 wasm-bindgen 的高级功能。核心类型是 wasm_bindgen::closure::Closure:
flowchart LR
subgraph "Rust 侧"
A["Closure<F>"] --> B["Box<dyn FnMut><br/>数据指针 + vtable 指针"]
end
subgraph "JS 侧"
C["JS Function"] --> D["调用时进入<br/>WASM 导出的 invoke shim"]
end
B -->|"(a, b) 两个 i32"| C
D -->|"按 vtable 调用闭包"| B
style A fill:#6366f1,color:#fff
style C fill:#10b981,color:#333
Closure::once — 一次性回调
// web-sys 需开启 "Window"、"console" 两个 feature
use wasm_bindgen::prelude::*; // #[wasm_bindgen]、JsCast(unchecked_ref)
use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsValue;
use web_sys::window;
#[wasm_bindgen]
pub fn start_timer() {
let callback = Closure::once(move || {
web_sys::console::log_1(&JsValue::from_str("Timer fired!"));
});
let window = window().unwrap();
window
.set_timeout_with_callback_and_timeout_and_arguments_0(
callback.as_ref().unchecked_ref(),
1000,
)
.unwrap();
// forget() 的文档第一行就是「Same as `mem::forget(self)`」——src/closure.rs:569;
// 想把回收交给 JS GC 用 into_js_value()(文档从同文件 543 行起)
callback.forget(); // 泄漏闭包,避免被 drop
}
Closure::once 创建一个只能调用一次的闭包。要分清两个「交出去」的方法:forget() 的文档第一行就是「Same as mem::forget(self)」,也就是彻底泄漏——闭包和它捕获的东西永远不会被回收;想把回收责任交给 JS 的 GC 得用 into_js_value()(文档原话是把这个闭包的内存管理权从 Rust 交给 JS GC;两处出处都标在上面那段代码的注释里),在支持弱引用的宿主上 JS 那个函数被 GC 时才会连带回收 Rust 内存。上面这个一次性的 setTimeout 回调用 forget() 泄漏的量固定且很小,可以接受;放进循环就不行了。
Closure::wrap — 可重复调用的回调
#[wasm_bindgen]
pub struct EventHandler {
callback: Closure<dyn FnMut(web_sys::MouseEvent)>,
}
#[wasm_bindgen]
impl EventHandler {
#[wasm_bindgen(constructor)]
pub fn new() -> EventHandler {
let callback = Closure::wrap(Box::new(|event: web_sys::MouseEvent| {
web_sys::console::log_1(&format!("Click at ({}, {})",
event.client_x(), event.client_y()).into());
}) as Box<dyn FnMut(_)>);
EventHandler { callback }
}
pub fn attach(&self, element: &web_sys::Element) {
element
.add_event_listener_with_callback(
"click",
self.callback.as_ref().unchecked_ref(),
)
.unwrap();
}
}
Closure::wrap 创建可重复调用的闭包,但需要手动管理生命周期——当 JS 不再需要回调时,Rust 侧必须 drop 闭包以释放 WASM 内存。上面的 EventHandler 在被 free() 时自动 drop callback 字段,避免了泄漏。
Closure::once 和 Closure::wrap 的选择取决于回调的使用模式。如果回调只被调用一次(如 setTimeout 的回调、Promise 的 resolve/reject),使用 Closure::once + forget() 最简单。如果回调被多次调用(如事件监听器、动画帧回调),使用 Closure::wrap 并在适当时机 drop。一个常见的错误是对事件监听器使用 Closure::once——事件监听器会被多次触发,而一次性闭包第二次被调用时会抛出 FnOnce called more than once(src/convert/closures.rs:196);任何闭包在 Rust 侧被 drop 之后再从 JS 调用,则抛 closure invoked recursively or after being dropped(见 src/closure.rs:86 起的 Ownership Model 一节,原句在 91 行)。两条消息都经 node 实测,不是未定义行为,但功能同样是坏的。
0.2.118 里一个 Closure 由三部分组成(src/closure.rs、src/convert/closures.rs,并对照实际胶水):
- 闭包环境(captured variables):
Box<dyn FnMut>放在 WASM 线性内存的堆上,拆成(数据指针, vtable 指针)两个i32 - JS Function 对象:创建时 Rust 把这两个
i32交给 JS,胶水的makeMutClosure造一个函数,把它们存在state = { a, b, cnt }里;Rust 这一侧的Closure只持有这个函数的JsValue(externref 表里的一个槽位) - invoke shim:每种闭包签名单态化出一个导出函数(产物里名如
wasm_bindgen__convert__closures_____invoke__h<哈希>),JS 调用时把(a, b, 参数…)传进去,shim 还原出&mut dyn FnMut再调用
运行时不会往 WASM 函数表里添新条目:闭包体的函数指针本来就在编译期生成的 vtable 里(WASM 里函数指针就是函数表下标),shim 按 vtable 做一次 call_indirect。
graph TB
subgraph "WASM 线性内存"
A["闭包环境<br/>(captured vars)"]
V["vtable<br/>(闭包体的函数表下标)"]
end
subgraph "WASM 导出"
B["invoke shim<br/>按签名单态化"]
end
subgraph "JS 堆"
C["Function 对象<br/>state = { a, b }"]
end
C -->|"f(a, b, ...args)"| B
B -->|"call_indirect 经 vtable"| V
V --> D["执行闭包逻辑"]
D -->|"读取 captured vars"| A
style A fill:#6366f1,color:#fff
style C fill:#10b981,color:#333
invoke shim 就是常说的 trampoline(跳板):JS 的 Function 只能传参数,无法直接带上闭包上下文,于是由 JS 函数把自己记住的 (a, b) 一并传进 shim,再由 shim 把调用"弹"到正确的 Rust 闭包上。
这也是 Closure 比普通函数复杂的原因:普通导出函数只是一个导出名,Closure 需要一对指针(数据 + vtable)、一个 JS 函数对象,外加一个按签名生成的 shim。
闭包的性能开销
按上面三块内存拆开看,创建一个 Closure 要做的事明显多于一次普通调用:
| 操作 | 实际发生了什么 |
|---|---|
创建 Closure::once / Closure::wrap |
堆上分配闭包环境 + 跨边界在 JS 堆上造一个 Function + 占一个 externref 表槽位 |
| 调用闭包(JS→WASM) | 进一次 invoke shim + 一次 call_indirect + 参数的 ABI 转换 |
forget() |
只是 mem::forget,不做任何回收 |
| drop 一个闭包 | 跨边界调一次 JS 的 _wbg_cb_unref,引用计数归零时再回调 __wbindgen_destroy_closure 释放闭包环境 + 释放 externref 槽位 |
所以「创建」比「调用」贵一个层级是结构性的,而不是某个数字上的巧合。具体差多少必须在目标浏览器上测(§6.11)——如果 API 需要每帧创建/销毁回调,直接复用 Closure 对象,别每次新建。
7.11 指针类型
wasm-bindgen 支持裸指针类型 *const T 和 *mut T——它们直接映射为 i32(WASM 的地址空间是 32 位),JS 侧表现为 number:
#[wasm_bindgen]
pub fn read_at(ptr: *const u8, len: usize) -> u8 {
unsafe { *ptr }
}
#[wasm_bindgen]
pub fn write_at(ptr: *mut u8, value: u8) {
unsafe { *ptr = value };
}
指针类型是"零转换"的——但也是"零安全"的。JS 侧可以传入任意 number 作为指针值:超出线性内存范围会 trap(运行时错误);落在范围内却不指向合法对象,则会悄悄读写错误数据,这才是更危险的未定义行为。
指针类型适合两种场景:
- 高性能数据传递:绕过
wasm-bindgen的复制机制,直接操作线性内存 - 与 C FFI 兼容的 API:WASM 模块需要暴露 C 风格的接口时
但大多数场景下,使用 &[u8]/Vec<u8> 比裸指针更安全——wasm-bindgen 的复制开销通常可以接受。
指针类型还有一个微妙的使用方式——实现 WASM 侧和 JS 侧之间的共享状态。例如,Rust 侧在线性内存中分配一个结构体,把指针返回给 JS,JS 后续通过同一个指针调用方法来操作这个结构体。这正是 #[wasm_bindgen] struct 的底层实现方式——但使用裸指针时你需要手动管理内存生命周期(分配、释放、避免 use-after-free),而 #[wasm_bindgen] struct 通过 free() 方法封装了这个过程。除非你有特殊需求(如自定义内存分配策略),否则推荐使用 #[wasm_bindgen] struct 而非裸指针。
7.12 异步函数与 Promise 的映射
前面讨论的所有类型映射都是同步的——Rust 函数直接返回 JS 可用的值。但现代 Web 应用大量使用异步——fetch、IndexedDB、crypto.subtle 都返回 Promise。wasm-bindgen 通过 wasm-bindgen-futures 桥接 Rust 的 Future 和 JS 的 Promise——这是跨边界异步的核心机制。
Rust 侧 async fn → JS Promise
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
// web-sys 需开启 "Window"、"Request"、"RequestInit"、"Response" 四个 feature
use web_sys::{Request, RequestInit, Response};
#[wasm_bindgen]
pub async fn fetch_url(url: &str) -> Result<String, JsValue> {
let opts = RequestInit::new();
opts.set_method("GET"); // 旧的 opts.method(..) 带 #[deprecated = "Use `set_method()` instead."]
let request = Request::new_with_str_and_init(url, &opts)?;
let window = web_sys::window().unwrap();
let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?;
let resp: Response = resp_value.dyn_into()?;
let text = JsFuture::from(resp.text()?).await?;
Ok(text.as_string().unwrap_or_default())
}
JS 调用:
const text = await fetch_url("https://api.example.com/data");
底层机制:
flowchart LR
classDef rust fill:#fed,stroke:#c60
classDef js fill:#def,stroke:#06c
A[Rust async fn]:::rust --> B[编译为状态机]:::rust
B --> C[wasm-bindgen-futures]:::rust
C --> D[创建 JS Promise]:::js
D --> E[JS await 等待]:::js
E --> F[Future poll 推进]:::rust
F --> G[Promise resolve / reject]:::js
wasm-bindgen-futures 把 Rust Future 包装成 JS Promise——每次 Future::poll 推进状态、最终 resolve 或 reject。
JS Promise → Rust Future
反向也成立——JS 函数返回的 Promise 可以在 Rust 里 await:
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}
#[wasm_bindgen]
pub async fn process_data(promise: js_sys::Promise) -> Result<JsValue, JsValue> {
let value = JsFuture::from(promise).await?;
log(&format!("Got value: {:?}", value));
Ok(value)
}
JsFuture::from(promise) 把 JS Promise 包装为 Rust Future——await 时让出控制权给 JS event loop、Promise resolve 后恢复执行。
异步的执行模型
WASM 是单线程的——但通过 JS event loop 实现协作式异步:
- Rust async fn 编译成状态机
- Future poll 在 WASM 内执行
- 遇到
JsFuture::from(promise).await时让出控制权 - JS event loop 处理其他事情(如网络 I/O)
- Promise resolve 时回到 WASM 继续 poll
这不是真正的并发——是协作式调度。WASM 模块的多个 async 任务串行执行——一个任务在 await 时让出、另一个任务才有机会运行。
异步的常见陷阱
陷阱 1:忘记 wasm-bindgen-futures 依赖
[dependencies]
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4" # 必加
少了 wasm-bindgen-futures、async 函数无法编译。
陷阱 2:以为 spawn_local 会立刻跑
#[wasm_bindgen]
pub fn start_background() {
wasm_bindgen_futures::spawn_local(async {
// 这段代码会跑,但不是现在
});
// 这一行先执行完;上面的 async 块要等控制权回到 JS 之后才被 poll
}
同步函数里 spawn_local 是合法且可用的:它把任务塞进 crates/js-sys/src/futures/queue.rs 里那个队列(spawn_local 本体在同 crate 的 crates/js-sys/src/futures/mod.rs:85),再由 schedule_task()(queue.rs:63)用 queueMicrotask 安排一次 run_all——宿主没有 queueMicrotask 时退回到一个预先 Promise::resolve(&JsValue::undefined()) 出来的 promise 上挂 then(同文件 69-72、97 行)。所以任务一定会被 poll,只是要等当前这次 WASM 调用返回、控制权回到 JS 之后。真正的坑是它 fire-and-forget——没有 join handle、没有取消、任务里的错误也不会冒回调用者,需要自己在 async 块里兜住。
陷阱 3:spawn_local 里借用参数
下面是读者自己写的代码的示例,不是 wasm-bindgen 的源码。跨 await 持有共享借用本身没问题(导出的 pub async fn fetch_url(url: &str) 就能编过);真正会撞墙的是把借用塞进 spawn_local——它要求 Future: 'static:
// ❌ 编译错误 E0521:borrowed data escapes outside of function
#[wasm_bindgen]
pub fn start(s: &str) {
wasm_bindgen_futures::spawn_local(async move {
log(s);
});
}
// ✅ 先转成拥有所有权的 String 再 move 进去
#[wasm_bindgen]
pub fn start(s: &str) {
let s = s.to_owned();
wasm_bindgen_futures::spawn_local(async move {
log(&s);
});
}
两段分别用 wasm-bindgen 0.2.118 / wasm-bindgen-futures 0.4.68 实编过:前者报 E0521,后者通过(log 即上文从 console 导入的那个函数)。
取消异步任务
JS 的 Promise 没有内置取消机制——AbortController 是 fetch 等 API 的标准做法。wasm-bindgen 里的对应:让 JS 持有 AbortController,把它的 signal 传进 Rust:
// web-sys 另需开启 "AbortSignal" feature
use web_sys::AbortSignal;
#[wasm_bindgen]
pub async fn cancellable_fetch(url: &str, signal: &AbortSignal) -> Result<String, JsValue> {
let opts = RequestInit::new();
opts.set_signal(Some(signal)); // 同样地,opts.signal(..) 已 deprecated
let request = Request::new_with_str_and_init(url, &opts)?;
let window = web_sys::window().unwrap();
// signal 被 abort 时这个 Promise 会 reject,`?` 把错误原样抛回 JS
let resp: Response = JsFuture::from(window.fetch_with_request(&request)).await?.dyn_into()?;
let text = JsFuture::from(resp.text()?).await?;
Ok(text.as_string().unwrap_or_default())
}
const ac = new AbortController();
const p = cancellable_fetch(url, ac.signal);
ac.abort(); // fetch 被取消,p 随之 reject
JS 侧调用 abort() 取消 fetch——Rust 侧 await 的那个 JsFuture 会收到 reject。这是异步可取消性的标准模式。
异步的性能特征
- 创建
JsFuture:给 Promise 挂两个Closure(resolve/reject)并包一层Rc<RefCell<Inner>> await让出 + 恢复:一次微任务往返——任务重新入队、queueMicrotask再触发一遍run_all- async fn 状态机的内存:取决于捕获变量大小
也就是说,单次 await 的固定成本远高于一次普通函数调用。频繁 await 的场景延迟敏感——能批量化的尽量批量、避免在循环里逐个 await。具体数值按 §6.11 的方法自己测。
7.13 wasm-bindgen 的调试与错误诊断
跨边界调用难以调试——错误可能源于 Rust、JS、或边界本身。这节给 wasm-bindgen 的调试工具箱。
常见错误类型
flowchart TB
classDef err fill:#fdd,stroke:#c00
E[wasm-bindgen 错误]
E --> E1[类型签名不匹配]:::err
E --> E2[内存访问越界]:::err
E --> E3[Closure 泄漏 / 提前 drop]:::err
E --> E4[Promise reject 未处理]:::err
E --> E5[版本不兼容]:::err
每类错误的症状不同——识别错的种类是定位的第一步。
类型签名不匹配
错误:JS 传入的参数类型和 Rust 期望的不符。
#[wasm_bindgen]
pub fn parse(s: &str) -> i32 { s.parse().unwrap_or(0) }
parse(42); // ❌ 数字传给 &str
// 默认(发布版)胶水不做类型检查,结果取决于错误值怎么被当成字符串处理,
// 0.2.118 + node 实测这一例得到 RuntimeError: memory access out of bounds;
// 用 wasm-bindgen --debug 生成的胶水才会先检查:
// Error: expected a string argument, found number
诊断:
- 开发期用
wasm-bindgen --debug(wasm-pack build --dev会带上它)生成带类型断言的胶水 - 看错误堆栈、找到
passStringToWasm0等转换函数 - 检查 TypeScript 声明(
.d.ts文件)匹配 Rust 签名 - 启用
console_error_panic_hookcrate,把 Rust panic 信息打到console.error(之后仍以unreachabletrap 结束)
// Cargo.toml: console_error_panic_hook = "0.1"
#[wasm_bindgen(start)]
pub fn init() {
console_error_panic_hook::set_once();
}
内存访问越界
错误:RuntimeError: memory access out of bounds——典型是裸指针误用:
#[wasm_bindgen]
pub fn read_at(ptr: *const u8) -> u8 {
unsafe { *ptr } // 如果 ptr 无效、trap
}
read_at(0xDEADBEEF); // ❌ 无效地址
// RuntimeError: memory access out of bounds
诊断:
- 要看到 Rust 行号得保留 DWARF:wasm-bindgen 默认会剥掉调试段,需加
--keep-debug(wasm-pack 里是[package.metadata.wasm-pack.profile.dev.wasm-bindgen]下的dwarf-debug-info = true,dev 档默认也是关的),再用支持 DWARF 的 DevTools 扩展查看 - 检查指针来源——确保来自 WASM 模块自己分配的内存
wasm-pack build --dev构建(--debug在 wasm-pack 0.14 里已标为「Deprecated. Renamed to--dev」):不做优化、保留断言,胶水带--debug类型检查
Closure 生命周期
最常见的错误——使用已 drop 的 closure:
fn buggy() {
let cb = Closure::wrap(Box::new(|| log("hello")) as Box<dyn Fn()>);
set_callback(cb.as_ref().unchecked_ref());
// cb 在函数返回时 drop——回调时已失效
}
闭包被 drop 后再从 JS 调用,会抛出 closure invoked recursively or after being dropped(src/closure.rs 的 Ownership Model 一节写明了这条消息)。
修复:
cb.forget()让 closure 永远存活(小心内存泄漏)- 或 closure 持有到合适的生命周期(如 struct 字段)
Promise reject 未处理
#[wasm_bindgen]
pub async fn risky() -> Result<JsValue, JsValue> {
let result = JsFuture::from(maybe_failing_promise()).await?;
Ok(result)
}
risky(); // ❌ 没 catch、unhandled rejection
诊断:
- 浏览器 console 看 "Uncaught (in promise)" 警告
- Node.js
process.on('unhandledRejection', ...)监听 - 始终
try/catch或.catch()处理 reject
版本不兼容
wasm-bindgen 的 Rust crate 和 CLI 工具版本要按精确一致来管:
# Cargo.toml
wasm-bindgen = "=0.2.118"
# 必须用同版本的 CLI
cargo install wasm-bindgen-cli --version 0.2.118 --locked
不一致时报错(0.2.117 的产物交给 0.2.118 CLI 实测,节选开头):
it looks like the Rust project used to create this Wasm file was linked against
version of wasm-bindgen that uses a different bindgen format than this binary:
rust Wasm file schema version: 0.2.117
this binary schema version: 0.2.118 (753bb7ff4)
严格说 CLI 比对的是 wasm_bindgen_shared::SCHEMA_VERSION(宏把它写进自定义段开头那段 JSON,crates/cli-support/src/wit/mod.rs 的 verify_schema_matches),schema 只在格式变化时才改——0.2.116、0.2.117 里它都还是 "0.2.115",所以这两版之间 CLI 不会报错;报错信息里打印的却是 crate 版本号(JSON 里的 version 键)。哪两个版本恰好兼容不值得去赌,工程上一律按精确匹配处理(§6.10)。
修复:固定版本、用 cargo install --locked 避免漂移。
调试工具
Browser DevTools:
- 保留 DWARF(见上文
--keep-debug)并装上 DWARF 调试扩展,stack trace 和断点才能落到 Rust 源码行 - Performance tab 看 WASM 调用开销
- Memory tab 看 WASM 线性内存
wasm-tools:
wasm-tools print module.wasm # 看 WAT 文本
wasm-tools dump module.wasm # 看二进制结构
Rust 侧调试:
use web_sys::console;
#[wasm_bindgen]
pub fn debug_me() {
console::log_1(&"checkpoint 1".into());
let x = compute();
console::log_2(&"x =".into(), &x.into());
}
web_sys::console::log_* 是 WASM 里 println! 的等价。
Profile 性能瓶颈
// 用 performance API 测 WASM 调用开销
const start = performance.now();
const result = my_wasm_fn(input);
const end = performance.now();
console.log(`took ${end - start} ms`);
频繁跨边界调用是性能瓶颈——用 profiler 定位、考虑批量化。
单元测试
wasm-bindgen-test 提供 WASM 单元测试支持:
// Cargo.toml: wasm-bindgen-test = "0.3"
use wasm_bindgen_test::*;
#[wasm_bindgen_test]
fn test_string_roundtrip() {
let s = "测试中文";
let result = process_string(s);
assert_eq!(result, "测试中文 处理完成");
}
wasm-pack test --headless --chrome
测试在真实浏览器里跑——能测到 wasm-bindgen 的边界行为。
调试的最佳实践
- 始终启用
console_error_panic_hook - 开发时用
wasm-pack build --dev(不优化、保留断言、胶水带类型检查) - 生产前跑完整 unit test
- 用 TypeScript 在 JS 侧做类型检查
- 换全局分配器时留意:老教程里的
wee_alloc已停止维护(仓库归档、有未修复的泄漏),要换就换talc、lol_alloc这类还在维护的(§5.6)
7.14 与 JS 原生对象的互操作
实际项目中频繁需要操作 JS 原生对象——Date、RegExp、Map、Set、Promise、URL 等。js-sys crate 为这些对象提供了 Rust 绑定——但用法和直接处理 JsValue 不同,理解差异有助于写出更自然的代码。
7.14.1 js-sys 提供的类型谱系
graph TD A["js-sys"] --> B["基础对象"] A --> C["集合"] A --> D["异步"] A --> E["其他"] B --> B1["js_sys::Object<br/>js_sys::Function<br/>js_sys::Symbol"] C --> C1["js_sys::Array<br/>js_sys::Map<br/>js_sys::Set<br/>js_sys::WeakMap"] D --> D1["js_sys::Promise<br/>js_sys::AsyncIterator<br/>js_sys::Generator"] E --> E1["js_sys::Date<br/>js_sys::RegExp<br/>js_sys::JSON<br/>js_sys::Number<br/>js_sys::BigInt"] style B fill:#10b981,color:#333 style C fill:#6366f1,color:#fff style D fill:#f59e0b,color:#333 style E fill:#dc2626,color:#fff
每种类型都沿 JS 的继承链实现 Deref(如 Date 声明了 extends = Object,于是 Deref<Target = Object>,Object 再 Deref 到 JsValue),所以都能经自动解引用当作 &JsValue 用(传给接受 &JsValue 的 API),也可以用专属方法(Date::now、Array::push 等)。
7.14.2 Date 与 Rust chrono 的互转
JS 的 Date 内部是 Unix 毫秒时间戳——和 Rust 的时间类型互转直接:
use js_sys::Date;
use chrono::{DateTime, TimeZone, Utc};
#[wasm_bindgen]
pub fn js_date_to_rust(date: &Date) -> String {
// Date.getTime() 返回毫秒
let ms = date.get_time() as i64;
let dt: DateTime<Utc> = Utc.timestamp_millis_opt(ms).unwrap();
dt.to_rfc3339()
}
#[wasm_bindgen]
pub fn rust_time_to_js(rfc3339: &str) -> Result<Date, JsValue> {
let dt = DateTime::parse_from_rfc3339(rfc3339)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(Date::new(&JsValue::from_f64(dt.timestamp_millis() as f64)))
}
陷阱:JS 的 Date 本身不存时区,只是一个 UTC 毫秒数,但 getHours() 这类方法按宿主的本地时区解释——Rust 侧用 chrono::DateTime<Utc> 而不是 NaiveDateTime 避免歧义。
7.14.3 Map/Set:高性能键值集合
当 Rust 侧的 HashMap 需要传给 JS 时,常见做法是交给 serde-wasm-bindgen——它默认就把 map 序列化成 JS Map(0.6.5 实测;想要普通对象得用 Serializer::serialize_maps_as_objects(true))。键值结构简单、又想精确控制构造过程时,也可以手写 js_sys::Map:
| 选择 | 适用 | 性能 |
|---|---|---|
serde-wasm-bindgen::to_value |
嵌套结构、不想手写转换 | 按字段逐个调 JS API 构造,有序列化开销 |
js_sys::Map 手写构造 |
结构简单、需要精确控制 | 每个条目一次 Map.set 跨边界调用 |
键、值拆成两个平行的 Vec |
单次传递、不需要查询 | 数值向量整块复制,但 JS 侧要自己重建 |
use js_sys::Map;
#[wasm_bindgen]
pub fn build_index(items: Vec<String>) -> Map { // &[String] 不能作参数:[String] 没有 RefFromWasmAbi
let map = Map::new();
for (i, item) in items.iter().enumerate() {
map.set(&JsValue::from_str(item), &JsValue::from_f64(i as f64));
}
map
}
JS 侧直接拿到 Map 实例,可以按键查询:
const idx = build_index(items);
console.log(idx.get('foo')); // O(1) 查询
7.14.4 RegExp 的跨边界使用
正则表达式是另一个跨边界优化点——regex crate 会把 DFA 引擎和 Unicode 表一起编进 .wasm,是体积上肉眼可见的一块;而 JS 的 RegExp 是引擎内置的,占 .wasm 零字节:
use js_sys::RegExp;
#[wasm_bindgen]
pub fn extract_emails(text: &str) -> Vec<String> {
let re = RegExp::new(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "g");
let mut results = Vec::new();
let mut current = re.exec(text);
while let Some(m) = current {
results.push(m.get(0).as_string().unwrap_or_default());
current = re.exec(text);
}
results
}
vs 引入 regex crate:
| 方案 | 体积影响 | 适用 |
|---|---|---|
js_sys::RegExp |
不占 .wasm 体积 |
简单正则、体积敏感;代价是每次匹配都跨一次边界 |
Rust regex crate |
最大:DFA 引擎 + Unicode 表 | 复杂正则、单次调用里做大量匹配 |
Rust regex-lite crate |
明显小于 regex(砍掉了 Unicode 表和 DFA 优化) |
简单正则 + 体积敏感 |
体积敏感的浏览器项目优先考虑 js_sys::RegExp。具体省多少、快多少,用 twiggy 和自己的基准去量(第 9 章)——不要照抄任何一篇文章里的 KB 数。
7.14.5 Reflect API:泛化的对象操作
js_sys::Reflect 提供动态属性访问——当不知道对象的具体结构时使用:
use js_sys::Reflect;
#[wasm_bindgen]
pub fn extract_field(obj: &JsValue, key: &str) -> Result<JsValue, JsValue> {
Reflect::get(obj, &JsValue::from_str(key))
}
#[wasm_bindgen]
pub fn set_field(obj: &JsValue, key: &str, value: &JsValue) -> Result<bool, JsValue> {
Reflect::set(obj, &JsValue::from_str(key), value)
}
Reflect::get/set 返回 Result——失败时返回错误而不是 panic,比直接的属性访问安全。
7.15 类型映射的反模式
新手常踩的几个坑——这些反模式编译时不报错,但要么性能糟糕,要么语义不正确。
7.15.1 反模式:在循环中传递 String
// 反模式:JS 循环里逐个调用导出的 transform(s: &str) -> String
for (let i = 0; i < 1000; i++) {
results.push(rust.transform(items[i])); // 1000 次跨边界调用,每次各复制进、复制出一个字符串
}
修复一:批量化,一次调用传完:
#[wasm_bindgen]
pub fn process_items(items: Vec<String>) -> Vec<String> {
items.iter().map(|s| transform(s)).collect()
}
字符串本身仍要逐个复制,但跨边界调用从 1000 次降到 1 次。
修复二:同一批字符串要反复处理时,让 Rust 侧持有它们,只在加入时复制一次:
#[wasm_bindgen]
pub struct Processor {
items: Vec<String>,
}
#[wasm_bindgen]
impl Processor {
pub fn add_item(&mut self, s: String) { self.items.push(s); }
pub fn process_all(&self) -> Vec<String> {
self.items.iter().map(|s| transform(s)).collect()
}
}
7.15.2 反模式:误用 &str 期望生命周期
// 反模式:返回引用——编译错但即使能编译也不安全
// pub fn get_first(&self) -> &str { &self.items[0] }
&str 不能作为 #[wasm_bindgen] 函数的返回类型——仓库里 guide/src/reference/types/str.md 的支持矩阵写得很直白:str 只有「&T parameter」是 Yes,「T return value」是 No。所有跨边界返回都必须拥有所有权,老老实实返回 String。
7.15.3 反模式:JsValue 当 String 用
// 反模式:类型已知却包成 JsValue 返回
fn bad(name: String) -> JsValue {
JsValue::from_str(&name) // 开销和直接返回 String 相当,类型却在 .d.ts 里退化成 any
}
// 直接返回 String 即可
fn good(name: String) -> String { name }
JsValue 只在确实需要"任意 JS 类型"时才用——Rust 类型已知时直接用 Rust 类型,wasm-bindgen 会自动选择最佳的 ABI。
7.15.4 反模式:闭包泄漏
// 反模式:每次调用都创建新闭包,旧闭包永不释放
#[wasm_bindgen]
pub fn setup_handler() {
let cb = Closure::wrap(Box::new(|| { /* ... */ }) as Box<dyn FnMut()>);
add_listener(&cb);
cb.forget(); // ← 内存永远泄漏
}
修复:用结构体持有闭包,drop 时自动清理:
#[wasm_bindgen]
pub struct Handler {
_cb: Closure<dyn FnMut()>,
}
#[wasm_bindgen]
impl Handler {
#[wasm_bindgen(constructor)]
pub fn new() -> Handler {
let cb = Closure::wrap(Box::new(|| { /* ... */ }) as Box<dyn FnMut()>);
add_listener(&cb);
Handler { _cb: cb }
}
// Drop 时 _cb 被释放:Rust 侧闭包环境回收,JS 那边再触发就抛
// "closure invoked recursively or after being dropped"。
// 注意它**不会**帮你 remove_event_listener——监听器还挂在 DOM 上,
// 要干净卸载得自己在 Drop 里调一次 remove_event_listener_with_callback。
}
7.15.5 反模式:忽视 Result 的 JsValue 错误
// 反模式:unwrap 导致 trap,JS 侧看到 unreachable
#[wasm_bindgen]
pub fn parse(s: &str) -> i32 {
s.parse().unwrap() // panic → trap → JS 抛 RuntimeError
}
// 应该返回 Result<i32, JsValue>
#[wasm_bindgen]
pub fn parse_checked(s: &str) -> Result<i32, JsValue> {
s.parse::<i32>().map_err(|e| JsValue::from_str(&e.to_string()))
}
panic 引发的 WASM trap 在 V8(Chrome、Node)中表现为 RuntimeError: unreachable——不装 console_error_panic_hook 时连 panic 信息都看不到。返回 Result<T, JsValue> 让 JS 侧能用 try/catch 优雅处理。
7.15.6 反模式速查表
graph TD A["反模式"] --> B["数据传递层"] A --> C["生命周期层"] A --> D["错误处理层"] B --> B1["循环中传 String"] B --> B2["JsValue 包装已知类型"] C --> C1["返回 &str"] C --> C2["闭包 forget 泄漏"] D --> D1["unwrap 导致 trap"] style A fill:#dc2626,color:#fff
每个反模式都有简单的修复——理解 wasm-bindgen 的边界本质(拥有权 + 序列化 + JS GC 协作)就能避免大多数问题。
7.16 跨边界泛型与 Trait 的工程模式
#[wasm_bindgen] 不能直接导出泛型函数和 trait——这条限制在 §6.13 提过。但实际项目中"我有一组类似的处理函数,想避免写 N 份"的需求很常见。下面是从生产中提炼的几种模式。
7.16.1 模式一:手动单态化 + 命名导出
最直接的做法是手动为每种类型写一个具体函数:
fn process<T: Process>(item: T) -> String { item.process() }
// 手动为每种类型导出
#[wasm_bindgen]
pub fn process_user(item: User) -> String { process(item) }
#[wasm_bindgen]
pub fn process_order(item: Order) -> String { process(item) }
#[wasm_bindgen]
pub fn process_product(item: Product) -> String { process(item) }
适合:类型数量固定(< 5 种)、JS 侧需要明确知道调用哪个函数。
7.16.2 模式二:宏自动生成导出
类型多时,手写重复——用宏批量生成:
macro_rules! export_processor {
($($name:ident => $type:ty),*) => {
$(
paste::paste! {
#[wasm_bindgen]
pub fn [<process_ $name>](item: $type) -> String {
process(item)
}
}
)*
};
}
export_processor! {
user => User,
order => Order,
product => Product
}
paste crate 把 process_$name 拼成实际标识符。展开后等价于手写三个 pub fn。
7.16.3 模式三:tag + JSON 的动态分发
如果类型是动态的(运行时才知道是哪种),用 JSON 序列化 + 标签字段:
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")] // 否则标签得写 "User",JS 传 "user" 会报 unknown variant
enum Item {
User { name: String, age: u32 },
Order { id: String, total: f64 },
Product { sku: String, price: f64 },
}
#[wasm_bindgen]
pub fn process_dynamic(json: &str) -> Result<String, JsValue> {
let item: Item = serde_json::from_str(json)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(match item {
Item::User { name, age } => format!("user: {name} ({age})"),
Item::Order { id, total } => format!("order: {id} ${total}"),
Item::Product { sku, price } => format!("product: {sku} ${price}"),
})
}
JS 侧:
process_dynamic(JSON.stringify({ type: 'user', name: 'Alice', age: 30 }));
process_dynamic(JSON.stringify({ type: 'order', id: 'X', total: 99.9 }));
代价:每次调用都要付一次「JS 侧 JSON.stringify + 跨边界传字符串 + Rust 侧 serde_json 反序列化」。适合:类型多变、调用频率不高的场景。
7.16.4 模式四:Trait Object 的句柄表
需要"动态选择实现"且性能敏感时,用句柄表(类似 §6.13):
graph LR A["JS 侧调用"] --> B["create_processor 拿到 id"] B --> C["全局 HashMap<id, Box<dyn Process>>"] C --> D["dispatch_by_id 调用具体实现"] style C fill:#10b981,color:#333
trait Processor: Send + Sync {
fn process(&self, input: &str) -> String;
}
struct UserProcessor;
impl Processor for UserProcessor {
fn process(&self, input: &str) -> String { format!("user: {input}") }
}
struct OrderProcessor;
impl Processor for OrderProcessor {
fn process(&self, input: &str) -> String { format!("order: {input}") }
}
thread_local! {
static REGISTRY: RefCell<HashMap<u32, Box<dyn Processor>>> = RefCell::new(HashMap::new());
static NEXT_ID: Cell<u32> = Cell::new(0);
}
#[wasm_bindgen]
pub fn create_processor(kind: &str) -> Result<u32, JsValue> {
let proc: Box<dyn Processor> = match kind {
"user" => Box::new(UserProcessor),
"order" => Box::new(OrderProcessor),
_ => return Err(JsValue::from_str("unknown kind")),
};
NEXT_ID.with(|c| {
let id = c.get();
c.set(id + 1);
REGISTRY.with(|r| r.borrow_mut().insert(id, proc));
Ok(id)
})
}
#[wasm_bindgen]
pub fn process_by_id(id: u32, input: &str) -> Result<String, JsValue> {
REGISTRY.with(|r| {
let r = r.borrow();
let proc = r.get(&id).ok_or_else(|| JsValue::from_str("invalid id"))?;
Ok(proc.process(input))
})
}
#[wasm_bindgen]
pub fn destroy_processor(id: u32) {
REGISTRY.with(|r| r.borrow_mut().remove(&id));
}
JS 侧:
const userProc = create_processor('user');
const orderProc = create_processor('order');
console.log(process_by_id(userProc, 'Alice'));
console.log(process_by_id(orderProc, 'X-001'));
destroy_processor(userProc);
destroy_processor(orderProc);
性能:跨边界只传一个 u32 和输入字符串,省掉了 JSON 模式那一整轮序列化/反序列化;Rust 侧是一次 HashMap 查找加一次 vtable 调用,都是 O(1)。代价:JS 侧必须显式 destroy 释放,否则内存泄漏。
7.16.5 模式五:生成 wasm-bindgen 风格的 class
如果业务上"trait object"本质是"对象",直接导出 #[wasm_bindgen] struct 是最自然的:
#[wasm_bindgen]
pub struct UserProcessor;
#[wasm_bindgen]
impl UserProcessor {
#[wasm_bindgen(constructor)]
pub fn new() -> UserProcessor { UserProcessor }
pub fn process(&self, input: &str) -> String {
format!("user: {input}")
}
}
#[wasm_bindgen]
pub struct OrderProcessor;
#[wasm_bindgen]
impl OrderProcessor {
#[wasm_bindgen(constructor)]
pub fn new() -> OrderProcessor { OrderProcessor }
pub fn process(&self, input: &str) -> String {
format!("order: {input}")
}
}
JS 侧获得自然的对象语义:
const u = new UserProcessor();
console.log(u.process('Alice'));
u.free();
const o = new OrderProcessor();
console.log(o.process('X-001'));
o.free();
每个 struct 是独立的 class——TypeScript 类型检查、IDE 智能提示都自然工作。代价:不能在运行时动态选择 class(必须 JS 侧用 if-else 分发)。
7.16.6 模式选择决策
flowchart TD
A["要导出泛型/trait"] --> B{"类型数量?"}
B -->|< 5 固定| C["手动单态化"]
B -->|5-20 固定| D["宏批量生成"]
B -->|动态、不可枚举| E{"性能敏感?"}
E -->|否| F["JSON tag 分发"]
E -->|是| G["句柄表 trait object"]
A --> H{"本质是对象?"}
H -->|是| I["#[wasm_bindgen] struct/class"]
style I fill:#10b981,color:#333
style D fill:#6366f1,color:#fff
style G fill:#f59e0b,color:#333
style F fill:#dc2626,color:#fff
多数"想导出泛型"的场景实际可以重构为 #[wasm_bindgen] struct——JS 侧的对象语义比强制泛型更自然。剩下的才需要 JSON tag 或句柄表模式。
7.17 与其他跨语言绑定技术的对比
wasm-bindgen 不是 Rust 生态唯一的跨语言绑定方案——cxx(Rust↔C++)、pyo3(Rust↔Python)、neon(Rust↔Node.js)等都解决类似问题。理解它们的设计差异有助于把 wasm-bindgen 放在合适的位置评判。
7.17.1 横向对比表
| 技术 | 目标 | ABI 类型 | 类型表达力 | 代际 |
|---|---|---|---|---|
wasm-bindgen |
Rust ↔ JS(WASM 边界) | 自定义 + ABI 特化 | 高 | 当前生态主流 |
cxx |
Rust ↔ C++ | C ABI + 编译时验证 | 高 | 成熟 |
pyo3 |
Rust ↔ Python | CPython C API | 中 | 成熟 |
neon |
Rust ↔ Node.js | N-API | 中 | 成熟 |
napi-rs |
Rust ↔ Node.js | N-API + macro | 中 | 上升 |
uniffi |
Rust ↔ Swift/Kotlin/Python | UDL 或过程宏 | 中 | 生产可用(Firefox 在用),尚未 1.0 |
7.17.2 设计哲学的差异
graph TD A["跨语言绑定方案"] --> B["静态绑定生成"] A --> C["动态运行时桥接"] B --> B1["wasm-bindgen<br/>cxx<br/>uniffi"] C --> C1["pyo3 (CPython API)<br/>neon (N-API)"] B --> B2["构建期生成两端胶水<br/>双方签名在构建期对齐"] C --> C2["只生成 Rust 一侧<br/>对端值在运行时检查类型"] style B fill:#10b981,color:#333 style C fill:#6366f1,color:#fff
静态 vs 动态是核心分类:
- 静态绑定(wasm-bindgen / cxx / uniffi):构建期同时生成 Rust 侧和对端(JS 胶水、C++ 头文件、Swift/Kotlin 代码)的桥接代码,两边的签名在构建期就对齐
- 动态桥接(pyo3 / neon):同样靠过程宏在编译期生成 Rust 侧代码,但对端是 CPython / N-API 的 C API,Python/JS 传来的值是动态对象,类型转换和检查在运行时处理
wasm-bindgen 选择静态——这是 WASM 边界的特殊约束(线性内存只能传基础类型)逼出来的,但反过来给 Rust 一侧的开发体验加分。
7.17.3 类型表达力对比
某些复杂场景的支持程度:
| 场景 | wasm-bindgen | cxx | pyo3 | neon |
|---|---|---|---|---|
| 异步函数 | ✓ wasm-bindgen-futures | ✗ 尚未实现,官方建议用 oneshot channel 绕 | ✓ pyo3-asyncio | ✓ Promise |
| 闭包跨边界 | ✓ Closure | △ 通过 trait object | ✓ PyAny callable | ✓ JsFunction |
| Trait object | ✗ 必须重写 | ✓ rust::Box<dyn> |
△ 句柄 | △ 句柄 |
| 泛型函数 | ✗ 必须单态化 | ✗ 必须单态化 | ✗ 同 | ✗ 同 |
| 自定义错误 | ✓ Result<T, JsValue> |
✓ Result<T, E> |
✓ PyResult | ✓ JsResult |
| 序列化(serde) | ✓ serde-wasm-bindgen | ✗ 手动 | ✓ pythonize | ✓ neon-serde |
每个方案都有自己的妥协。泛型是四家共同的限制:单态化发生在 Rust 编译期,对端没法按需实例化新的类型参数。trait object 则是 wasm-bindgen 特有的缺口——JS 侧没有 Rust vtable 的对应物,只能退回句柄或具体 struct。
7.17.4 性能特征
跨语言调用的开销差异,本质上由「边界上要做多少工作」决定,按开销从小到大:
| 技术 | 每次调用要额外做的事 |
|---|---|
cxx |
几乎没有——C++ 和 Rust 共享同一套 C ABI 和同一个地址空间,字符串/切片可以直接借用 |
wasm-bindgen |
跨线性内存边界:malloc + UTF-8 编解码 + 复制 + free(§6.11) |
neon / napi-rs |
走 N-API 的 C 函数,V8 值要装箱/拆箱,字符串同样要复制 |
pyo3 |
CPython 的对象模型:引用计数、GIL 获取/释放、PyObject 装箱 |
跨地址空间的复制(WASM 线性内存与 JS 堆)是主要成本,能共享地址空间的方案自然最便宜。具体数字不要照抄——各方案的基准都强依赖数据大小和宿主版本,要比就在自己的负载上比。
7.17.5 学习曲线对比
graph LR A["Rust 工程师学习"] --> B["wasm-bindgen<br/>要懂:JS 胶水、线性内存"] A --> C["cxx<br/>要懂:C++ 类型与所有权"] A --> D["pyo3<br/>要懂:CPython 对象模型、GIL"] A --> E["neon<br/>要懂:N-API、JS 值生命周期"] style B fill:#10b981,color:#333 style C fill:#6366f1,color:#fff style D fill:#f59e0b,color:#333 style E fill:#6366f1,color:#fff
上手难度主要取决于要补多少对端运行时的知识:wasm-bindgen 的对端是浏览器里的 JS,大多数 Web 开发者已经熟悉;pyo3 则必须理解 CPython 的对象模型、GIL 机制、生命周期管理,门槛更高。
7.17.6 选择决策
flowchart TD
A["需要 Rust 与其他语言互操作"] --> B{"目标语言?"}
B --> C["JS / TS(浏览器)"]
B --> D["JS / TS(Node.js)"]
B --> E["C++"]
B --> F["Python"]
B --> G["Swift / Kotlin"]
B --> H["多语言 + WASM 部署"]
C --> I["wasm-bindgen"]
D --> J["napi-rs(推荐)<br/>或 wasm-bindgen + Node 模式"]
E --> K["cxx"]
F --> L["pyo3"]
G --> M["uniffi"]
H --> N["wit-bindgen"]
style I fill:#10b981,color:#333
style N fill:#6366f1,color:#fff
每个目标语言有最佳工具——不要勉强跨界使用。例如想"用 wasm-bindgen 调 Python"是没意义的,应该直接用 pyo3。
7.17.7 共同的设计模式
虽然实现不同,所有跨语言绑定都共享几个核心模式:
| 模式 | 体现 |
|---|---|
| 过程宏自动生成 | wasm-bindgen #[wasm_bindgen] / pyo3 #[pyfunction] / napi-rs #[napi] |
| 句柄表管理跨语言对象 | 所有方案都有"OpaqueRef"或类似机制 |
| 错误用 Result 跨边界 | Rust 标准做法,所有方案保留 |
| 零拷贝优化(视图/引用) | wasm-bindgen Uint8Array 视图、cxx Slice、pyo3 PyBuffer |
| 生命周期约束 | 引用不能随意跨边界:wasm-bindgen 只能返回拥有所有权的值,cxx 允许返回引用但要在签名里写明生命周期 |
理解这些共同模式后,学新方案的成本大幅降低——核心思想都一样,只是语法和约束不同。
7.18 类型映射的演进:从 wasm-bindgen 到 Component Model
wasm-bindgen 是 Rust + JS 生态的当前事实标准——但 WebAssembly 社区组(W3C CG)推进的 Component Model 是更长远的方向。理解这两者的关系和迁移路径,是做长期技术规划的基础。
7.18.1 两套类型系统的根本差异
graph TD A["wasm-bindgen<br/>类型系统"] --> B["针对 JS 优化"] A --> C["运行时是 JS 引擎"] A --> D["ABI 是工具特化"] E["Component Model<br/>类型系统"] --> F["语言无关"] E --> G["运行时是组件 runtime"] E --> H["ABI 是 Canonical ABI(标准)"] style A fill:#10b981,color:#333 style E fill:#6366f1,color:#fff
设计目标的差异:
| 维度 | wasm-bindgen | Component Model |
|---|---|---|
| 目标 | Rust → JS 边界 | 任意语言 ↔ 任意语言 |
| 标准化 | 社区事实标准 | W3C WebAssembly CG 提案(Phase 1,2026-09-24 核对),尚非 W3C 标准 |
| 类型表达力 | 偏向 JS 的语义 | 抽象的类型系统 |
| 性能 | JS 原生(直接操作 JsValue) | Canonical ABI(lift/lower) |
| 工具链 | 成熟(2018 年至今) | 服务端可用、浏览器无原生实现 |
7.18.2 类型映射对照
每种类型在两个系统中的表达:
| Rust 类型 | wasm-bindgen | Component Model WIT |
|---|---|---|
String |
JS string(直接) | string |
Vec<u8> |
Uint8Array |
list<u8> |
Option<T> |
T | undefined |
option<T> |
Result<T, E> |
throws on err | result<T, E> |
C 风格 enum |
数字常量对象(Object.freeze);字符串 enum 是字面量联合类型 |
enum(带数据的对应 variant) |
struct |
class | record |
| 生命周期对象 | class with free() |
resource |
| 闭包 | Closure<...> |
暂无原生支持 |
| 异步函数 | async fn + Promise |
async func(Preview 3) |
7.18.3 性能差异
两者的开销差异是结构性的,不是实现质量的差异:
- wasm-bindgen 针对 JS 单一目标做特化——能传句柄的地方就传句柄(
JsValue只是一个索引),字符串走的是一条为 JS 引擎调优过的路径(ASCII 快路径、缓存的TextEncoder/TextDecoder)。 - Component Model 要在任意两种语言之间通用,所以规定了 Canonical ABI:值必须 lower 到线性内存的标准布局、再由对端 lift 回去,中间那一层编码是省不掉的(第 14 章展开)。
也就是说 Component Model 为通用性付了一笔固定的「翻译费」。这笔费用具体值多少,取决于数据形状和运行时实现,本专栏不给数字——要比较就在自己的接口和目标运行时上测。
7.18.4 演进时间线
已经发生的几件事(其余都是推测,本专栏不做时间表预言):
| 时间 | 事件 |
|---|---|
| 2018 | wasm-bindgen 发布,此后一直是 Rust + 浏览器的事实标准 |
| 2024-01 | WASI Preview 2 发布,Component Model 与 WIT 首次有了稳定的对外版本 |
| 2024 | Rust 新增 wasm32-wasip1 / wasm32-wasip2 目标,wasm32-wasi 旧名在 1.84 移除 |
| 至今 | 主流浏览器仍没有原生的组件模型实现,浏览器侧要用组件必须先经工具链降级成核心模块 |
关键判断只有一条:服务端和 CLI 已经可以认真用 Component Model,浏览器侧还不行。
7.18.5 当前的工程选择
flowchart TD
A["选择类型映射方案"] --> B{"目标环境?"}
B --> C["浏览器"]
B --> D["服务器端"]
B --> E["未来兼容"]
C --> F["wasm-bindgen<br/>浏览器侧的现实选择"]
D --> G{"多语言互操作?"}
G -->|是| H["Component Model + WIT"]
G -->|否(仅 Rust)| I["wasm-bindgen 或 cxx"]
E --> J["关注 Component Model<br/>但不立即迁移"]
style F fill:#10b981,color:#333
style H fill:#6366f1,color:#fff
现实是:浏览器里没有原生的组件模型实现,面向浏览器的 Rust + WASM 工作基本只能用 wasm-bindgen。服务器端可以用 Component Model,但 crate 生态的成熟度还不如 wasm-bindgen。
7.18.6 迁移路径的工程考虑
flowchart LR
A["现有 wasm-bindgen 项目"] --> B{"业务需要?"}
B -->|稳定运行| C["不动<br/>等 Component Model 浏览器主流"]
B -->|需要多语言| D["渐进引入 Component Model"]
B -->|新项目| E["Component Model + wasm-bindgen 共存"]
D --> F["核心模块用 WIT"]
D --> G["浏览器交互仍用 wasm-bindgen"]
style C fill:#10b981,color:#333
style D fill:#f59e0b,color:#333
迁移不是非此即彼——可以共存:
- 核心业务代码用 WIT 接口(语言无关)
- 浏览器特定的 UI 交互用 wasm-bindgen
- 服务器端用 Component Model 调用核心模块
这种混合策略让团队可以"两条腿走路"——既保留 wasm-bindgen 生态成熟度的红利,又开始投资 Component Model 的未来。
7.18.7 wasm-bindgen 长期会消失吗
不会。即使 Component Model 在浏览器主流化,wasm-bindgen 仍然有其位置:
- JS 特化优化:wasm-bindgen 直接生成针对 JS 的胶水(externref 直传、ASCII 快路径),不必经过 Canonical ABI 的通用编码
- 简单场景的便利:单 Rust ↔ 单 JS 的场景,wasm-bindgen 始终更轻量
- TypeScript 集成:wasm-bindgen 的 .d.ts 自动生成是杀手锏
- 生态惯性:大量已发布的 npm 包是 wasm-bindgen 的产物
更可能的终态:wasm-bindgen 与 Component Model 长期共存——前者是 Rust + JS 的"高速通道",后者是多语言互操作的"标准通道"。
7.18.8 当下的工程建议
flowchart TD A["工程建议"] --> B["1. 浏览器项目<br/>继续 wasm-bindgen"] A --> C["2. 多语言项目<br/>选 Component Model"] A --> D["3. 关注 Component Model<br/>每季度评估"] A --> E["4. 新接口设计参考 WIT<br/>方便未来迁移"] A --> F["5. 生态稳定前别迁移<br/>等候选稳定版"] style A fill:#6366f1,color:#fff
不要被"Component Model 是未来"的噪声裹挟——技术选型要看当下的成熟度而非未来潜力。就浏览器场景而言,Component Model 目前还没有原生落地,wasm-bindgen 是成熟稳定的那一个。生产项目应该选稳定的。
7.19 实战:把 Rust crate 完整暴露给 JS
前面 18 节涵盖各类型的映射——把它们组合起来才能产生真实价值。这里以一个真实案例展示"如何把一个 Rust crate 完整暴露给 JS"——所有类型映射在统一项目中如何协作。
7.19.1 案例:Markdown 解析器的 WASM 包装
假设要把 Rust 的 pulldown-cmark Markdown 解析器包装成 npm 包供 JS 使用。完整的 API 设计需要协调多种类型映射。
use wasm_bindgen::prelude::*;
use pulldown_cmark::{Parser, html, Options};
// 1. 简单字符串接口
#[wasm_bindgen]
pub fn render(markdown: &str) -> String {
let parser = Parser::new(markdown);
let mut html = String::new();
html::push_html(&mut html, parser);
html
}
// 2. 带选项的接口(用 struct)
#[wasm_bindgen]
pub struct RenderOptions {
pub tables: bool,
pub footnotes: bool,
pub strikethrough: bool,
pub task_lists: bool,
pub smart_punctuation: bool,
}
#[wasm_bindgen]
impl RenderOptions {
#[wasm_bindgen(constructor)]
pub fn new() -> RenderOptions {
RenderOptions {
tables: true,
footnotes: false,
strikethrough: true,
task_lists: true,
smart_punctuation: false,
}
}
}
#[wasm_bindgen]
pub fn render_with_options(markdown: &str, opts: &RenderOptions) -> String {
let mut options = Options::empty();
if opts.tables { options.insert(Options::ENABLE_TABLES); }
if opts.footnotes { options.insert(Options::ENABLE_FOOTNOTES); }
if opts.strikethrough { options.insert(Options::ENABLE_STRIKETHROUGH); }
if opts.task_lists { options.insert(Options::ENABLE_TASKLISTS); }
if opts.smart_punctuation { options.insert(Options::ENABLE_SMART_PUNCTUATION); }
let parser = Parser::new_ext(markdown, options);
let mut html = String::new();
html::push_html(&mut html, parser);
html
}
// 3. 错误处理(用 Result)
#[wasm_bindgen]
pub fn render_strict(markdown: &str) -> Result<String, JsValue> {
if markdown.is_empty() {
return Err(JsValue::from_str("empty input"));
}
if markdown.len() > 1024 * 1024 {
return Err(JsValue::from_str("input too large (> 1MB)"));
}
Ok(render(markdown))
}
// 4. 流式解析(用 struct + 句柄)
#[wasm_bindgen]
pub struct MarkdownStream {
buffer: String,
}
#[wasm_bindgen]
impl MarkdownStream {
#[wasm_bindgen(constructor)]
pub fn new() -> MarkdownStream {
MarkdownStream { buffer: String::new() }
}
pub fn push(&mut self, chunk: &str) {
self.buffer.push_str(chunk);
}
pub fn finalize(self) -> String {
render(&self.buffer)
}
}
// 5. 异步接口(用 async)
#[wasm_bindgen]
pub async fn render_url(url: &str) -> Result<String, JsValue> {
let response = wasm_bindgen_futures::JsFuture::from(
web_sys::window().unwrap().fetch_with_str(url)
).await?;
let resp: web_sys::Response = response.dyn_into()?;
let text = wasm_bindgen_futures::JsFuture::from(resp.text()?).await?;
let markdown = text.as_string().unwrap_or_default();
Ok(render(&markdown))
}
7.19.2 API 设计的考虑
graph TD A["API 设计原则"] --> B["1. 简单优先"] A --> C["2. 渐进暴露复杂度"] A --> D["3. 错误显式化"] A --> E["4. 异步用 async"] A --> F["5. 大数据用句柄"] B --> B1["render(md) 一行调用"] C --> C1["render_with_options 可选高级"] D --> D1["Result + JsValue"] E --> E1["render_url async fn"] F --> F1["MarkdownStream 流式"] style A fill:#10b981,color:#333
5 个 API 形成了"金字塔"结构——简单需求用顶层,复杂需求用底层。
7.19.3 JS 侧使用示例
import init, {
render,
render_with_options,
render_strict,
render_url,
RenderOptions,
MarkdownStream
} from '@my-org/markdown-wasm';
await init();
// 1. 最简单
const html1 = render('# Hello\n\nWorld');
// 2. 带选项
const opts = new RenderOptions();
opts.tables = true;
opts.smart_punctuation = true;
const html2 = render_with_options(markdown, opts);
opts.free(); // 必须!
// 3. 错误处理
try {
const html3 = render_strict(input);
} catch (err) {
console.error('Render failed:', err);
}
// 4. 异步
const html4 = await render_url('https://example.com/README.md');
// 5. 流式
const stream = new MarkdownStream();
for await (const chunk of largeChunks) {
stream.push(chunk);
}
const html5 = stream.finalize(); // stream 自动 free
每种使用方式对应不同复杂度——但所有都用同一个 npm 包。
7.19.4 类型映射的协调
这个项目展示了多种类型映射的协调:
| API | 类型映射用法 |
|---|---|
render(md: &str) -> String |
§7.4 字符串 |
RenderOptions 结构体 |
§7.6 结构体 |
Result<String, JsValue> |
§7.8 Result |
MarkdownStream 流式 |
§7.6 + §7.16 句柄 |
async fn render_url |
§7.12 异步 |
| 选项 boolean 字段 | §7.3 bool |
每种映射都不是孤立——组合起来形成完整 API。这是 wasm-bindgen 的核心威力:让一个 Rust crate 通过一组类型协调的 API 完全暴露给 JS。
7.19.5 项目工程化
完整项目还需要:
flowchart TD A["完整 wasm-bindgen 项目"] --> B["src/lib.rs:上述 5 个 API"] A --> C["Cargo.toml:依赖与 profile"] A --> D["package.json 模板"] A --> E["TypeScript 类型生成"] A --> F["wasm-bindgen-test 单测"] A --> G["性能基准"] A --> H["README.md 用户文档"] style A fill:#6366f1,color:#fff
每条都有具体内容——前面章节都有覆盖(§6.14 TS 类型 / §6.16 测试 / §8.x wasm-pack)。这一节展示如何把它们组合在一起。
7.19.6 各 API 的开销构成
不给绝对数字(那完全取决于文档大小和引擎),只看每个 API 在解析本身之外多付了什么:
| API | 解析之外的额外开销 |
|---|---|
render |
一次入参字符串复制 + 一次返回字符串复制 |
render_with_options |
同上,外加一次 RenderOptions 的构造与 free() |
render_strict |
同 render,多两次 O(1) 的长度/空判断,可忽略 |
render_url |
同 render,外加一次真实网络往返——这一项必然主导总耗时 |
MarkdownStream |
N 次 push 各复制一小段,最后一次性渲染;省掉了 N 次「全量字符串来回」 |
要判断某个 API 是否够快,按 §6.11 的方法在目标浏览器上测自己的文档规模。
7.19.7 教训:从 0 到产品级 wasm-bindgen 项目
flowchart LR A["阶段 1<br/>玩具"] --> B["简单函数 + 字符串"] B --> C["阶段 2<br/>可用"] C --> D["+ 选项 + 错误处理"] D --> E["阶段 3<br/>产品级"] E --> F["+ 流式 + 异步 + TS 类型 + 测试"] F --> G["阶段 4<br/>开源发布"] G --> H["+ 文档 + 持续维护 + RFC"] style A fill:#dc2626,color:#fff style H fill:#10b981,color:#333
每阶段对应不同投入和收益——大多数项目停在阶段 2-3 即可。面向社区发布的开源库才需要走到阶段 4。
这套"5 API 金字塔"模式可以套用到其他 wasm-bindgen 项目——它是把一个 Rust crate 完整、渐进地暴露给 JS 的一种可复用组织方式。
7.20 类型映射的常见问题 FAQ
前面 19 节系统介绍了所有类型映射——但读者实战中会遇到具体问题。这一节是从社区讨论提炼的高频问题 FAQ,每个都是真实工程场景。
7.20.1 Q1: 为什么我的 Vec<u8> 在 JS 里是 Uint8Array 而 Vec<i8> 是 Int8Array?
graph LR A["Rust Vec<T>"] --> B["JS TypedArray"] C["Vec<u8>"] --> D["Uint8Array"] E["Vec<i8>"] --> F["Int8Array"] G["Vec<u16>"] --> H["Uint16Array"] I["Vec<u32>"] --> J["Uint32Array"] K["Vec<f32>"] --> L["Float32Array"] M["Vec<f64>"] --> N["Float64Array"] O["Vec<其他>"] --> P["Array<...>"] style B fill:#10b981,color:#333
回答:crates/cli-support/src/descriptor.rs:243 的 VectorKind::js_ty() 就是这张表——U8 → Uint8Array、I8 → Int8Array、F32 → Float32Array……数值向量整块字节复制成一个 TypedArray;元素是 JsValue、导入类型或导出 struct 时则是 VectorKind::Externref/NamedExternref,落到普通 Array,得逐个元素做 ABI 转换。后者一定更贵,差多少取决于元素数量和元素类型。
7.20.2 Q2: 我能直接传 HashMap<String, Vec<u8>> 给 JS 吗?
不能直接作为 #[wasm_bindgen] 函数的参数或返回值——要么经过 serde:
use serde::{Serialize, Deserialize};
use serde_wasm_bindgen::to_value;
#[wasm_bindgen]
pub fn get_data() -> Result<JsValue, JsValue> {
let map: HashMap<String, Vec<u8>> = HashMap::new();
Ok(to_value(&map)?)
}
serde-wasm-bindgen 0.6.5 默认把 map 序列化成 JS Map(node 实测,值 Vec<u8> 变成普通数组 [1, 2]):
const data = wasm.get_data();
console.log(data.get('key')); // 是 Map,data['key'] 拿到的是 undefined
想要普通对象,用 serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true) 序列化;要么就用 js_sys::Map 手动构造。
7.20.3 Q3: Option<T> 在 JS 里是 null 还是 undefined?
#[wasm_bindgen]
pub fn maybe_value() -> Option<i32> {
None
}
JS 侧得到 undefined——不是 null。这与 TypeScript 习惯一致:
const v = wasm.maybe_value();
if (v === undefined) { /* ... */ } // 推荐
if (v == null) { /* ... */ } // 也能工作(== 同时匹配 null/undefined)
7.20.4 Q4: 我能从 JS 调用 Rust 的 trait 方法吗?
不能直接调——#[wasm_bindgen] 不支持 trait。变通:把 trait 方法包装为 struct 的方法:
trait Animal {
fn speak(&self) -> String;
}
struct Dog;
impl Animal for Dog {
fn speak(&self) -> String { "Woof!".to_string() }
}
// JS 不能直接看到 Animal trait
// 把 Dog 暴露为 #[wasm_bindgen] struct
#[wasm_bindgen]
pub struct DogJs;
#[wasm_bindgen]
impl DogJs {
#[wasm_bindgen(constructor)]
pub fn new() -> DogJs { DogJs }
pub fn speak(&self) -> String {
Dog.speak() // 内部用 trait
}
}
7.20.5 Q5: 为什么我的 enum 在 JS 里是数字?
#[wasm_bindgen]
pub enum Color {
Red,
Green,
Blue,
}
JS 侧得到的是数字(0/1/2),不是字符串。这是 wasm-bindgen 的优化——传数字比传字符串快。如果需要字符串:
// 注意:C 风格 enum 的 ABI 是 u32,没有 RefFromWasmAbi,
// 所以不能给它写 `&self` 方法;用一个按值接收的自由函数
#[wasm_bindgen]
pub fn color_name(c: Color) -> String {
match c {
Color::Red => "Red",
Color::Green => "Green",
Color::Blue => "Blue",
}
.to_string() // 返回类型必须是 String,&str 不能作返回值(§7.15.2)
}
如果一开始就想要字符串语义,用 string enum(#[wasm_bindgen] pub enum Color { Red = "red", ... })——它在 JS 侧直接就是字符串字面量联合类型。
7.20.6 Q6: 我能传 JS 函数给 Rust 作为回调吗?
可以——用 &js_sys::Function:
#[wasm_bindgen]
pub fn process_with_callback(
items: Vec<String>,
callback: &js_sys::Function,
) -> Result<(), JsValue> {
let this = JsValue::null();
for item in items {
let arg = JsValue::from_str(&item);
callback.call1(&this, &arg)?;
}
Ok(())
}
JS 调用:
wasm.process_with_callback(items, (item) => console.log(item));
7.20.7 Q7: 大字符串传输有性能问题吗?
有——每次跨边界传字符串都要走一遍 UTF-8 编码 + 整块复制,成本随字节数线性增长(含非 ASCII 字符的字符串还会在第一个非 ASCII 字符处离开 §6.4 那条 ASCII 快路径,落到 TextEncoder.encodeInto 加两次 realloc)。具体多少毫秒自己测。
优化模式:
// 反模式:传完整字符串
#[wasm_bindgen]
pub fn process(text: String) -> String { /* ... */ }
// 推荐:用句柄
#[wasm_bindgen]
pub struct TextProcessor {
text: String,
}
#[wasm_bindgen]
impl TextProcessor {
pub fn process(&self) -> String { /* 不传 text,从 self 读 */ }
}
这种"句柄留 Rust 内"的模式适合大字符串场景。
7.20.8 Q8: 我能用 Rust async fn 做超长任务吗?
能——但要小心:
use wasm_bindgen_futures::JsFuture;
#[wasm_bindgen]
pub async fn long_task() -> i32 {
for _ in 0..1000 {
process_chunk();
// wasm-bindgen-futures 没有 yield_now:await 一个已 resolve 的 Promise
// 就能让出一次微任务
let _ = JsFuture::from(js_sys::Promise::resolve(&JsValue::NULL)).await;
}
42
}
注意「让出」分两档:await 一个已 resolve 的 Promise 只让出到微任务队列,浏览器不会趁机重排版和绘制;要真正把一帧还给渲染,得让出到宏任务——setTimeout(0)、requestAnimationFrame,或支持的浏览器上的 scheduler.yield()。UI 卡顿的场景要的是后者。
7.20.9 Q9: 我能在 JS 里 instanceof 我的 Rust struct 吗?
可以——#[wasm_bindgen] 把 struct 编译为 JS class,instanceof 工作:
import { User } from 'my-wasm-lib';
const u = new User('Alice');
console.log(u instanceof User); // true
但 instanceof 比的是具体的类对象:如果同一个包被打包进两份(各有一份胶水),两边的 User 不是同一个类,instanceof 会返回 false。
7.20.10 Q10: 类型映射会做哪些隐式转换?
graph LR A["JS 调用"] --> B["wasm-bindgen 自动转换"] B --> C["Rust 类型"] D["JS number"] --> E["i32 / f64<br/>越界按 ToInt32 截断"] F["JS bigint"] --> G["i64 / u64"] H["JS string"] --> I["String / &str"] J["JS boolean"] --> K["bool"] L["JS null/undefined"] --> M["Option<T> 的 None"] style B fill:#6366f1,color:#fff
理解这些隐式转换避免运行时惊讶——注意 release 产物里不做范围校验:JS 的 number 传给 i32 参数时,是 WASM 引擎按 JS 的 ToInt32 规则截断(2**32 + 1 会变成 1),既不报错也不 panic。只有 wasm-bindgen --debug 生成的胶水里才有 _assertNum 之类的 typeof 检查,而且它检查的是类型不是范围。例外是 char:发布版胶水也会调 _assertChar 拒绝代理项和越界码点(§7.3)。
7.20.11 Q11: 为什么我的 wasm-bindgen 版本升级后旧代码不能编了?
wasm-bindgen 一直停在 0.2.x,版本号只动第三位;按 Cargo 的规则这些都算兼容升级,cargo update 会自动升上去,但第三位的升级里照样会带进破坏性变化(§6.15.1、§6.15.5)。仓库 CHANGELOG.md 里能直接查到的几类:
- 生成的 TS 类型变了:0.2.100 起可选参数从
T | undefined变成T | undefined | null(#4188) - 原先能编过的写法变成编译错误:0.2.100 起给 enum 加
getter/setter/constructor直接报错(#4278) - trait bound 收紧:0.2.109 起
panic=unwind下Closure::new()、Closure::once()等要求UnwindSafe(#4893) - 默认行为随 target feature 漂移:0.2.94 起 externref / multivalue 变换由
.wasm的 target-features 段自动决定(#4133)
修复:读 CHANGELOG,按条目迁移。
7.21 跨专栏关联:类型映射的通用模式
wasm-bindgen 的 IntoWasmAbi/FromWasmAbi trait 和本系列其他专栏中的类型转换框架是同一个设计模式的不同应用:
| 框架 | 核心 trait | 转换方向 | 跨边界 |
|---|---|---|---|
wasm-bindgen |
IntoWasmAbi / FromWasmAbi |
Rust ↔ WASM ABI | 是(线性内存 ↔ JS 堆) |
| Serde | Serialize / Deserialize |
Rust ↔ 数据模型 | 否(Rust 进程内部) |
sqlx |
Encode / Decode |
Rust ↔ SQL 协议 | 是(Rust ↔ 数据库网络协议) |
axum |
FromRequest / IntoResponse |
Rust ↔ HTTP | 是(Rust ↔ TCP 流) |
这些框架的共同结构:定义一个 trait 把 Rust 类型"拉平"为目标域的表示,然后用过程宏自动生成 impl。理解了 wasm-bindgen 的 IntoWasmAbi,其他框架的同名 trait 就是同一个模式——只是"目标域"不同。
具体到 Serde:两者都有一个"中间表示"的概念。Serde 的 Serializer trait 定义了通用数据模型(bool、i32、string、seq、map 等),impl Serialize for T 把 T 拆解为这些基本元素。wasm-bindgen 的 WASM ABI 就是它的"通用数据模型"——只有 i32/i64/f32/f64 四种元素,impl IntoWasmAbi for T 把 T 拆解为这四种基本值。Serde 的数据模型更丰富(有 string、seq、map),WASM ABI 更贫瘠——这也是 wasm-bindgen 的胶水代码比 Serde 的序列化代码更复杂的原因。
另一个值得对比的维度是"类型安全边界"。Serde 的类型安全在 Rust 进程内部得到完全保证——impl Deserialize for T 产出的值一定是类型 T,编译器保证了这一点。wasm-bindgen 的类型安全在 WASM 边界处断裂——JS 调用者可以传入任意值给期望特定类型的函数,JS 的动态类型系统无法在编译期捕获类型错误。wasm-bindgen 生成的 TypeScript 声明(.d.ts 文件)部分缓解了这个问题——使用 TypeScript 的 JS 项目可以在编译期获得类型检查。但 .d.ts 只是"声明",不是"保证"——运行时仍然可以绕过 TypeScript 的类型检查传入错误的值。对安全性要求高的 WASM 模块,应该在 Rust 侧对输入做防御性校验——不要假设 JS 侧传来的值一定符合类型签名。
下一章看 wasm-pack——它把 cargo build + wasm-bindgen + npm publish 串成一条命令。