Rust + WebAssembly 全链路解析

第7章 wasm-bindgen 深入:类型映射与胶水代码

作者 杨艺韬 · 21,196 字 · 发布于 · 更新于

"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&lt;u8&gt;"| 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&lt;T, E&gt;"| K["Ok→T 的映射, Err→throw"]
  B -->|"Option&lt;T&gt;"| 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 这个东西——长度从来都是和指针一起返回的,不需要额外查询。

完整流程:

  1. Rust 调用 get_name() 得到 String
  2. String 的字节存储在线性内存中(由 Rust 分配器管理)
  3. 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)
  4. JS 通过 TextDecoder 从线性内存中读取 UTF-8 字节,解码为 JS String
  5. 释放责任落在 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
  1. 直接操作 memory.buffer:JS 侧获取 Uint8Array 视图直接操作 WASM 线性内存,零复制。风险是 memory.grow 后视图失效——需要每次重新获取。

  2. 预分配线性内存:Rust 侧暴露一个 alloc(size) -> ptr 函数,JS 调用它获取一块 WASM 内存,直接写入数据,然后传指针给处理函数。

  3. 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&lt;T, JsValue&gt;<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&lt;F&gt;"] --> B["Box&lt;dyn FnMut&gt;<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,并对照实际胶水):

  1. 闭包环境(captured variables):Box<dyn FnMut> 放在 WASM 线性内存的堆上,拆成(数据指针, vtable 指针)两个 i32
  2. JS Function 对象:创建时 Rust 把这两个 i32 交给 JS,胶水的 makeMutClosure 造一个函数,把它们存在 state = { a, b, cnt } 里;Rust 这一侧的 Closure 只持有这个函数的 JsValue(externref 表里的一个槽位)
  3. 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(运行时错误);落在范围内却不指向合法对象,则会悄悄读写错误数据,这才是更危险的未定义行为。

指针类型适合两种场景:

  1. 高性能数据传递:绕过 wasm-bindgen 的复制机制,直接操作线性内存
  2. 与 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_hook crate,把 Rust panic 信息打到 console.error(之后仍以 unreachable trap 结束)
// 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 串成一条命令。