Skip to content

第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:IntoWasmAbiFromWasmAbiIntoWasmAbi 定义了 Rust 类型如何转换为 WASM ABI 值(传给 JS 的方向),FromWasmAbi 定义了 WASM ABI 值如何转换回 Rust 类型(从 JS 接收的方向)。每个支持跨边界传递的 Rust 类型都实现了这两个 trait——包括原生类型、字符串、结构体、JsValue 等。当你写 #[wasm_bindgen] pub fn greet(name: &str) 时,宏展开后的代码实际上调用的是 <&str as IntoWasmAbi>::into_abi()<String as FromWasmAbi>::from_abi()

这两个 trait 的设计可以直接类比 Serde 的 Serialize/Deserialize——前者是"Rust → 外部"方向,后者是"外部 → Rust"方向。Serde 用 Serializer trait 抽象了外部格式的差异(JSON、YAML、MessagePack 等),wasm-bindgenWasmAbi 类型抽象了 WASM 原生类型的差异(i32i64f32f64)。

完整类型映射表

以下是 wasm-bindgen 0.2.100 支持的类型映射的完整参考:

Rust 类型WASM 传递JS 类型转换开销
i32i32number
u32i32number>>> 0~1 ns(无符号转换)
i64i64BigInt~10 ns
u64i64BigInt~10 ns
f32f32number
f64f64number
booli32boolean~1 ns
chari32string(单字符)~50 ns
&stri32 + i32string~100-180 ns + 复制
Stringi32string~100-180 ns + 复制
&[u8]i32 + i32Uint8Array~100 ns + 复制
Vec<u8>i32Uint8Array~100 ns + 复制
JsValuei32any~50 ns(对象栈操作)
Structi32Class 实例~50 ns(指针传递)
C-like Enumi32number(常量)
Option<T>取决于 TT | null同 T + null 检查
Result<T, E>取决于 TT | throw同 T + 异常机制
Closurei32(表索引)Function~100 ns
*const Ti32number
*mut Ti32number

7.2 原生数值类型

i32 / u32

最简单的映射——WASM 的 i32 直接对应 JS 的 number。Rust 的 i32u32 在 WASM 层面都是 i32(WASM 不区分有符号/无符号),但在 JS 侧的表现不同:

  • i32 在 JS 中可能是负数(如 -1 = 0xFFFFFFFF
  • u32 需要确保 JS 看到的是 >>> 0(无符号右移零位,强制转为无符号 32 位整数)

wasm-bindgen 生成的胶水代码:

javascript
// 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 位浮点数。当一个 i320xFFFFFFFF 从 WASM 返回时,JS 会把它当作有符号整数 -1(因为符号扩展)。>>> 0(无符号右移零位)是一个 JS 技巧——它把值强制转为无符号 32 位整数,0xFFFFFFFF >>> 0 = 4294967295

这个 >>> 0 技巧在 JS 社区被广泛使用——TypeScript 编译器、Babel、甚至 V8 引擎内部都用它做无符号整数转换。wasm-bindgen 只是遵循了这个惯例。值得注意的是,>>> 0 只对 32 位整数有效——对于 u64,需要完全不同的处理方式(下一节详述)。

从 Rust 侧看,i32u32IntoWasmAbi 实现几乎相同——都是直接传递 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 的处理方式在 0.2.80 之后有了重大变化:

rust
#[wasm_bindgen]
pub fn big_number() -> u64 {
    0x1_0000_0000_0000_0000 // 超过 Number.MAX_SAFE_INTEGER
}
javascript
// 返回 BigInt
const result = big_number(); // 72057594037927936n
typeof result; // "bigint"

需要注意:JS 的 BigIntnumber 之间不能直接做算术运算——1n + 1 会抛出 TypeError。如果你的 WASM 导出函数返回 i64/u64,JS 调用者需要用 BigInt 语法处理返回值。

f32 / f64

浮点数直接映射为 JS 的 number——因为 JS 的 number 本身就是 64 位浮点数。f32 会被提升为 f64(精度不会损失,因为 f64 包含所有 f32 的值),f64 直接传递。

javascript
// Rust: pub fn compute(x: f64) -> f64
export function compute(x) {
    return wasm.compute(x); // 直接传递,零转换
}

唯一需要注意的是 NaN 的传递——Rust 的 NaN 可能带有不同的 payload 位模式,而 JS 的 NaN 规范要求特定的位模式。wasm-bindgen 不做 NaN 规范化,所以通过 WASM 传递的 NaN 在 JS 中可能不是 Number.NaN,但 isNaN() 检测仍然有效。

另一个浮点数相关的边界情况是 -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

javascript
// 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 ? 1 : 0); // boolean → i32
}

转换开销约 1 纳秒——可以忽略。

char

Rust 的 char 是 Unicode 标量值(0 到 0x10FFFF),映射为 i32 传递,JS 侧转换为单字符 string

javascript
// 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 codePoint = c.codePointAt(0);
    if (codePoint === undefined) throw new Error('expected a char');
    return wasm.is_letter(codePoint) !== 0;
}

String.fromCodePointcodePointAt 的开销约 50 纳秒。如果 API 设计需要大量传递单个字符,考虑改用 u32(Unicode 码点)+ JS 侧手动转换,避免重复的 String.fromCodePoint 调用。

7.4 字符串:最复杂的映射

字符串传递是 wasm-bindgen 中开销最大的操作。原因:Rust 的 String 是 UTF-8 编码的字节序列 + 长度 + 容量,存储在线性内存中;JS 的 String 是 UTF-16 编码的不可变值,存储在 JS 堆中。两者编码不同、存储位置不同、生命周期模型不同。

Rust → JS 方向

rust
#[wasm_bindgen]
pub fn get_name() -> String {
    "杨艺韬".to_string()
}

生成的 Rust 侧代码(简化):

rust
#[export_name = "get_name"]
pub unsafe extern "C" fn __wbindgen_get_name() -> u32 {
    let s = get_name();
    // 把 String 的指针和长度信息编码
    // 返回一个编码后的 u32(实际是通过辅助函数传递指针+长度)
    __wbindgen_string_new(s.as_ptr() as u32, s.len() as u32)
}

生成的 JS 侧代码:

javascript
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });

function getStringFromWasm0(ptr, len) {
    return cachedTextDecoder.decode(
        getUint8Memory0().subarray(ptr, ptr + len)
    );
}

export function get_name() {
    const ret = wasm.get_name();
    const len = wasm.__wbindgen_strlen(ret);
    const result = getStringFromWasm0(ret, len);
    wasm.__wbindgen_free(ret, len, 1); // 释放 WASM 侧内存
    return result;
}

完整流程:

  1. Rust 调用 get_name() 得到 String
  2. String 的字节存储在线性内存中(由 Rust 分配器管理)
  3. Rust 返回指向字节的 i32 指针
  4. JS 通过 TextDecoder 从线性内存中读取 UTF-8 字节,解码为 JS String
  5. Rust 侧的 String 被 drop——内存由 __wbindgen_free 释放

注意 cachedTextDecoder——wasm-bindgen 缓存了 TextDecoder 实例,避免每次调用都创建新的。fatal: true 意味着如果 UTF-8 字节序列不合法会抛出异常,而不是静默替换为替代字符。

JS → Rust 方向

rust
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
    format!("Hello, {}!", name)
}

JS 侧生成的代码:

javascript
const lTextEncoder = new TextEncoder();

function passStringToWasm0(arg, malloc, realloc) {
    if (typeof arg !== 'string') throw new Error('expected a string');

    // 编码为 UTF-8
    const buf = lTextEncoder.encode(arg);
    // 在 WASM 线性内存中分配空间
    const ptr = malloc(buf.length, 1) >>> 0;
    // 复制字节到线性内存
    getUint8Memory0().set(buf, ptr);
    return [ptr, buf.length];
}

export function greet(name) {
    const [ptr0, len0] = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
    const ret = wasm.greet(ptr0, len0);
    // 释放输入字符串的 WASM 内存
    wasm.__wbindgen_free(ptr0, len0, 1);
    return getStringFromWasm0(ret, wasm.__wbindgen_strlen(ret));
}

String vs &str vs JsString

wasm-bindgen 支持三种字符串类型,各有适用场景:

Rust 类型语义跨边界行为适用场景
&str借用(不获取所有权)JS 侧复制到线性内存,调用后释放函数参数,只读访问
String拥有所有权JS 侧复制到线性内存,Rust 获取所有权函数参数,需要修改/存储
js_sys::JsStringJS 堆上的 String不复制,传递对象栈索引需要和 JS API 交互时
rust
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 String
#[wasm_bindgen]
pub fn concat_js(a: &JsString, b: &JsString) -> JsString {
    let result = js_sys::String::new(&format!("{}{}", a, b));
    result.unchecked_into()
}

中文字符串的陷阱

UTF-8 编码下,一个中文字符占 3 字节。JS 的 String.length 返回的是 UTF-16 码元数量(中文 1 字符 = 1 码元),Rust 的 str.len() 返回的是 UTF-8 字节数。在 WASM 边界上,wasm-bindgen 传递的是字节数而非字符数——这是正确的,但容易让开发者困惑:

javascript
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::insertString::remove 的索引参数在 Rust 中是字节偏移量,不是字符偏移量。如果 JS 调用者用 string.length 计算出的索引传入 Rust 侧做字符串操作,中文字符处会出现字节对齐错误,导致 panic。正确的做法是在 JS 侧也使用字节偏移量(通过 TextEncoder.encode(str.slice(0, n)).length 计算前 n 个字符的字节偏移量),或者在 Rust 侧提供一个接受字符索引的 API 内部做转换。

7.5 二进制数据

&[u8] vs Vec<u8> vs JsValue (Uint8Array)

三种二进制数据传递方式,性能特征截然不同:

Rust 类型JS 侧表现内存策略适用场景
&[u8]Uint8Array复制到线性内存,调用后释放函数参数,只读
Vec<u8>Uint8Array复制到线性内存,所有权转移函数参数或返回值
js_sys::Uint8ArrayUint8Array不复制,传递对象栈索引需要和 JS API 交互
*const u8 + usizenumber零复制,传指针高性能场景,手动管理
rust
// 方式一:复制传递(默认)
#[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 {
    // 不复制 JS 侧的数据,直接通过对象栈引用
    let len = input.byte_length() as usize;
    let mut buf = vec![0u8; len];
    input.copy_to(&mut buf);
    // 处理 buf...
    let result = js_sys::Uint8Array::new_with_byte_offset_and_length(
        &wasm_bindgen::memory(),
        buf.as_ptr() as i32,
        buf.len(),
    );
    result
}

传递大量数据的优化策略

对于 >100KB 的数据,复制开销变得显著。三种优化策略:

  1. 直接操作 memory.buffer:JS 侧获取 Uint8Array 视图直接操作 WASM 线性内存,零复制。风险是 memory.grow 后视图失效——需要每次重新获取。

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

  3. SharedArrayBuffer 共享:如果数据在 JS 侧的 ArrayBuffer 中,且页面设置了 Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: require-corp 头,可以创建 SharedArrayBuffer 让 JS 和 WASM 共享同一块内存。这是唯一真正的零复制共享方案,但安全头要求限制了它的适用范围。

7.6 结构体与类

#[wasm_bindgen] struct 在 JS 侧生成一个包装类。类实例持有指向 Rust 对象的 i32 指针:

rust
#[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 类(简化):

javascript
export class ImageProcessor {
    constructor(width, height) {
        this.ptr = wasm.ImageProcessor_new(width, height);
    }

    process() {
        wasm.ImageProcessor_process(this.ptr);
    }

    get_width() {
        return wasm.ImageProcessor_get_width(this.ptr) >>> 0;
    }

    free() {
        if (this.ptr !== 0) {
            wasm.__wbindgen_ImageProcessor_destroy(this.ptr);
            this.ptr = 0;
        }
    }
}

Rust 结构体字段的可见性

wasm-bindgen 的一个常见困惑:#[wasm_bindgen] struct 的字段默认不对 JS 暴露。JS 侧只能通过 impl 块中的方法访问字段。如果要让 JS 直接访问某个字段,需要使用 #[wasm_bindgen(getter_with_clone)]#[wasm_bindgen(readonly)] 属性:

rust
#[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>,
}

#[wasm_bindgen]
impl Config {
    // getter 自动生成,也可以手动写 setter
    #[wasm_bindgen(setter = name)]
    pub fn set_name(&mut self, name: String) {
        self.name = name;
    }
}

方法接收者类型

Rust 的方法接收者(&self&mut selfself)在 wasm-bindgen 中有不同的语义和 JS 侧行为:

rust
#[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 }
}

wasm-bindgen 在 JS 侧对 &mut self 做了借用检查:同一个对象上,&mut 方法调用期间不能再次调用任何方法。这是 Rust 借用规则在 JS 侧的运行时模拟——用 this.__wbg_ptr 标记是否已被借用。调用 into_total(self) 后,JS 侧的 this.ptr 被置为 0,再次调用任何方法会抛出错误。

生命周期问题

JS 不会自动调用 free()。如果 JS 侧的 ImageProcessor 对象被 GC 回收但 free() 没被调用,Rust 侧的内存永远不会释放——WASM 模块中的内存泄漏。

wasm-bindgen 通过 FinalizationRegistry 提供自动清理(需要 --target-web 模式):

javascript
const registry = new FinalizationRegistry(ptr => {
    wasm.__wbindgen_ImageProcessor_destroy(ptr);
});

// 创建对象时注册
const processor = new ImageProcessor(800, 600);
registry.register(processor, processor.ptr);

FinalizationRegistry 的回调时机不确定——可能延迟很久。对内存敏感的应用(如大图像处理),建议手动调用 free()

7.7 Option<T> 映射

Option<T>wasm-bindgen 中的映射取决于 T 的类型:

Option<原生类型>

对于原生数值类型,Option<i32> 使用哨兵值表示 None

rust
#[wasm_bindgen]
pub fn maybe_int(val: Option<i32>) -> Option<i32> {
    val.map(|v| v * 2)
}

JS 侧:

javascript
export function maybe_int(val) {
    // val 传入 undefined/null → Rust 收到 None
    // val 传入 number → Rust 收到 Some(number)
    const ret = wasm.maybe_int(isNoneLike(val) ? 0xFFFFFFFE : val);
    // 返回值 0xFFFFFFFE 表示 None,其他值表示 Some
    return ret === 0xFFFFFFFE ? undefined : ret;
}

哨兵值 0xFFFFFFFE(不是 0xFFFFFFFF,因为 0xFFFFFFFF 可能是有意义的 -1)表示 None。这意味着 Option<i32> 无法表示 Some(0xFFFFFFFE) 这个值——但这是一个极端的边界情况,实际中几乎不会遇到。

Option<引用类型>

对于 StringVec<u8>JsValue 等引用类型,None 映射为 JS 的 nullundefined

rust
#[wasm_bindgen]
pub fn maybe_string(val: Option<String>) -> Option<String> {
    val.map(|s| s.to_uppercase())
}

JS 侧:

javascript
export function maybe_string(val) {
    // val 是 null/undefined → Rust 收到 None
    // val 是 string → Rust 收到 Some(String)
    const ptr0 = isNoneLike(val) ? 0 : passStringToWasm0(val, ...);
    const len0 = isNoneLike(val) ? 0 : WASM_VECTOR_LEN;
    const ret = wasm.maybe_string(ptr0, len0);
    // 返回值 ptr=0 表示 None
    return ret === 0 ? undefined : getStringFromWasm0(ret, ...);
}

指针值 0(空指针)表示 None,非零指针表示 Some。这和 Rust 内部的 Option<ptr> 表示完全一致——Rust 的 Option<Box<T>>None 就是空指针。

Option<结构体>

rust
#[wasm_bindgen]
pub fn find_user(id: u32) -> Option<User> {
    // ...
}

JS 侧返回 User 实例或 undefined。当 Rust 返回 None 时,JS 胶水代码返回 undefined;返回 Some(user) 时,JS 胶水代码创建 User 包装对象。

需要注意的是,JS 侧的 undefinednull 都对应 Rust 侧的 None。当 JS 调用者传入 null 时,wasm-bindgen 把它视为 None——这在大多数情况下是正确的行为,但如果你需要区分 nullundefined,就不能使用 Option<T>,而需要使用 JsValue 并手动检查。

7.8 Result<T, E> 映射

Result<T, E> 映射为 JS 的异常机制——Ok(T) 正常返回,Err(E) 抛出异常。

Result<T, JsValue>

最常见的模式——用 JsValue 作为错误类型:

rust
#[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 侧:

javascript
export function divide(a, b) {
    try {
        const ret = wasm.divide(a, b);
        return ret;
    } catch (e) {
        // wasm.divide 内部调用 __wbindgen_throw 抛出异常
        throw e;
    }
}

Result<T, E> 其中 E 是结构体

rust
#[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 实例,可以调用它的方法:

javascript
try {
    validate("");
} catch (e) {
    console.log(e.code());    // 1
    console.log(e.message()); // "input cannot be empty"
}

Result 的局限性

wasm-bindgenResult<T, E> 有严格限制:E 必须是 JsValue 或标注了 #[wasm_bindgen] 的类型。不支持任意的 Rust 错误类型——比如 Result<i32, String>Result<i32, anyhow::Error> 都不直接支持。原因在于 WASM 没有跨语言的异常类型系统,Err 值必须能映射为某种 JS 值才能被 throw。

rust
// ❌ 不支持
#[wasm_bindgen]
pub fn parse(input: &str) -> Result<i32, String> { ... }

// ✅ 使用 JsValue 包装
#[wasm_bindgen]
pub fn parse(input: &str) -> Result<i32, JsValue> {
    my_parse(input).map_err(|e| JsValue::from_str(&e))
}

// ✅ 使用自定义错误类型
#[wasm_bindgen]
pub fn parse(input: &str) -> Result<i32, AppError> { ... }

7.9 枚举映射

Rust 的 enum 在 JS 侧有两种表示方式:

C-like 枚举

rust
#[wasm_bindgen]
pub enum Status {
    Ok = 0,
    Error = 1,
    Pending = 2,
}

JS 侧生成一个 Object.freeze 常量映射:

javascript
export const Status = Object.freeze({
    Ok: 0,
    Error: 1,
    Pending: 2,
});

Object.freeze 确保枚举值不可修改——Status.Ok = 42 在严格模式下会抛出 TypeError。但 JS 的 constObject.freeze 只能防止重新赋值,不能防止类型混淆——JS 调用者可以传入任意 number 值给期望 Status 的函数,wasm-bindgen 不会在 JS 侧做范围检查。如果传入 3(不在枚举定义中),Rust 侧收到的是未定义行为——可能导致 panic 或静默的内存错误。

wasm-bindgen 要求 C-like 枚举的判别值必须是 i32 可表示的整数。不支持 isize/usize 判别值,也不支持手动指定非连续值以外的复杂布局。枚举值的判别值从 0 开始自动递增,也可以手动指定(如上例中的 Ok = 0, Error = 1, Pending = 2)。如果 Rust 侧的枚举带有 #[repr(u8)]#[repr(i8)] 等属性,wasm-bindgen 仍然用 i32 传递——因为 WASM 没有小于 32 位的值类型。

带数据的枚举

rust
// ❌ 不支持——wasm-bindgen 不能映射带数据的 enum
#[wasm_bindgen]
pub enum Result {
    Ok(i32),
    Err(String),
}

wasm-bindgen 不支持带数据的 enum(因为 JS 没有等价的类型——JS 的 enum 只是字符串/数字的映射)。替代方案有三种:

方案三(Serde 序列化)最灵活但开销最大——需要把整个枚举值序列化为 JSON 字符串,传到 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

Closure::once — 一次性回调

rust
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();

    callback.forget(); // 泄漏闭包,避免被 drop
}

Closure::once 创建一个只调用一次的闭包。调用后闭包自动从 WASM 函数表中移除。forget() 告诉 wasm-bindgen 不要在 Rust 侧管理这个闭包的生命周期——它的内存会在 JS 侧 GC 时通过 FinalizationRegistry 清理。

Closure::wrap — 可重复调用的回调

rust
#[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::onceClosure::wrap 的选择取决于回调的使用模式。如果回调只被调用一次(如 setTimeout 的回调、Promise 的 resolve/reject),使用 Closure::once + forget() 最简单。如果回调被多次调用(如事件监听器、动画帧回调),使用 Closure::wrap 并在适当时机 drop。一个常见的错误是对事件监听器使用 Closure::once——事件监听器会被多次触发,一次性闭包在第一次触发后就会被回收,后续触发时调用的是已释放的函数表条目,导致未定义行为。

每个 Closure 在 WASM 侧分配三块内存:

  1. 闭包上下文(captured variables):存储在 WASM 线性内存中
  2. 函数表条目:在 WASM 的 Table 中注册一个条目,JS 通过索引调用
  3. JS Function 对象:在 JS 堆上创建一个 Function,持有函数表索引

trampoline 函数是一段自动生成的 WASM 代码,它从闭包上下文中读取 captured 变量,然后调用实际的闭包体。这个间接层是必要的,因为 JS 的 Function 只能传参数,无法直接传递闭包上下文。trampoline 的名字来源于"跳板"——它把 JS 的调用"弹"到正确的 Rust 闭包上下文中。

理解了 trampoline 的存在,就能理解为什么 Closure 的内存模型比普通函数复杂。普通函数只需要一个函数指针(i32),Closure 需要三个东西:函数指针、闭包上下文的指针、以及 WASM 函数表中的索引。这也是为什么创建 Closure 的开销(200-300 纳秒)比普通函数调用(8-50 纳秒)高一个数量级——它涉及函数表注册、JS Function 对象创建、以及闭包上下文的内存分配。

闭包的性能开销

操作耗时
创建 Closure::once~200 ns
创建 Closure::wrap~300 ns
调用闭包(JS→WASM)~50-100 ns
forget() 一个闭包~50 ns
drop 一个闭包~50 ns

创建闭包的开销比创建普通函数高 3-5 倍,因为涉及函数表注册和 JS 对象创建。如果 API 设计需要频繁创建/销毁回调(如每帧的事件处理),考虑在 Rust 侧复用 Closure 对象而非每次新建。

7.11 指针类型

wasm-bindgen 支持裸指针类型 *const T*mut T——它们直接映射为 i32(WASM 的地址空间是 32 位),JS 侧表现为 number

rust
#[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 作为指针值,如果该值不是有效的 WASM 线性内存地址,会导致未定义行为(通常是 WASM 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 应用大量使用异步——fetchIndexedDBcrypto.subtle 都返回 Promisewasm-bindgen 通过 wasm-bindgen-futures 桥接 Rust 的 Future 和 JS 的 Promise——这是跨边界异步的核心机制。

Rust 侧 async fn → JS Promise

rust
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use web_sys::{Request, RequestInit, Response};

#[wasm_bindgen]
pub async fn fetch_url(url: &str) -> Result<String, JsValue> {
    let mut opts = RequestInit::new();
    opts.method("GET");
    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 调用:

javascript
const text = await fetch_url("https://api.example.com/data");

底层机制:

wasm-bindgen-futures 把 Rust Future 包装成 JS Promise——每次 Future::poll 推进状态、最终 resolve 或 reject。

JS Promise → Rust Future

反向也成立——JS 函数返回的 Promise 可以在 Rust 里 await

rust
#[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 依赖

toml
[dependencies]
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"  # 必加

少了 wasm-bindgen-futures、async 函数无法编译。

陷阱 2:在非 async 函数里 spawn

rust
// ❌ 错误:在同步函数里 spawn 异步任务
#[wasm_bindgen]
pub fn start_background() {
    wasm_bindgen_futures::spawn_local(async {
        // 任务永远没机会运行——没有事件循环驱动
    });
}

spawn_local 需要事件循环——非 async 上下文里 spawn 的 task 不会被 poll。改用:把整个函数标 async、或用 setTimeout 触发。

陷阱 3:Future 借用问题

rust
// ❌ 错误:跨 await 借用
async fn buggy(s: &str) -> Result<(), JsValue> {
    let future = make_request(s);  // s 被借用
    let result = future.await;     // await 期间 s 不能再用
    println!("{}", s);             // 错误:s 已被借用
    Ok(())
}

await 持有借用要小心——常见编译错误。解决:克隆 sString

取消异步任务

JS 的 Promise 没有内置取消机制——AbortController 是 fetch 等 API 的标准做法。wasm-bindgen 里的对应:

rust
use web_sys::{AbortController, AbortSignal};

#[wasm_bindgen]
pub async fn cancellable_fetch(url: &str) -> Result<String, JsValue> {
    let abort = AbortController::new()?;
    let signal = abort.signal();
    
    let mut opts = RequestInit::new();
    opts.signal(Some(&signal));
    
    let request = Request::new_with_str_and_init(url, &opts)?;
    // ... 如果 signal 被 abort、fetch 会 reject
}

JS 侧调用 abort() 取消 fetch——Rust 侧的 Future::poll 会收到 reject。这是异步可取消性的标准模式。

异步的性能特征

  • 创建 JsFuture:~50 ns(Promise 包装的开销)
  • await 让出 + 恢复:~1-5 μs(事件循环往返一次)
  • async fn 状态机的内存:取决于捕获变量大小

频繁 await 的场景延迟敏感——能批量化的尽量批量、避免在循环里逐个 await

7.13 wasm-bindgen 的调试与错误诊断

跨边界调用难以调试——错误可能源于 Rust、JS、或边界本身。这节给 wasm-bindgen 的调试工具箱。

常见错误类型

每类错误的症状不同——识别错的种类是定位的第一步。

类型签名不匹配

错误:JS 传入的参数类型和 Rust 期望的不符。

rust
#[wasm_bindgen]
pub fn parse(s: &str) -> i32 { s.parse().unwrap_or(0) }
javascript
parse(42);  // ❌ 数字传给 &str
// TypeError: expected a string argument

诊断:

  • 看错误堆栈、找到 passStringToWasm0 等转换函数
  • 检查 TypeScript 声明(.d.ts 文件)匹配 Rust 签名
  • 启用 console_error_panic_hook crate、把 Rust panic 转成 JS 错误
rust
// 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——典型是裸指针误用:

rust
#[wasm_bindgen]
pub fn read_at(ptr: *const u8) -> u8 {
    unsafe { *ptr }  // 如果 ptr 无效、trap
}
javascript
read_at(0xDEADBEEF);  // ❌ 无效地址
// RuntimeError: memory access out of bounds

诊断:

  • WASM 启用 source map、看到 Rust 行号
  • 检查指针来源——确保来自 WASM 模块自己分配的内存
  • wasm-pack build --debug 保留 debug 信息

Closure 生命周期

最常见的错误——使用已 drop 的 closure:

rust
fn buggy() {
    let cb = Closure::wrap(Box::new(|| log("hello")) as Box<dyn Fn()>);
    set_callback(cb.as_ref().unchecked_ref());
    // cb 在函数返回时 drop——回调时已失效
}

调用回调时报错:null function or function signature mismatch

修复:

  • cb.forget() 让 closure 永远存活(小心内存泄漏)
  • 或 closure 持有到合适的生命周期(如 struct 字段)

Promise reject 未处理

rust
#[wasm_bindgen]
pub async fn risky() -> Result<JsValue, JsValue> {
    let result = JsFuture::from(maybe_failing_promise()).await?;
    Ok(result)
}
javascript
risky();  // ❌ 没 catch、unhandled rejection

诊断:

  • 浏览器 console 看 "Uncaught (in promise)" 警告
  • Node.js process.on('unhandledRejection', ...) 监听
  • 始终 try/catch.catch() 处理 reject

版本不兼容

wasm-bindgen 的 Rust crate 和 CLI 工具版本必须一致:

bash
# Cargo.toml
wasm-bindgen = "0.2.93"

# 必须用同版本的 CLI
cargo install wasm-bindgen-cli --version 0.2.93

不一致时报错:

the version of `wasm-bindgen` (0.2.93) does not match the one used to build wasm-pack

修复:固定版本、用 cargo install --locked 避免漂移。

调试工具

Browser DevTools

  • WASM source map 让 stack trace 显示 Rust 文件
  • Performance tab 看 WASM 调用开销
  • Memory tab 看 WASM 线性内存

Wasmtime explorer

bash
wasm-tools print module.wasm  # 看 WASM IR
wasm-tools dump module.wasm   # 看二进制结构

Rust 侧调试

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 性能瓶颈

javascript
// 用 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 单元测试支持:

rust
// 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, "测试中文 处理完成");
}
bash
wasm-pack test --headless --chrome

测试在真实浏览器里跑——能测到 wasm-bindgen 的边界行为。

调试的最佳实践

  • 始终启用 console_error_panic_hook
  • 开发时用 wasm-pack build --dev(保留 debug 信息)
  • 生产前跑完整 unit test
  • 用 TypeScript 在 JS 侧做类型检查
  • wee_alloc 替代默认分配器时、注意它的限制

7.14 与 JS 原生对象的互操作

实际项目中频繁需要操作 JS 原生对象——Date、RegExp、Map、Set、Promise、URL 等。js-sys crate 为这些对象提供了 Rust 绑定——但用法和直接处理 JsValue 不同,理解差异有助于写出更自然的代码。

7.14.1 js-sys 提供的类型谱系

每种类型都是 JsValue 的子类型——Deref<Target=JsValue>。可以隐式当作 JsValue 用(传给接受 JsValue 的 API),也可以用专属方法(Date::nowArray::push 等)。

7.14.2 Date 与 Rust chrono 的互转

JS 的 Date 内部是 Unix 毫秒时间戳——和 Rust 的时间类型互转直接:

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)——Rust 侧用 chrono::DateTime<Utc> 而不是 NaiveDateTime 避免歧义。

7.14.3 Map/Set:高性能键值集合

当 Rust 侧的 HashMap 需要传给 JS 时,序列化成 Object 是常见做法——但对于非字符串键或大数据量,用 js_sys::Map 更合适:

选择适用性能
serde-wasm-bindgenObject字符串键、< 1000 条目序列化开销显著
js_sys::Map任意键类型、> 1000 条目直接构造,无序列化
Vec<(K, V)>单次传递、不需要查询最快,但 JS 侧要重建
rust
use js_sys::Map;

#[wasm_bindgen]
pub fn build_index(items: &[String]) -> Map {
    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 实例,可以高效查询:

javascript
const idx = build_index(items);
console.log(idx.get('foo'));  // O(1) 查询

7.14.4 RegExp 的跨边界使用

正则表达式是另一个跨边界优化点——Rust 的 regex crate 编译后约 20-40KB,而 JS 的 RegExp 是引擎内置的:

rust
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::RegExp0(引擎内置)中等(跨边界开销)简单正则、体积敏感
Rust regex crate+30KB快 2-5x复杂正则、大量调用
Rust regex-lite crate+5KB中等简单正则 + 体积敏感

体积敏感的浏览器 WASM 项目,用 js_sys::RegExp 节省 30KB——这通常比性能差异更重要。

7.14.5 Reflect API:泛化的对象操作

js_sys::Reflect 提供动态属性访问——当不知道对象的具体结构时使用:

rust
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

rust
// 反模式:每次迭代都跨边界传 String
#[wasm_bindgen]
pub fn process_items(items: Vec<String>) -> Vec<String> {
    items.iter().map(|s| transform(s)).collect()
}

// JS 调用:每次循环都做 UTF-8 编码 + 复制
for (let i = 0; i < 1000; i++) {
    results.push(rust.transform(items[i]));  // 1000 次跨边界字符串复制
}

修复:让 Rust 侧持有所有 String,JS 只传/收引用:

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 期望生命周期

rust
// 反模式:返回引用——编译错但即使能编译也不安全
// pub fn get_first(&self) -> &str { &self.items[0] }

&str 不能作为 #[wasm_bindgen] 函数的返回类型——所有跨边界返回必须拥有所有权。新手有时会试图用 Box<str>&'static str,前者反而比 String 慢,后者只能返回字面量。

7.15.3 反模式:JsValue 当 String 用

rust
// 反模式:JsValue::from_str 然后立即转回 String
fn bad(name: String) -> JsValue {
    JsValue::from_str(&name)  // 等于 name 本身的开销 + JsValue 包装
}

// 直接返回 String 即可
fn good(name: String) -> String { name }

JsValue 只在确实需要"任意 JS 类型"时才用——Rust 类型已知时直接用 Rust 类型,wasm-bindgen 会自动选择最佳的 ABI。

7.15.4 反模式:闭包泄漏

rust
// 反模式:每次调用都创建新闭包,旧闭包永不释放
#[wasm_bindgen]
pub fn setup_handler() {
    let cb = Closure::wrap(Box::new(|| { /* ... */ }) as Box<dyn FnMut()>);
    add_listener(&cb);
    cb.forget();  // ← 内存永远泄漏
}

修复:用结构体持有闭包,drop 时自动清理:

rust
#[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 被释放,自动 remove_listener
}

7.15.5 反模式:忽视 Result 的 JsValue 错误

rust
// 反模式: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(s: &str) -> Result<i32, JsValue> {
    s.parse::<i32>().map_err(|e| JsValue::from_str(&e.to_string()))
}

WASM trap 在浏览器中表现为 RuntimeError: unreachable——失去任何上下文。返回 Result<T, JsValue> 让 JS 侧能用 try/catch 优雅处理。

7.15.6 反模式速查表

每个反模式都有简单的修复——理解 wasm-bindgen 的边界本质(拥有权 + 序列化 + JS GC 协作)就能避免大多数问题。

7.16 跨边界泛型与 Trait 的工程模式

#[wasm_bindgen] 不能直接导出泛型函数和 trait——这条限制在 §6.13 提过。但实际项目中"我有一组类似的处理函数,想避免写 N 份"的需求很常见。下面是从生产中提炼的几种模式。

7.16.1 模式一:手动单态化 + 命名导出

最直接的做法是手动为每种类型写一个具体函数:

rust
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 模式二:宏自动生成导出

类型多时,手写重复——用宏批量生成:

rust
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 序列化 + 标签字段:

rust
#[derive(Deserialize)]
#[serde(tag = "type")]
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 侧:

javascript
process_dynamic(JSON.stringify({ type: 'user', name: 'Alice', age: 30 }));
process_dynamic(JSON.stringify({ type: 'order', id: 'X', total: 99.9 }));

代价:JSON 序列化/反序列化开销(每次 100-500ns)。适合:类型多变、调用频率不高的场景。

7.16.4 模式四:Trait Object 的句柄表

需要"动态选择实现"且性能敏感时,用句柄表(类似 §6.13):

rust
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 侧:

javascript
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);

性能:trait dispatch 是 O(1)(vtable 查找),加上 id → trait object 的 HashMap 查找(也是 O(1))。比 JSON 模式快 10-100 倍。代价:JS 侧必须显式 destroy 释放,否则内存泄漏。

7.16.5 模式五:生成 wasm-bindgen 风格的 class

如果业务上"trait object"本质是"对象",直接导出 #[wasm_bindgen] struct 是最自然的:

rust
#[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 侧获得自然的对象语义:

javascript
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 模式选择决策

90% 的"想导出泛型"场景实际可以重构为 #[wasm_bindgen] struct——JS 侧的对象语义比强制泛型更自然。剩下的 10% 才需要 JSON tag 或句柄表模式。

7.17 与其他跨语言绑定技术的对比

wasm-bindgen 不是 Rust 生态唯一的跨语言绑定方案——cxx(Rust↔C++)、pyo3(Rust↔Python)、neon(Rust↔Node.js)等都解决类似问题。理解它们的设计差异有助于把 wasm-bindgen 放在合适的位置评判。

7.17.1 横向对比表

技术目标ABI 类型类型表达力代际
wasm-bindgenRust ↔ JS(WASM 边界)自定义 + ABI 特化当前生态主流
cxxRust ↔ C++C ABI + 编译时验证成熟
pyo3Rust ↔ PythonCPython C API成熟
neonRust ↔ Node.jsN-API成熟
napi-rsRust ↔ Node.jsN-API + macro上升
uniffiRust ↔ Swift/Kotlin/PythonUDL + 自定义实验

7.17.2 设计哲学的差异

静态 vs 动态是核心分类:

  • 静态绑定(wasm-bindgen / cxx):编译时生成所有桥接代码,类型不匹配编译失败
  • 动态桥接(pyo3 / neon):在 CPython / V8 的 C API 上构建,类型转换运行时处理

wasm-bindgen 选择静态——这是 WASM 边界的特殊约束(线性内存只能传基础类型)逼出来的,但反过来给 Rust 一侧的开发体验加分。

7.17.3 类型表达力对比

某些复杂场景的支持程度:

场景wasm-bindgencxxpyo3neon
异步函数✓ wasm-bindgen-futures✓ async-trait✓ 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

每个方案都有自己的妥协。wasm-bindgen 的弱点(trait object/泛型)是 WASM ABI 的限制——不是 Rust 编译器的限制。

7.17.4 性能特征

实测:从 host 调用 Rust 函数处理 1KB 字符串:

技术单次调用延迟
直接 Rust 函数调用5 ns
wasm-bindgen(浏览器)250 ns
cxx(C++ inline)8 ns
pyo3(CPython)800 ns
neon(Node.js)400 ns

cxx 最快——因为 C++ 和 Rust 几乎共享 ABI,只有微小的转换成本。pyo3 较慢——CPython 的 GIL + 引用计数 + 类型转换都有开销。wasm-bindgen 居中——主要开销在 WASM-JS 边界的字符串复制。

7.17.5 学习曲线对比

wasm-bindgen 学习曲线最缓——文档质量好、类型系统清晰、错误信息友好。pyo3 较陡——必须理解 CPython 的对象模型、GIL 机制、生命周期管理。

7.17.6 选择决策

每个目标语言有最佳工具——不要勉强跨界使用。例如想"用 wasm-bindgen 调 Python"是没意义的,应该直接用 pyo3。

7.17.7 共同的设计模式

虽然实现不同,所有跨语言绑定都共享几个核心模式:

模式体现
过程宏自动生成wasm-bindgen #[wasm_bindgen] / pyo3 #[pyfunction] / neon #[js_function]
句柄表管理跨语言对象所有方案都有"OpaqueRef"或类似机制
错误用 Result 跨边界Rust 标准做法,所有方案保留
零拷贝优化(视图/引用)wasm-bindgen Uint8Array 视图、cxx Slice、pyo3 PyBuffer
生命周期约束所有方案都禁止跨边界返回引用

理解这些共同模式后,学新方案的成本大幅降低——核心思想都一样,只是语法和约束不同。

7.18 类型映射的演进:从 wasm-bindgen 到 Component Model

wasm-bindgen 是 Rust + JS 生态的当前事实标准——但 W3C 推进的 Component Model 是更长远的方向。理解这两者的关系和迁移路径,是做长期技术规划的基础。

7.18.1 两套类型系统的根本差异

设计目标的差异:

维度wasm-bindgenComponent Model
目标Rust → JS 边界任意语言 ↔ 任意语言
标准化社区事实标准W3C 正式标准
类型表达力偏向 JS 的语义抽象的类型系统
性能JS 原生(直接操作 JsValue)Canonical ABI(lift/lower)
工具链成熟(5+ 年)早期-中期(2024 Phase 1)

7.18.2 类型映射对照

每种类型在两个系统中的表达:

Rust 类型wasm-bindgenComponent Model WIT
StringJS string(直接)string
Vec<u8>Uint8Arraylist<u8>
Option<T>T | undefinedoption<T>
Result<T, E>throws on errresult<T, E>
enumunion typevariant
structclassrecord
生命周期对象class with free()resource
闭包Closure<...>暂无原生支持
异步函数async fn + Promiseasync func(Preview 3)

7.18.3 性能差异

实测:传递 1KB 字符串 + 接收 1KB 字符串:

方案单次调用耗时
wasm-bindgen(浏览器)250 ns
Component Model(同进程,Wasmtime)480 ns
Component Model(浏览器,未来支持)估计 300-500 ns

wasm-bindgen 更快——因为它做的是 JS 特化的优化(直接操作 JsValue 句柄),不需要 Canonical ABI 的中间编码。但 Component Model 的"通用性溢价"也只是 ~2x——可接受。

7.18.4 演进时间线

7.18.5 当前的工程选择

2026 年的现实:浏览器 WASM 工作几乎只能用 wasm-bindgen。Component Model 在浏览器还没广泛支持。服务器端可以用 Component Model,但生态成熟度不如 wasm-bindgen。

7.18.6 迁移路径的工程考虑

迁移不是非此即彼——可以共存:

  • 核心业务代码用 WIT 接口(语言无关)
  • 浏览器特定的 UI 交互用 wasm-bindgen
  • 服务器端用 Component Model 调用核心模块

这种混合策略让团队可以"两条腿走路"——既保留 wasm-bindgen 生态成熟度的红利,又开始投资 Component Model 的未来。

7.18.7 wasm-bindgen 长期会消失吗

不会。即使 Component Model 在浏览器主流化,wasm-bindgen 仍然有其位置:

  • JS 特化优化:wasm-bindgen 在 JS 引擎内部有更多优化机会
  • 简单场景的便利:单 Rust ↔ 单 JS 的场景,wasm-bindgen 始终更轻量
  • TypeScript 集成:wasm-bindgen 的 .d.ts 自动生成是杀手锏
  • 生态惯性:数万个 npm 包依赖 wasm-bindgen 的输出

更可能的终态:wasm-bindgen 与 Component Model 长期共存——前者是 Rust + JS 的"高速通道",后者是多语言互操作的"标准通道"。

7.18.8 当下的工程建议

不要被"Component Model 是未来"的噪声裹挟——技术选型要看当下的成熟度而非未来潜力。Component Model 在 2026 年是早期-中期,wasm-bindgen 是成熟稳定。生产项目应该选稳定的。

7.19 实战:把 Rust crate 完整暴露给 JS

前面 18 节涵盖各类型的映射——把它们组合起来才能产生真实价值。这里以一个真实案例展示"如何把一个 Rust crate 完整暴露给 JS"——所有类型映射在统一项目中如何协作。

7.19.1 案例:Markdown 解析器的 WASM 包装

假设要把 Rust 的 pulldown-cmark Markdown 解析器包装成 npm 包供 JS 使用。完整的 API 设计需要协调多种类型映射。

rust
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 设计的考虑

5 个 API 形成了"金字塔"结构——简单需求用顶层,复杂需求用底层。

7.19.3 JS 侧使用示例

javascript
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 项目工程化

完整项目还需要:

每条都有具体内容——前面章节都有覆盖(§6.14 TS 类型 / §6.16 测试 / §8.x wasm-pack)。这一节展示如何把它们组合在一起。

7.19.6 性能数据

实测:5 种 API 的性能特征(10KB Markdown):

API耗时备注
render4 ms基础调用
render_with_options4.5 ms加创建 + free 选项
render_strict4.2 ms加 input validation
render_url + fetch50-500 ms主要是网络
MarkdownStream 100 chunks8 ms多次 push 的累积开销

各 API 性能差异主要在协议本身——render_strict 的安全检查 0.2ms 开销值得,render_url 的网络是不可避免的。

7.19.7 教训:从 0 到产品级 wasm-bindgen 项目

每阶段对应不同投入和收益——大多数项目停在阶段 2-3 即可。开源社区项目(pulldown-cmark-wasm 这种)才需要走到阶段 4。

理解了类型映射的所有层面,这套"5 API 金字塔"模式可以应用到任何 wasm-bindgen 项目——它是把一个 Rust crate 完整 + 渐进暴露给 JS 的标准范式。

7.20 类型映射的常见问题 FAQ

前面 19 节系统介绍了所有类型映射——但读者实战中会遇到具体问题。这一节是从社区讨论提炼的高频问题 FAQ,每个都是真实工程场景。

7.20.1 Q1: 为什么我的 Vec<u8> 在 JS 里是 Uint8Array 而 Vec<i8> 是 Int8Array?

回答:wasm-bindgen 对原生数值的 Vec 直接用 TypedArray(零拷贝可能),其他类型用普通 Array(每元素一个 JS 对象)。性能差异 5-50x。

7.20.2 Q2: 我能直接传 HashMap<String, Vec<u8>> 给 JS 吗?

不能直接传——必须经过 serde:

rust
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)?)
}

JS 侧拿到的是 plain object(不是 Map):

javascript
const data = wasm.get_data();
console.log(data['key']);  // 不是 data.get('key')

如果需要真 Map,用 js_sys::Map 手动构造。

7.20.3 Q3: Option<T> 在 JS 里是 null 还是 undefined?

rust
#[wasm_bindgen]
pub fn maybe_value() -> Option<i32> {
    None
}

JS 侧得到 undefined——不是 null。这与 TypeScript 习惯一致:

javascript
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 的方法:

rust
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 里是数字?

rust
#[wasm_bindgen]
pub enum Color {
    Red,
    Green,
    Blue,
}

JS 侧得到的是数字(0/1/2),不是字符串。这是 wasm-bindgen 的优化——传数字比传字符串快。如果需要字符串:

rust
#[wasm_bindgen]
impl Color {
    pub fn name(&self) -> &str {
        match self {
            Color::Red => "Red",
            Color::Green => "Green",
            Color::Blue => "Blue",
        }
    }
}

或者用 &str 作为接口(但失去枚举的类型安全)。

7.20.6 Q6: 我能传 JS 函数给 Rust 作为回调吗?

可以——用 &js_sys::Function

rust
#[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 调用:

javascript
wasm.process_with_callback(items, (item) => console.log(item));

7.20.7 Q7: 大字符串传输有性能问题吗?

有——每次跨边界传字符串都 UTF-8 编码 + 复制。1MB 字符串约 2-5ms。

优化模式:

rust
// 反模式:传完整字符串
#[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 做超长任务吗?

能——但要小心:

rust
#[wasm_bindgen]
pub async fn long_task() -> i32 {
    // 1. 让出控制权,不阻塞主线程
    for _ in 0..1000 {
        process_chunk();
        wasm_bindgen_futures::yield_now().await;
    }
    42
}

每隔一段就 yield_now() 让出主线程——否则浏览器 UI 卡死。

7.20.9 Q9: 我能在 JS 里 instanceof 我的 Rust struct 吗?

可以——#[wasm_bindgen] 把 struct 编译为 JS class,instanceof 工作:

javascript
import { User } from 'my-wasm-lib';
const u = new User('Alice');
console.log(u instanceof User);  // true

但跨 wasm-bindgen 版本可能失败——确保所有相关代码用同一版本。

7.20.10 Q10: 类型映射会做哪些隐式转换?

理解这些隐式转换避免运行时惊讶——例如 JS number 是 f64,传给 Rust i32 时会校验范围。

7.20.11 Q11: 为什么我的 wasm-bindgen 版本升级后旧代码不能编了?

wasm-bindgen 0.2.x 不保证 minor 兼容(§6.15 已说明)。常见破坏性变更:

  • 字符串 ABI 改变(0.2.50)
  • u64 BigInt(0.2.66)
  • multivalue 默认开启(0.2.84)

修复:读 CHANGELOG,按指南迁移。

7.20.12 FAQ 的工程价值

把 FAQ 写进项目 wiki——团队遇到类似问题时不需要"再次研究",直接查 FAQ。这套知识资产化让团队效率显著提升。

7.21 跨书关联:类型映射的通用模式

wasm-bindgenIntoWasmAbi/FromWasmAbi trait 和本系列其他书中的类型转换框架是同一个设计模式的不同应用:

框架核心 trait转换方向跨边界
wasm-bindgenIntoWasmAbi / FromWasmAbiRust ↔ WASM ABI是(线性内存 ↔ JS 堆)
SerdeSerialize / DeserializeRust ↔ 数据模型否(Rust 进程内部)
sqlxEncode / DecodeRust ↔ SQL 协议是(Rust ↔ 数据库网络协议)
axumFromRequest / IntoResponseRust ↔ HTTP是(Rust ↔ TCP 流)

这些框架的共同结构:定义一个 trait 把 Rust 类型"拉平"为目标域的表示,然后用过程宏自动生成 impl。理解了 wasm-bindgenIntoWasmAbi,其他框架的同名 trait 就是同一个模式——只是"目标域"不同。

具体到 Serde:两者都有一个"中间表示"的概念。Serde 的 Serializer trait 定义了通用数据模型(booli32stringseqmap 等),impl Serialize for TT 拆解为这些基本元素。wasm-bindgen 的 WASM ABI 就是它的"通用数据模型"——只有 i32/i64/f32/f64 四种元素,impl IntoWasmAbi for TT 拆解为这四种基本值。Serde 的数据模型更丰富(有 stringseqmap),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 串成一条命令。

基于 VitePress 构建