Rust + WebAssembly 全链路解析

第6章 wasm-bindgen:Rust 与 JS 的桥梁

作者 杨艺韬 · 20,177 字 · 发布于 · 更新于

"Facilitating high-level interactions between Wasm modules and JavaScript." — wasm-bindgen 仓库 README

6.1 为什么需要 wasm-bindgen

WASM MVP 的值类型只有 i32、i64、f32、f64 四种。这不是一个设计失误——WASM 的设计目标是做一个低层级的虚拟指令集,而非高级语言的 FFI 框架。但对于 Rust 开发者来说,这意味着:

  • Rust 的 String 不能直接传给 JS——JS 看到的只是一个指针(i32)
  • JS 的 Promise 不能直接传给 Rust——Rust 看到的只是一个 i32 索引
  • Rust 的 Result<T, E> 不能直接映射到 JS 的 Error——两者没有共同的表示
  • Rust 的 Vec<u8> 在 JS 眼里只是线性内存中的一段连续字节——没有 length 属性,没有迭代器
graph LR
  subgraph "Rust 世界"
    A[String]
    B["Vec&lt;u8&gt;"]
    C["Result&lt;T, E&gt;"]
    D[closure]
    E["JsValue"]
  end
  subgraph "WASM 边界(只有 i32/i64/f32/f64)"
    F["指针 + 长度"]
    G["指针 + 长度"]
    H["i32 标签 + 值"]
    I["表索引"]
    J["句柄索引 i32"]
  end
  subgraph "JS 世界"
    K[String]
    L[Uint8Array]
    M["Error / throw"]
    N[Function]
    O["任意 JS 对象"]
  end
  A --> F --> K
  B --> G --> L
  C --> H --> M
  D --> I --> N
  E --> J --> O

wasm-bindgen 的工作就是在这些表示之间自动生成转换代码——胶水代码(glue code)。你写 Rust 时只管用 String、Result、JsValue,wasm-bindgen 负责在边界上做正确的转换。没有它,你需要手写每一个函数的序列化/反序列化逻辑——这跟用 C 写 Python 扩展模块时手写 PyArg_ParseTuple 一样痛苦。

这个问题的本质是阻抗不匹配(impedance mismatch)——两个系统有完全不同的数据表示和生命周期模型,中间需要一个适配层。数据库领域有对象关系阻抗不匹配(ORM 解决),操作系统领域有用户态/内核态的阻抗不匹配(系统调用接口解决),WASM 领域有 Rust/JS 的阻抗不匹配——wasm-bindgen 就是这个适配层。

6.2 wasm-bindgen 的三层架构

wasm-bindgen 不是单一工具,而是由三个协同工作的组件构成:

组件 语言 作用 源码位置
wasm-bindgen crate Rust 运行时库(JsValue、ABI 转换 trait)+ 重导出过程宏 仓库根的 src/
wasm-bindgen-macro / -macro-support / -shared Rust 过程宏解析与代码生成、宏与 CLI 共用的 schema crates/macro/、crates/macro-support/、crates/shared/
wasm-bindgen-cli / -cli-support Rust 命令行工具,读取 .wasm + 元数据,生成 JS 胶水代码 crates/cli/、crates/cli-support/
生成的 JS 胶水 JavaScript 包装 WASM 导出/导入,处理类型转换 构建时生成
flowchart LR
  subgraph "编译期(Rust 侧)"
    A["Rust 源码<br/>+ #[wasm_bindgen]"] --> B["过程宏展开<br/>生成描述函数"]
    B --> C["cargo build<br/>→ .wasm"]
    C --> D["自定义段嵌入<br/>接口元数据"]
  end
  subgraph "构建后期(CLI 侧)"
    D --> E["wasm-bindgen CLI<br/>解析自定义段"]
    E --> F["生成 JS 胶水<br/>.js + .d.ts"]
    E --> G["修改 .wasm<br/>移除自定义段"]
  end
  subgraph "运行期(浏览器侧)"
    F --> H["JS 包装函数<br/>类型转换"]
    G --> I["优化后的 .wasm<br/>二进制"]
    H --> J["JS↔WASM 互操作"]
    I --> J
  end
  style B fill:#6366f1,color:#fff
  style E fill:#f59e0b,color:#333
  style J fill:#10b981,color:#333

关键设计:wasm-bindgen 不修改 Rust 编译器的行为——它利用 WASM 模块的自定义段(Custom Section)来携带元数据。Rust 代码中的 #[wasm_bindgen] 宏在编译时把接口描述写入自定义段,CLI 工具在构建后读取这些描述,生成对应的 JS 代码,然后从 .wasm 中移除自定义段(减小体积)。这种"编译时不碰编译器,构建后做代码生成"的策略,使得 wasm-bindgen 通常能跟随 Rust 编译器的版本升级而无需改动——它主要依赖 WASM 二进制格式的稳定性。例外也有:链接器产物的形态变了,CLI 照样得跟着修(0.2.118 就修了 rustc 1.94+ 产物触发的两个 CLI panic,见 §6.15.1)。

这种设计可以类比编译器的多遍架构——第一遍(Rust 编译器)产出 .wasm + 元数据,第二遍(wasm-bindgen CLI)消费元数据并产出最终产物。两遍之间通过自定义段这个稳定的"文件格式"解耦,使得第一遍的工具(rustc)和第二遍的工具(wasm-bindgen CLI)可以独立发布和更新。这也是为什么 wasm-bindgen 能够支持多种输出格式——0.2.118 的 --target 可选 bundler(默认)、web、nodejs、no-modules、deno、experimental-nodejs-module、module 七种(crates/cli/src/wasm_bindgen.rs 的 enum Target),CLI 根据目标环境生成不同的 JS 加载代码,而 Rust 编译器完全不需要知道这些差异。

6.3 #[wasm_bindgen] 过程宏:编译期的代码变换

#[wasm_bindgen] 是一个属性式过程宏(attribute proc macro),它在 Rust 编译期对被标注的项做两件事:

  1. 改写函数签名:把 Rust 类型转换为 WASM ABI 兼容的原始类型(i32/i64/f32/f64)
  2. 生成描述函数:在每个导出函数旁边生成一个 __wbindgen_describe_* 函数,把类型信息编码为 u32 序列写入自定义段

导出函数的宏展开

use wasm_bindgen::prelude::*;

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

宏展开后大致生成以下两样东西。下面这段是展开结果的示意,不是任何源码文件里的原文——宏是用 quote! 把 token 拼出来的,这里省掉了外层的 const _: () = { ... } 包装、__wbindgen_coverage! 和一串编译期检查项:

// 1. 导出的 shim:参数/返回值全部换成 WASM ABI 类型
//    &str 走 RefFromWasmAbi,它的 Abi 是 WasmSlice;splat() 一律摊成
//    四个槽位,WasmSlice 的 Prim1/Prim2 是 ptr/len,Prim3/Prim4 是 ()
#[cfg_attr(target_family = "wasm", export_name = "greet")]
pub unsafe extern "C-unwind" fn __wasm_bindgen_generated_greet(
    arg0_1: u32, arg0_2: u32, arg0_3: (), arg0_4: (),
) -> wasm_bindgen::convert::WasmRet<
        <String as wasm_bindgen::convert::ReturnWasmAbi>::Abi> {
    // 参数还原与调用都在 maybe_catch_unwind 的闭包里
    let _ret = wasm_bindgen::__rt::maybe_catch_unwind(|| {
        let arg0 = unsafe {
            <str as wasm_bindgen::convert::RefFromWasmAbi>::ref_from_abi(
                WasmAbi::join(arg0_1, arg0_2, arg0_3, arg0_4),
            )
        };                          // arg0 是 Box<str> 锚点
        let arg0 = &*arg0;
        greet(arg0)                 // 闭包结束时锚点 drop,参数内存释放
    });
    <String as wasm_bindgen::convert::ReturnWasmAbi>::return_abi(_ret).into()
}

// 2. 描述函数:把类型签名"讲"给 CLI 听
#[no_mangle]
#[doc(hidden)]
pub extern "C-unwind" fn __wbindgen_describe_greet() {
    use wasm_bindgen::describe::*;
    wasm_bindgen::__rt::link_mem_intrinsics();
    inform(FUNCTION);
    inform(0);                  // shim 索引
    inform(1);                  // 参数个数
    inform(REF); inform(STRING); // 参数 0:&str(REF 套 STRING)
    inform(STRING);              // 返回类型 ret:String
    inform(STRING);              // inner_ret:非 async 函数与 ret 相同
}

这两段的生成规则分头躺在两个文件里:shim 的名字 __wasm_bindgen_generated_greet 由 crates/macro-support/src/ast.rs:559 的 rust_symbol() 拼出(前缀 + 可选的类名 + 函数名);shim 本体、arg{i} 这套参数名(crates/macro-support/src/codegen.rs:635)、把 WasmSlice 摊成 4 个原始槽位的 splat()(同文件 2664 行)都在 codegen.rs;描述函数的名字则是 crates/macro-support/src/codegen.rs:2607 的 format!("__wbindgen_describe_{ident}")——ident 用的是导出名,所以是 greet 而不是内部符号名。返回类型描述两遍,是因为 CLI 侧 crates/cli-support/src/descriptor.rs 的 Function::decode 固定依次读 ret 和 inner_ret(后者给 async fn 描述 Future 的输出类型)。上面的示意与 1.96.0 上 RUSTC_BOOTSTRAP=1 cargo rustc --target wasm32-unknown-unknown -- -Zunpretty=expanded 的实际展开逐项对得上。

注意 inform 的签名是 pub fn inform(a: u32)——它是按顺序一个个调用的,不是把标志位「或」在一起。标签常量定义在 crates/shared/src/tys.rs:10 的 tys! 调用里,是一串从 0 开始的连续整数:I8 U8 I16 U16 I32 U32 I64 U64 I128 U128 F32 F64 BOOLEAN FUNCTION CLOSURE CACHED_STRING STRING REF REFMUT LONGREF SLICE VECTOR EXTERNREF NAMED_EXTERNREF ENUM STRING_ENUM RUST_STRUCT CHAR OPTIONAL RESULT UNIT CLAMPED NONNULL。

这里有一个容易漏掉的关键点:描述函数不是被静态解析的,而是被真正"执行"的。inform 转发给导入函数 __wbindgen_describe,CLI 侧用 crates/cli-support/src/interpreter/ 里的一个微型 WASM 解释器把每个 __wbindgen_describe_* 跑一遍,收集它 inform 出来的 u32 流,再由 crates/cli-support/src/descriptor.rs 解码成结构化签名。这也解释了为什么 wasm-bindgen crate 和 CLI 的版本必须严格匹配——标签编号和解码逻辑是一套内部协议。

描述信息的自定义段

描述函数只解决了「类型长什么样」,还有一半信息——JS 名字、js_namespace、内联 JS 片段、module = "..."、要生成的 TS 类型——是宏在编译期直接序列化进自定义段的。宏生成的是一个带 #[link_section] 的静态字节数组(crates/macro-support/src/codegen.rs:181,此处省掉了同处的 #[allow(long_running_const_eval)]):

#[link_section = "__wasm_bindgen_unstable"]
static _GENERATED: [u8; _LEN] = flat_byte_slices([_PREFIX_JSON_BYTES, _ENCODED_BYTES]);

每个 #[wasm_bindgen] 项贡献一段这样的字节,链接器把它们拼成一个 __wasm_bindgen_unstable 自定义段。每段的开头是一个 4 字节小端长度前缀 + 一小段 JSON(实测展开里有 schema_version 和 version 两个键,值都是 0.2.118),后面是同样带 4 字节长度前缀的 Program 结构紧凑编码——Program 的字段列表在 crates/shared/src/lib.rs:19 的 shared_api! 宏里(struct Program<'a> 在同文件 22 行):exports、enums、imports、structs、typescript_custom_sections、local_modules、inline_js、unique_crate_identifier、package_json、linked_modules。

CLI 侧 crates/cli-support/src/wit/mod.rs:1796 用 module.customs.remove_raw("__wasm_bindgen_unstable") 把它取出来(顺便从产物里删掉),再配合解释器跑出来的类型描述,就凑齐了完整接口。这比单纯看 .wasm 的导出段有用得多——WASM 的类型段只有 functype,没有任何语义信息。

6.4 JS→WASM 函数调用机制

理解了宏展开,现在从 JS 调用者的视角走一遍完整的调用流程。

调用链全景

sequenceDiagram
  participant JS as JavaScript 调用者
  participant Glue as JS 胶水代码
  participant Mem as WASM 线性内存
  participant Wasm as Rust 函数
  JS->>Glue: greet("World")
  Glue->>Glue: passStringToWasm0("World")
  Note over Glue,Mem: TextEncoder 编码为 UTF-8<br/>__wbindgen_malloc 分配内存<br/>复制字节到线性内存
  Glue->>Wasm: greet(ptr=0x1000, len=5)
  Wasm->>Wasm: 从 ptr+len 构造 &str(Box<str> 锚点)
  Wasm->>Wasm: 执行 greet() → String
  Wasm->>Wasm: 把 String 拆成 (ptr, len) 交出所有权
  Wasm->>Wasm: shim 返回前 drop 锚点<br/>参数那块内存在 Rust 侧释放
  Wasm-->>Glue: ret = (ptr, len)
  Glue->>Mem: getStringFromWasm0(ptr, len)
  Note over Glue,Mem: TextDecoder 解码 UTF-8<br/>构造 JS String
  Glue->>Mem: __wbindgen_free 释放返回值内存
  Glue-->>JS: "Hello, World!"

参数传递:从 JS 值到 WASM 原生类型

JS 胶水代码的核心职责是在 JS 值和 WASM 原生类型之间做转换。不同类型的转换策略完全不同:

原生数值(i32/f32/f64)——直接传递,零开销:

// Rust: pub fn add(a: i32, b: i32) -> i32
export function add(a, b) {
    return wasm.add(a, b);
}

字符串(&str/String)——需要内存分配和复制:

// wasm-bindgen 0.2.118 生成的 JS(删减版;模板见 crates/cli-support/src/js/mod.rs:2130
// 的 expose_pass_string_to_wasm(),这里省掉了 realloc === undefined 的分支和 debug 断言)
function passStringToWasm0(arg, malloc, realloc) {
    // 快路径:纯 ASCII 时直接按 charCodeAt 写字节,完全不进 TextEncoder
    let len = arg.length;
    let ptr = malloc(len, 1) >>> 0;
    const mem = getUint8ArrayMemory0();
    let offset = 0;
    for (; offset < len; offset++) {
        const code = arg.charCodeAt(offset);
        if (code > 0x7F) break;
        mem[ptr + offset] = code;
    }
    // 慢路径:遇到非 ASCII,扩容到最坏情况 3 字节/字符,再用 encodeInto 续写
    if (offset !== len) {
        if (offset !== 0) arg = arg.slice(offset);
        ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
        const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
        const ret = cachedTextEncoder.encodeInto(arg, view);
        offset += ret.written;
        ptr = realloc(ptr, len, offset, 1) >>> 0;   // 缩回真实长度
    }
    WASM_VECTOR_LEN = offset;
    return ptr;
}

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;
        // 返回 String 时是两个值(ptr, len):开了 multivalue 就直接返回两个,
        // 否则先向下挪栈指针腾出一块 retptr,从线性内存里读回来
        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);
    }
}

realloc 参数的用途常被误解:它不是为了跨调用复用缓冲区,而是为了处理非 ASCII——先按「一字符一字节」乐观分配,遇到多字节字符再 realloc 扩到 3 倍上界,写完后 realloc 缩回真实长度。整个过程仍然是一次性的,函数返回后缓冲区就没了。

顺便记住三个 ABI 约定:__wbindgen_malloc(size, align)、__wbindgen_realloc(ptr, old_size, new_size, align)、__wbindgen_free(ptr, size, align)——都带对齐参数,手写胶水时漏掉会直接破坏堆。

还有一个容易被忽视的细节:参数那块内存在函数返回时就被释放了,而且释放它的是 Rust 侧,不是 JS 侧。上面 greet 的胶水里 finally 块释放的是 deferred2_0/deferred2_1——那是返回值的 (ptr, len);参数指针 ptr0 从头到尾没有出现在任何 __wbindgen_free 里。真正释放它的是 shim:&str 参数走 RefFromWasmAbi,而 impl RefFromWasmAbi for str 的 Anchor 是 Box<str>(src/convert/slices.rs:381),shim 里那个局部锚点在函数结束时 drop,把这段线性内存还给分配器。(CLI 只在出向值上生成延迟释放——Instruction::DeferFree 只在 crates/cli-support/src/wit/outgoing.rs:125 和 530 行两处产生,入向参数没有对应指令。)这意味着如果 Rust 侧的函数内部保存了对 &str 的引用(比如存入全局状态),这个引用在函数返回后就会变成悬垂指针。这是 wasm-bindgen 的一个常见陷阱——如果你的 Rust 函数需要长期持有传入的数据,必须使用 String(获取所有权)而非 &str(借用)。

返回值传递:从 WASM 到 JS

返回值的处理比参数更复杂,因为 Rust 函数的返回值可能是任意类型,而 WASM 只能返回一个或多个原生值。wasm-bindgen 的策略是:

  • 返回原生类型(i32/f64 等):直接返回
  • 返回 String/Vec<u8>:Rust 侧把数据留在线性内存中,返回一个编码后的指针;JS 侧读取后释放
  • 返回结构体:返回指向 Rust 对象的 i32 指针;JS 侧创建包装类持有该指针
  • 返回 JsValue:返回句柄索引(§6.6 详述)

返回值传递的一个微妙之处是所有权转移。当 Rust 函数返回 String 时,Rust 侧的 String 被 drop——但它的字节数据已经"转移"给了 JS 侧(通过指针+长度传递)。实际上 0.2.118 里根本没有 __wbindgen_string_new 这种辅助导入(它早年存在,现已删除)——导出函数直接把 (ptr, len) 两个值交出来(开了 multivalue 就是两个返回值,否则写进 retptr 再由 JS 读回),JS 侧 getStringFromWasm0(ptr, len) 解码,随后 __wbindgen_free(ptr, len, 1) 释放。这意味着 String 的字节数据短暂地存在于 WASM 线性内存中(从 Rust 函数返回到 JS 读取完毕)。这里的指针是线性内存里的偏移量,memory.grow() 不会让它失效;会失效的是基于旧 memory.buffer 建的 TypedArray 视图——所以 getStringFromWasm0 每次都经 getUint8ArrayMemory0() 取视图(见 §6.5)。

6.5 内存传递:所有权与复制

wasm-bindgen 的核心难题是字符串和缓冲区的传递。Rust 和 JS 运行在完全不同的内存空间中——Rust 使用 WASM 线性内存(一块连续的 ArrayBuffer),JS 使用 GC 管理的堆。两者之间传递数据,必须跨边界复制或共享。

传递字符串:两次复制

#[wasm_bindgen]
pub fn process(input: &str) -> String { ... }

Rust 侧接收 &str 时,JS 胶水代码先在线性内存中分配一块空间,把 JS 字符串的 UTF-8 编码复制进去,然后传指针+长度给 WASM。返回 String 时反过来——WASM 返回指针+长度,JS 读取并构造 JS String,然后释放 WASM 侧内存。

这个过程有两次内存复制(JS→WASM 一次,WASM→JS 一次)和两次分配/释放。短字符串的开销以固定成本(调用 + malloc/free)为主,大字符串则由复制和 UTF-8 编解码主导——分界点大约在字符串长到复制时间超过固定成本的地方,具体位置取决于引擎,需要实测。

传递二进制数据:复制与零拷贝

#[wasm_bindgen]
pub fn process_bytes(input: &[u8]) -> Vec<u8> { ... }

wasm-bindgen 默认对 &[u8] 和 Vec<u8> 也做复制,和字符串一样——因为 JS 侧的 Uint8Array 可能引用外部 ArrayBuffer(不在 WASM 线性内存中),不复制就无法保证数据在线性内存中的连续性。

零拷贝的做法是让 JS 手动传入指向 WASM 内存的数据:

const memory = wasm.memory;
const ptr = wasm.__wbindgen_malloc(size, 1);   // (size, align)
const view = new Uint8Array(memory.buffer, ptr, size);
// ... 填充 view ...
const result = wasm.process_from_ptr(ptr, size);
wasm.__wbindgen_free(ptr, size, 1);            // (ptr, size, align)

Rust 侧也有对应的封装(如 js-sys 的 unsafe fn Uint8Array::view(&[u8])),底层逻辑一样——直接在 memory.buffer 上开视图。需要注意的关键陷阱:memory.grow() 之后,所有基于旧 memory.buffer 创建的 TypedArray 视图都会失效——因为 grow 会让旧的 ArrayBuffer 对象被 detach、换上一个新的 ArrayBuffer 对象。

具体来说,当 Rust 代码调用 memory.grow()(比如因为 Vec::push 导致容量不足,触发 realloc),引擎会 detach 旧的 ArrayBuffer 对象、换上一个覆盖更大范围的新对象。注意这里换掉的是 JS 侧那个 ArrayBuffer 对象,底层字节通常并不搬家(引擎预留了地址空间,见 §3.11)——但对 JS 代码来说效果一样:所有 new Uint8Array(memory.buffer, ...) 创建的视图都被 detach,byteLength 归零,再次访问只会读到空数组。安全的做法是每次访问前都重新从 memory.buffer 获取视图,或者使用 wasm-bindgen 生成的 getUint8ArrayMemory0() 辅助函数——它缓存一个 Uint8Array 视图,每次调用先检查 cachedUint8ArrayMemory0.byteLength === 0(buffer 被 detach 的标志),发现失效就重建。

&str vs String:借用与所有权在边界的差异

Rust 的 &str 和 String 在 wasm-bindgen 中有不同的语义:

Rust 签名 JS→WASM 行为 WASM→JS 行为
fn f(s: &str) JS 分配内存复制字符串,传指针+长度,调用后释放 不适用
fn f(s: String) JS 分配内存复制字符串,传指针+长度,所有权转移给 Rust 不适用
fn f() -> String 不适用 Rust 把 String 留在线性内存,返回指针+长度,JS 读取后释放
fn f() -> &str 不适用 不支持:guide/src/reference/types/str.md 的支持矩阵里 str 的「T return value」一栏是 No,只能返回 String

对于参数,&str 和 String 在 JS 侧的胶水代码几乎没有区别——都是复制+传指针。差异在 Rust 侧:&str 意味着函数不获取所有权,String 意味着函数获取所有权。但两者的参数内存都在 shim 返回时由 Rust 侧释放(&str 是 Box<str> 锚点 drop,String 是函数自己 drop),所以实际效果相同。推荐使用 &str 作为参数类型,语义更清晰。

6.6 JsValue 的句柄管理:对象栈与 externref 表

JS 对象在 wasm-bindgen 中通过 JsValue 表示——这是一个 Rust 结构体,内部持有一个 u32 句柄索引。这个索引落在哪张表上,取决于模块有没有开 reference-types:近几年的 rustc 默认开着,所以你实际拿到的是 WASM 侧的 externref 表(生成的胶水里是 __wbindgen_externrefs + __externref_table_alloc,第 11 章 §11.4.1 有完整代码)。本节先讲关掉 reference-types 时那条老路径——JS 侧的对象栈(Stack Cache),因为它把"句柄怎么分配、怎么回收"讲得最清楚,externref 表只是把同一套簿记搬进了 WASM:

graph TB
  subgraph "Rust 侧"
    A["JsValue { idx: 1500 }"]
    B["JsValue { idx: 1501 }"]
    C["JsValue { idx: 1025 } ← JS null"]
  end
  subgraph "JS 侧对象表 heap"
    D["heap[1500] = {some: 'object'}"]
    E["heap[1501] = [1, 2, 3]"]
    F["heap[1025] = null(常量槽)"]
  end
  subgraph "JS 堆(GC 管理)"
    G["{some: 'object'}"]
    H["[1, 2, 3]"]
  end
  A -->|idx 查找| D
  B -->|idx 查找| E
  C -->|idx 查找| F
  D -->|强引用| G
  E -->|强引用| H

对象栈的工作原理

对象栈是一个 JS Array,前面留出一段预分配区,后面按需增长。0.2.118 里这条路径的实现(crates/cli-support/src/js/mod.rs,只在关掉 reference-types 时才会出现在产物里):

// 常量:INITIAL_HEAP_OFFSET = 1024
//       INITIAL_HEAP_VALUES = ["undefined", "null", "true", "false"]
let heap = new Array(1024).fill(undefined);
heap.push(undefined, null, true, false);   // 索引 1024..1027
let heap_next = heap.length;               // 1028

function addHeapObject(obj) {
    if (heap_next === heap.length) heap.push(heap.length + 1);
    const idx = heap_next;
    heap_next = heap[idx];
    heap[idx] = obj;
    return idx;
}

function dropObject(idx) {
    if (idx < 1028) return;   // INITIAL_HEAP_OFFSET + 4 个常量,不回收
    heap[idx] = heap_next;    // 把这个槽挂回空闲链表
    heap_next = idx;
}

function takeObject(idx) { const ret = getObject(idx); dropObject(idx); return ret; }

三个要点:

  1. 前 1024 个槽是借用栈(addBorrowedObject 从 stack_pointer 往下压,函数返回时弹出),紧跟着 4 个常量槽 undefined/null/true/false——dropObject 对索引 < 1028 的直接返回,不回收。这个 1024 是 0.2.109 才从 128 提上来的,为的是修「深调用栈或大量并发异步操作时 table index is out of bounds」(仓库 CHANGELOG.md 的 0.2.109 一节记的就是这条 externref 栈从 128 槽提到 1024 槽;CHANGELOG 里挂的链接是 #4951,但实际改动的提交是 #4957,同一提交也把 JS 侧的 INITIAL_HEAP_OFFSET 改成了 1024);两个常量在 crates/cli-support/src/js/mod.rs:218(INITIAL_HEAP_VALUES)和 220 行(INITIAL_HEAP_OFFSET)。
  2. 1028 以上是动态槽,用空闲链表管理:heap_next 指向下一个空闲槽,每个空闲槽里存着再下一个空闲槽的索引,槽用完了就 heap.push(heap.length + 1) 追加一个。
  3. 没有所谓「栈满了就转移到 Set」的机制——数组直接增长。

JsValue 的 Rust 侧表示

// src/lib.rs:161
pub struct JsValue {
    idx: u32,
    _marker: PhantomData<*mut u8>, // not at all threadsafe
}

JsValue 在 Rust 侧就是一个 u32——没有任何堆分配(PhantomData<*mut u8> 只是用来去掉自动 Send/Sync;#[cfg(not(target_feature = "atomics"))] 时又手动 unsafe impl 回来了,见 src/lib.rs:166)。它的 Drop 调用导入函数 __wbindgen_object_drop_ref(idx),在对象栈路径下由 JS 侧把槽位挂回空闲链表,每次 drop 都是一次跨边界调用——高频创建/销毁 JsValue 的场景要注意,能缓存就缓存。(在下面的 externref 路径下,CLI 会把这个导入改写成 WASM 内部的 __externref_table_dealloc,实测 0.2.118 产物的导入段里已经没有 __wbindgen_object_drop_ref。)

externref 表:0.2.118 上的默认路径

Reference Types 提案引入了 externref 类型——WASM 可以直接把 JS 对象引用放进一张 externref 表,JsValue 里的 u32 变成表索引,槽位由 WASM 侧的分配器管理,不再需要 JS 那个 heap 数组。

这里有一个已经过时的说法要更正:--reference-types 这个 CLI 标志在 0.2.96 就被废弃了(PR #4237,「Deprecate --reference-types in favor of automatic target feature detection」),现在在 --help 里都是隐藏的。0.2.118 的行为是:

// crates/cli-support/src/lib.rs:344
// Enable reference type transformations if the module is already using it.
if let Ok(true) = wasm_conventions::target_feature(&module, "reference-types") {
    self.externref = true;
}
// multivalue 同理

也就是说——CLI 读 .wasm 里的 target-features 段来决定要不要走 externref 路径。而 reference-types 从 Rust 1.82 起就在 wasm32 目标的默认 feature 集里(Rust 1.82.0 发布说明的 Compatibility Notes 一条:multivalue 与 reference-types 自此默认开启,rust-lang/rust#128511;当前默认集的权威清单在 rustc 的平台文档 src/doc/rustc/src/platform-support/wasm32-unknown-unknown.md 里)。结论:用近几年的 rustc + wasm-bindgen,你默认已经在跑 externref 路径了,上面那个 heap 数组只在显式关掉 reference-types 时才会出现在产物里。注意光靠 RUSTFLAGS="-C target-feature=-reference-types" 关不掉:预编译的 std 仍带着这个 feature,target-features 段照样写着 +reference-types,实测 1.96.0 下 CLI 仍走 externref 路径并报 failed to find the __wbindgen_externref_table_alloc function;要连 std 一起重编(上面提到的 rustc 平台文档同样写明关 feature 须重编标准库;实测 RUSTC_BOOTSTRAP=1 cargo build -Zbuild-std=std,panic_abort 后)才能拿到上面那份 heap 胶水(另外还有 WASM_BINDGEN_EXTERNREF / WASM_BINDGEN_MULTI_VALUE 两个环境变量可以强制打开,见 crates/cli-support/src/lib.rs:91)。

对象栈的设计体现了 wasm-bindgen 的一个核心工程原则——在能力最弱的公共平台(WASM MVP)上构建最高效的抽象。对象栈用数组+空闲链表实现了类似 GC 的引用管理,同时保证了和所有 WASM 运行时的兼容性。当平台能力提升(reference-types 进入默认集)时,wasm-bindgen 靠读 target-features 段无缝切到 externref 实现,用户什么都不用改——这正是抽象层存在的意义。

6.7 导入 JS 函数

wasm-bindgen 不仅支持 Rust→JS 的导出,还支持 JS→Rust 的导入——让 Rust 代码调用 JS 函数。

基本导入

// 不带 module 属性 = 从宿主全局作用域取
#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = console)]
    fn log(s: &str);

    #[wasm_bindgen(js_namespace = Math)]
    fn random() -> f64;
}

#[wasm_bindgen]
pub fn debug_greet(name: &str) {
    log(&format!("Greeting: {}", name));
    let r = random();
    log(&format!("Random: {}", r));
}

extern "C" 块声明了要从 JS 导入的函数;不带 module 属性时从宿主全局作用域取,带 module = "..." 时从对应模块 import(见 §6.12)。

编译期这些导入统统落在一个占位模块 __wbindgen_placeholder__ 上(crates/macro-support/src/codegen.rs:286 等处写死的 #[link(wasm_import_module = "__wbindgen_placeholder__")]),CLI 再把它们重写成真实的导入并生成对应实现:

// --target bundler 下,导入实现被写成 my_lib_bg.js 里的具名导出
// (函数名后缀是内容哈希,实际值随代码变化)
export function __wbg_log_abc123(arg0, arg1) {
    console.log(getStringFromWasm0(arg0, arg1));
}
export function __wbg_random_def456() {
    const ret = Math.random();
    return ret;
}

CLI 同时把 .wasm 里这些导入的模块名从 __wbindgen_placeholder__ 改写成 ./my_lib_bg.js——于是打包器把 .wasm 的导入需求和 _bg.js 的导出对上号。--target web/nodejs 则改为生成一个 __wbg_get_imports() 函数,把同一批实现塞进一个 { "./my_lib_bg.js": { ... } } 的导入对象。

内联 JS 代码

wasm-bindgen 支持在 Rust 代码中直接内联 JS:

#[wasm_bindgen(inline_js = "export function customAdd(a, b) { return a + b; }")]
extern "C" {
    #[wasm_bindgen(js_name = customAdd)]   // JS 侧导出名与 Rust 函数名不同,必须写明
    fn custom_add(a: i32, b: i32) -> i32;
}

CLI 把内联的 JS 代码写成输出目录下的独立模块文件(实测为 snippets/<crate 名>-<哈希>/inline0.js),再从那里导入。这适合短小的 JS 辅助函数——但不要用它写复杂逻辑,因为内联 JS 不参与 ESLint/TypeScript 的类型检查。

导入全局对象

#[wasm_bindgen]
extern "C" {
    // 访问 window 对象
    #[wasm_bindgen(js_namespace = window)]
    fn alert(s: &str);

    // 访问 document 对象
    #[wasm_bindgen(js_namespace = ["document", "body"])]
    fn append_child(child: &web_sys::Node);
}

js_namespace 支持点分路径,让 Rust 可以访问任意深度的 JS 全局对象。web_sys crate 本身就是用这种方式把整个 Web API 暴露给 Rust 的——它内部包含了数千个 #[wasm_bindgen] extern "C" 声明,覆盖 DOM、Canvas、WebGL、Fetch 等所有浏览器 API。

6.8 导出类型:结构体与构造函数

#[wasm_bindgen] 不仅能标注函数,还能标注结构体——在 JS 侧生成对应的类。

#[wasm_bindgen]
pub struct Counter {
    count: i32,
}

#[wasm_bindgen]
impl Counter {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Counter {
        Counter { count: 0 }
    }

    pub fn increment(&mut self) {
        self.count += 1;
    }

    pub fn get(&self) -> i32 {
        self.count
    }
}

JS 侧使用:

const counter = new Counter();  // 调用 Counter::new()
counter.increment();             // 调用 Counter::increment(&mut self)
counter.increment();
console.log(counter.get());      // 2
counter.free();                  // 释放 Rust 侧内存(应主动调用,见 §6.14.7)

Counter 在 JS 中是一个包装对象——内部持有一个指向 Rust 侧 Counter 在线性内存中地址的 i32 指针。每次方法调用都把指针传回 WASM。constructor 属性让 new Counter() 映射到 Rust 的 Counter::new()——这是 JS 开发者最自然的创建对象方式。

有两个容易搞混的规则要说清楚:

规则一:只有 pub 字段会生成 JS 侧的属性。crates/macro-support/src/parser.rs:501 里遍历字段时第一件事就是 match field.vis { syn::Visibility::Public(..) => {} _ => continue, }——非 pub 字段被直接跳过,对类型没有任何额外要求。上面 Counter 的 count 是私有的,所以 JS 侧只有 increment()/get() 两个方法,没有 .count 属性。

规则二:pub 字段的 getter 默认要求 Copy。因为生成的 getter 是按值返回的。字段类型不是 Copy(比如 String)时,编译器会让你加 #[wasm_bindgen(getter_with_clone)](可以标在字段上,也可以标在整个 struct 上),改成 .clone() 返回;或者干脆把字段设为私有,自己写 #[wasm_bindgen(getter)] 方法。

另外结构体必须是自包含的('static)——不能带生命周期参数,也就不能存 &str,只能存拥有所有权的类型。

6.9 错误处理:Result 与 JsValue

Rust 的 Result<T, E> 映射到 JS 的异常机制:

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

try {
    const result = divide(10, 0); // 抛出的是字符串 "division by zero",不是 Error
} catch (e) {
    console.log(e);         // "division by zero"
    console.log(e.message); // undefined
}

Err 里装的 JsValue 会被原样抛出——这里是一个 JS 字符串,所以 e.message 是 undefined(node v24 实跑)。想让 JS 侧拿到真正的 Error 对象,把错误类型换成 wasm_bindgen::JsError:Result<i32, JsError> 配 Err(JsError::new("division by zero")),此时 e instanceof Error 为 true、e.message 就是那句文本。

wasm-bindgen 的实现方式:这条路径不经过 __wbindgen_throw。0.2.118 的导出 shim 把 Result 摊成三个返回值(Ok 值、错误值的表索引、是否出错),由 JS 胶水自己 throw:

// wasm-bindgen 0.2.118 --target nodejs 实际生成
function divide(a, b) {
    const ret = wasm.divide(a, b);
    if (ret[2]) {
        throw takeFromExternrefTable0(ret[1]);
    }
    return ret[0];
}

__wbindgen_throw(产物里的导入名形如 __wbg___wbindgen_throw_<hash>,JS 侧实现是 throw new Error(...))服务的是 wasm_bindgen::throw_str 这类 Rust 主动抛字符串的场合。

反过来——Rust 调用一个可能 throw 的 JS 函数怎么办?#[wasm_bindgen(catch)] 解决这个问题,而且不依赖 WASM 异常处理提案:

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(catch, js_namespace = JSON, js_name = parse)]
    fn json_parse(text: &str) -> Result<JsValue, JsValue>;
}

在默认产物(模块没启用 WASM 异常处理)里,它的实现是纯 JS 侧的 try/catch(crates/cli-support/src/js/mod.rs:2998 的 expose_handle_error();下面是对象栈路径那份,在同文件 3037 行,默认 externref 路径的那份在 3016 行)。0.2.109 起,若模块启用了 WASM 异常处理,CLI 改为在 WASM 内借 WebAssembly.JSTag 直接捕获,不再生成 handleError(CHANGELOG 的 #4942):

function handleError(f, args) {
    try {
        return f.apply(this, args);
    } catch (e) {
        wasm.__wbindgen_exn_store(addHeapObject(e));  // externref 路径下是 addToExternrefTable0(e)
    }
}

异常被 JS 侧接住并存进一个全局槽(__wbindgen_exn_store),导入 shim 正常返回,Rust 侧的 ABI 转换层看到槽里有值就把它变成 Err(JsValue)。所以加了 catch 就能在 Rust 里正常处理 JS 异常;没加 catch 的导入函数抛异常时,异常会穿透整个 WASM 调用栈冒到最外层的 JS 调用者——这才是「WASM 无法 catch」这句话真正成立的场景。

顺带澄清一个常见误传:0.2.118 的仓库根 Cargo.toml 的 [features] 段里没有 exception-handling 这个 feature,对外的 features 只有 default/std/enable-interning/serde-serialize/spans/strict-macro(外加 gg-alloc 这类内部项和几个已废弃的空 feature)。wasm-bindgen 对 panic=unwind 的支持是通过别的路径推进的——CHANGELOG 的 0.2.109 一节就有一条:用 panic=unwind 构建时 Closure::new()、Closure::once() 等方法开始要求闭包满足 UnwindSafe,并新增了 new_aborting() / once_aborting() 等变体(#4893)。

6.10 wasm-bindgen-cli 的工作原理

wasm-bindgen CLI 是整个流程中最复杂的部分。它的输入是 Rust 编译器生成的 .wasm 文件,输出是修改后的 .wasm + JS 胶水代码 + TypeScript 声明。

四个处理步骤

flowchart TD
  A["输入: .wasm 文件"] --> B["步骤一: 解析自定义段"]
  B --> C["步骤二: 生成 JS 胶水代码"]
  C --> D["步骤三: 修改 .wasm 二进制"]
  D --> E["步骤四: 生成 TypeScript 声明"]
  B --> B1["还原接口描述:<br/>函数签名、类型映射、<br/>导入/导出关系"]
  C --> C1["为每个导出函数生成 JS 包装<br/>为每个导入函数生成 JS 实现<br/>生成运行时辅助函数"]
  D --> D1["移除自定义段<br/>替换导入函数签名<br/>添加内部辅助函数导入"]
  E --> E1[".d.ts 类型声明文件<br/>让 TypeScript 用户有类型安全"]
  style B fill:#f59e0b,color:#333
  style C fill:#6366f1,color:#fff
  style D fill:#22d3ee,color:#333
  style E fill:#10b981,color:#333

步骤一:读取自定义段

Rust 编译时,#[wasm_bindgen] 宏在 .wasm 的自定义段中写入接口元数据(名字、模块、内联 JS 等);类型签名则不在段里,而是由 CLI 用解释器执行 __wbindgen_describe_* 函数得到(§6.3)。两者合起来还原出完整的接口描述。

步骤二:生成 JS 胶水

根据接口描述,CLI 为每个导出函数生成 JS 包装函数,为每个导入函数生成 JS 实现。这是生成量最大的部分——导出函数越多、跨边界的复合类型越多,胶水代码涨得越快,规模上通常远超手写的等价代码。这包括参数转换、返回值提取、内存管理的全部逻辑。

步骤三:修改 .wasm

CLI 做以下修改:

  1. 移除自定义段和描述函数:描述信息已经提取完毕,留在 .wasm 中只会增加体积(把本章 §6.3–§6.9 的示例合进一个 crate,1.96.0 release 构建实测:__wasm_bindgen_unstable 段 8620 字节;经 CLI 处理、连同一批 __wbindgen_describe_* 函数一起删掉后,.wasm 从 74416 字节降到 53272 字节)
  2. 改写函数签名:JsValue 在 rustc 产物里就是一个 i32 句柄索引;走默认的 externref 路径时,CLI 的 externref 变换把导入/导出签名里的这些 i32 改写成 externref,并在 WASM 内部插入表的分配与释放
  3. 为内置导入生成实现:.wasm 里本来就有 wasm-bindgen crate 声明的一批内部导入(crates/cli-support/src/intrinsic.rs 里 intrinsics! 登记的那张表,__wbindgen_object_drop_ref 在第 74 行,其后还有 __wbindgen_string_get、__wbindgen_throw……),CLI 识别后为它们生成 JS 实现,并把导入模块名从 __wbindgen_placeholder__ 改写掉

步骤四:生成 TypeScript 声明

默认就会生成 .d.ts 文件——wasm-pack 侧对应的是一个反向开关 --no-typescript(wasm-pack 仓库 src/command/build.rs:136 的 disable_dts 字段,其文档注释说明默认生成、该 flag 用于关闭)。这让 TypeScript 用户在导入 WASM 模块时获得完整的类型提示——包括参数类型、返回类型、类的成员和方法。

CLI 版本匹配的重要性

wasm-bindgen crate 和 wasm-bindgen-cli 的版本必须完全匹配——哪怕是补丁版本的差异(如 0.2.117 vs 0.2.118)也会报错。校验点很具体:crates/shared/src/lib.rs:16 有一个

// This gets changed whenever our schema changes.
// At this time versions of wasm-bindgen and wasm-bindgen-cli are required to have the exact same
// SCHEMA_VERSION in order to work together.
pub const SCHEMA_VERSION: &str = "0.2.118";

宏把它写进自定义段开头那段 JSON,CLI(crates/cli-support/src/wit/mod.rs 的 verify_schema_matches)读出其中的 schema_version 和自己的常量比对,不一致就直接失败。严格说比的是 schema 版本而不是 crate 版本:schema 只在格式变化时才改,并非每版都变(0.2.116、0.2.117 里的 SCHEMA_VERSION 都还是 "0.2.115",0.2.110 里是 "0.2.107"),但哪两个版本恰好兼容不值得去赌——自定义段的编码和描述符标签都不是稳定公共接口,任何一版都可能调整,工程上一律按精确匹配处理。wasm-pack 会自动拉取匹配版本的 CLI,手动调 CLI 时得自己保证。

6.11 实测:一次跨边界调用的开销

用 micro-benchmark 测量不同参数类型的跨边界调用开销,帮助理解性能瓶颈在哪里:

#[wasm_bindgen]
pub fn add_i32(a: i32, b: i32) -> i32 { a + b }

#[wasm_bindgen]
pub fn concat_str(a: &str, b: &str) -> String {
    format!("{}{}", a, b)
}

#[wasm_bindgen]
pub fn process_bytes(data: &[u8]) -> Vec<u8> {
    data.iter().map(|&b| b.wrapping_add(1)).collect()
}

一组参考数据:wasm-bindgen 0.2.118 --target nodejs,Rust 1.96.0 release 构建,node v24.20.0,Intel Xeon Gold 6240;每项先预热,再循环调用取平均(前三项各 100 万次,1KB 字符串 10 万次,100KB 字节 1000 次),连跑三轮结果相差不到 5%。绝对值随引擎、机器、字符串内容变化很大,重点看相对量级:

函数 参数 实测(每次调用) 相对量级 开销来源
纯 JS (a, b) => a + b 对照 ≈ 4 ns — —
add_i32 2 个 i32 ≈ 10 ns 基线(与一次普通 JS 函数调用同量级) 纯调用开销
concat_str 2 个 16 字节字符串 ≈ 510 ns 比基线高一到两个数量级(约 50 倍) malloc + 复制 + 解码 + free
concat_str 2 个 1KB 字符串 ≈ 8.4 µs 再高一个数量级 UTF-8 编解码 + 复制
process_bytes 32 字节 &[u8] ≈ 250 ns 与短字符串同量级 malloc + 复制
process_bytes 100KB &[u8] ≈ 79 µs 随字节数线性增长 大量字节复制

要拿到你自己项目的真实数字,用 §6.16.6 的方式在目标浏览器上跑一遍——不要照抄任何一篇文章里的绝对值。

这个量级关系说明:WASM 跨边界调用的开销不在于调用本身,而在于数据传递。i32 的跨边界调用几乎零开销——和 JS 内部的函数调用在同一数量级。字符串和字节数组的开销主要来自内存复制。

对 API 设计的启示:如果接口能减少跨边界的数据量(比如传递指针而非字符串、批量操作而非逐个调用),性能会显著提升。这和系统编程中的 "chunk vs individual" 原则完全一致。

批量化 API 设计模式

基于以上数据,推荐一种"批量化"的 API 设计模式——把多次跨边界调用合并为一次:

// ❌ 逐个调用:N 次跨边界
#[wasm_bindgen]
pub fn process_one(item: &str) -> String { ... }
// JS: for (const item of items) result.push(process_one(item));

// ✅ 批量调用:1 次跨边界
// 注意参数是 Vec<JsValue> 而不是 &[JsValue]:0.2.118 里 [JsValue] 没有实现
// RefFromWasmAbi,写成切片会直接编译失败(实测)
#[wasm_bindgen]
pub fn process_batch(items: Vec<JsValue>) -> Vec<JsValue> {
    items.iter().map(|v| {
        let s = v.as_string().unwrap();
        JsValue::from_str(&process_one(&s))
    }).collect()
}
// JS: const results = process_batch(items);

批量化 API 把 N 次内存分配/复制/释放减少为 1 次——虽然总数据量相同,但每次调用的固定开销从 N 份降为 1 份。N 越大、单项数据越小,收益越明显;反过来如果每项本身就是几百 KB,固定开销可以忽略,批量化就没什么意义了。

6.12 自定义 JS 模块导入

§6.7 介绍了通过 extern "C" 块导入 JS 函数——但只局限于全局函数。生产中常需要导入 npm 包或自定义 JS 文件——#[wasm_bindgen(module = "...")] 提供了完整的模块系统集成。

6.12.1 三种导入路径

flowchart LR
  A["Rust 代码<br/>extern block"] --> B{"导入来源?"}
  B --> C["全局对象<br/>不带 module 属性"]
  B --> D["npm 包<br/>module = 'lodash'"]
  B --> E["本地 JS 文件<br/>module = '/snippets/utils.js'"]
  B --> F["内联 JS<br/>inline_js"]
  style C fill:#10b981,color:#333
  style D fill:#6366f1,color:#fff
  style E fill:#f59e0b,color:#333
  style F fill:#dc2626,color:#fff

6.12.2 从 npm 包导入

#[wasm_bindgen(module = "lodash")]
extern "C" {
    #[wasm_bindgen(js_name = "groupBy")]
    fn group_by(collection: &JsValue, iteratee: &JsValue) -> JsValue;

    #[wasm_bindgen(js_name = "debounce")]
    fn debounce(func: &js_sys::Function, wait: u32) -> js_sys::Function;
}

#[wasm_bindgen]
pub fn use_lodash(items: JsValue) -> JsValue {
    let key_fn = js_sys::Function::new_with_args("x", "return x.category");
    group_by(&items, &key_fn.into())
}

构建后,模块路径 lodash 会原样出现在产物里:需要参数转换的 debounce 由 JS 胶水 import { debounce } from 'lodash' 再包一层;groupBy 的参数和返回值都是 JsValue,不需要任何转换,CLI 干脆让 .wasm 直接从 lodash 模块导入 groupBy(实测 --target bundler 产物的导入段里就是 lodash :: groupBy)。打包工具(Webpack/Vite)解析这些导入时会从 node_modules/lodash 读取实际代码。这就是为什么 --target bundler 是 npm 集成的最佳选择:bundler 会处理 npm 解析。

6.12.3 本地 JS 文件作为辅助层

某些场景下,纯 JS 实现比 Rust 实现更简单——或者需要绕过 wasm-bindgen 的类型转换限制。这时可以写本地 JS 文件作为"helper layer":

// snippets/dom_helpers.js
export function fast_query_all(selector) {
    return Array.from(document.querySelectorAll(selector));
}

export function attach_styles(css) {
    const style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);
}
#[wasm_bindgen(module = "/snippets/dom_helpers.js")]
extern "C" {
    fn fast_query_all(selector: &str) -> Vec<web_sys::Element>;
    fn attach_styles(css: &str);
}

module = "/snippets/..." 这种以 / 开头的路径,根是 crate 根目录(Cargo.toml 所在目录),不是 workspace 根、也不是 src/——guide/src/reference/js-snippets.md 的原话是「where the root is relative to the crate root (where Cargo.toml is located)」。wasm-bindgen CLI 会把这些文件复制到输出目录,并在生成的胶水中引用它们。

6.12.4 inline_js:单文件项目的便利

简单场景下,把 JS 直接内联在 Rust 代码中:

#[wasm_bindgen(inline_js = "
    export function format_currency(amount, currency) {
        return new Intl.NumberFormat('en-US', {
            style: 'currency',
            currency: currency,
        }).format(amount);
    }
")]
extern "C" {
    fn format_currency(amount: f64, currency: &str) -> String;
}

inline_js 的代价:JS 代码无法被工具链 lint/format/syntax-check——错误只在运行时才暴露。生产代码建议放到独立 .js 文件,inline 仅用于一次性脚本或文档示例。

6.12.5 何时绕过 wasm-bindgen

某些场景 wasm-bindgen 的类型转换过于昂贵,直接写胶水更高效:

场景 wasm-bindgen 做法 直接胶水做法 省掉了什么
传 1MB 二进制 Vec<u8> 复制进线性内存 直接在 memory.buffer 上开视图 整整一次 1MB 复制 + 一次 malloc/free
高频回调(每秒万次) Closure::wrap + 表项管理 直接挂 export 每次调用的表查找与闭包包装
简单数值返回 走 JsValue 包装 直接 export i32/f64 一次句柄槽的分配与回收(对象栈路径下 drop 还要跨一次边界)

绕过的方式:用 #[unsafe(no_mangle)] pub extern "C" fn(edition 2024 的写法,裸 #[no_mangle] 在 edition 2024 下编不过)替代 #[wasm_bindgen],自己写 JS 端的 import 表。失去类型自动转换,换来零开销。

6.13 wasm-bindgen 的限制与降级路径

工程实践中迟早会遇到"wasm-bindgen 表达不了的需求"——理解这些限制有助于在不撞墙的情况下设计 API。

6.13.1 表达力的边界

限制一:泛型函数无法导出。Rust 的泛型在编译时单态化——而 #[wasm_bindgen] 需要一个确定的导出符号和确定的 ABI 签名。所以泛型 pub fn process<T>() 不能直接 #[wasm_bindgen](宏直接报错 can't #[wasm_bindgen] functions with lifetime or type parameters),必须先用具体类型实例化:

// 不能直接导出泛型
// #[wasm_bindgen]
// pub fn process<T: Serialize>(item: T) -> String { ... }  // 编译错误

// 必须为每种具体类型导出
#[wasm_bindgen]
pub fn process_user(user: User) -> String { /* ... */ }

#[wasm_bindgen]
pub fn process_order(order: Order) -> String { /* ... */ }

限制二:trait object 跨边界。Box<dyn Trait> 不能直接传给 JS——JS 没有 trait object 的概念。变通方案:用一个整数 id 作为 trait object 的"句柄"交给 JS,Rust 侧维护 HashMap<u32, Box<dyn Trait>>:

thread_local! {
    static IMPLS: RefCell<HashMap<u32, Box<dyn MyTrait>>> = RefCell::new(HashMap::new());
}

#[wasm_bindgen]
pub fn create_impl(kind: &str) -> u32 {
    let id = next_id();
    let imp: Box<dyn MyTrait> = match kind {
        "a" => Box::new(ImplA),
        "b" => Box::new(ImplB),
        _ => panic!(),
    };
    IMPLS.with(|m| m.borrow_mut().insert(id, imp));
    id
}

限制三:lifetime 跨边界。fn foo<'a>(s: &'a str) -> &'a str 编译器无法保证 JS 侧不会让返回值的引用比输入活得更久。所有跨边界返回必须是拥有所有权的类型(String、Vec、Box),不能是引用。

限制四:多值返回。WASM 1.0 不支持函数返回多个值——这种情况下 wasm-bindgen 用「调用方先把栈指针往下挪出一块 retptr,被调方把多个值写进线性内存,调用方读完再把栈指针挪回去」来模拟(生成的 JS 里那对 __wbindgen_add_to_stack_pointer(-16) / (16) 就是干这个的)。多值提案已并入 WASM 2.0,multivalue 也进了 rustc 的默认 feature 集,此时 CLI 会自动走真正的多值返回,省掉这次内存往返(见 §6.6 讲的 target-feature 自动探测)。

6.13.2 限制的等级表

graph TD
  A["遇到限制"] --> B{"严重程度?"}
  B --> C["可绕过 - 用句柄"]
  B --> D["可绕过 - 用具体类型"]
  B --> E["必须降级 - 直接 extern C"]
  B --> F["架构改写"]
  C --> C1["Trait object<br/>泛型存储<br/>复杂回调"]
  D --> D1["泛型函数<br/>带泛型的 struct"]
  E --> E1["共享内存视图<br/>原始指针操作<br/>极致性能"]
  F --> F1["WASM ↔ JS 频繁双向调用<br/>考虑全部用 JS 或全部用 WASM"]
  style C fill:#10b981,color:#333
  style D fill:#6366f1,color:#fff
  style E fill:#f59e0b,color:#333
  style F fill:#dc2626,color:#fff

6.13.3 降级到 raw extern "C" 的实战

当 wasm-bindgen 的类型转换成为瓶颈,直接用 C ABI:

// 不用 wasm-bindgen,直接 C ABI
#[unsafe(no_mangle)]
pub extern "C" fn fast_process(ptr: *const u8, len: usize) -> u32 {
    let data = unsafe { std::slice::from_raw_parts(ptr, len) };
    data.iter().map(|&b| b as u32).sum()
}

#[unsafe(no_mangle)]
pub extern "C" fn alloc(size: usize) -> *mut u8 {
    let mut buf = Vec::with_capacity(size);
    let ptr = buf.as_mut_ptr();
    std::mem::forget(buf);
    ptr
}

#[unsafe(no_mangle)]
pub extern "C" fn dealloc(ptr: *mut u8, size: usize) {
    unsafe { Vec::from_raw_parts(ptr, 0, size); }  // drop here
}

JS 侧手写胶水:

const wasmModule = await WebAssembly.instantiateStreaming(fetch('./my.wasm'));
const { memory, fast_process, alloc, dealloc } = wasmModule.instance.exports;

function processData(data) {
    const ptr = alloc(data.length);
    new Uint8Array(memory.buffer, ptr, data.length).set(data);
    const result = fast_process(ptr, data.length);
    dealloc(ptr, data.length);
    return result;
}

代价:手写胶水 + 手动内存管理(alloc/dealloc 必须配对)。收益要看清楚:上面这个例子里 JS 仍然做了一轮 alloc + set 复制 + dealloc,和 wasm-bindgen 传 &[u8] 的做法等价,省下的只是胶水层那点固定开销。真正省掉全量复制的,是让数据直接在 alloc 出来的那块线性内存里产生和消费(比如把解码结果直接写进这块内存的视图、多次调用反复复用同一块缓冲区),这时数据越大越划算。

6.13.4 混合策略:90% wasm-bindgen + 10% raw

不需要 all-in raw——关键热路径用 raw、其余用 wasm-bindgen 是可行的。同一个 crate 既可以有 #[wasm_bindgen] 函数也可以有 #[unsafe(no_mangle)] extern "C" 函数(实测 CLI 会原样保留这类导出):

// 普通业务函数:方便用 wasm-bindgen
#[wasm_bindgen]
pub fn parse_config(json: &str) -> Result<Config, JsValue> { ... }

// 热路径:raw extern "C"
#[unsafe(no_mangle)]
pub extern "C" fn render_pixel_buffer(ptr: *mut u8, w: u32, h: u32) { ... }

JS 侧两种调用方式共存:

import init, { parse_config } from './pkg/my_lib.js';

// init() 本身就返回 wasm 的 exports 对象(生成模板里 __wbg_init/initSync 都以 return wasm 收尾,
// 见 crates/cli-support/src/js/mod.rs:1110)
const wasm = await init();

const cfg = parse_config(jsonString);        // wasm-bindgen 路径
wasm.render_pixel_buffer(ptr, w, h);         // raw 路径:直接调导出

大型 WASM 应用普遍采取这种分层:业务代码用 wasm-bindgen 保证开发效率,渲染热路径退到 raw 导出保证性能。(具体某个产品各用了多少 raw 导出属于闭源实现细节,本节不做断言。)

6.14 TypeScript 类型生成与定制

wasm-bindgen 的隐藏价值之一是自动生成 TypeScript 类型声明(.d.ts)——让 TS 项目消费 WASM 模块时获得完整的类型推断和编辑器智能提示。这部分在文档中常被忽视,但对生产级 TS 项目至关重要。

6.14.1 自动生成的 .d.ts 是怎么样的

#[wasm_bindgen]
pub struct User {
    name: String,
    age: u32,
}

#[wasm_bindgen]
impl User {
    #[wasm_bindgen(constructor)]
    pub fn new(name: String, age: u32) -> User { User { name, age } }

    #[wasm_bindgen(getter)]
    pub fn name(&self) -> String { self.name.clone() }

    pub fn greet(&self) -> String {
        format!("Hi, I'm {}", self.name)
    }
}

#[wasm_bindgen]
pub fn create_users(count: u32) -> Vec<User> {
    (0..count).map(|i| User::new(format!("user-{i}"), i)).collect()
}

wasm-pack build 后生成的 my_lib.d.ts(节选):

export class User {
    free(): void;
    constructor(name: string, age: number);
    readonly name: string;
    greet(): string;
}

export function create_users(count: number): User[];

注意三个细节:

  • free(): void 自动加在每个导出 struct 上——TS 用户需要显式释放 Rust 对象
  • name 用 getter 属性表达(readonly)
  • Vec<User> 映射为 User[]——crates/cli-support/src/descriptor.rs:258 的 VectorKind::NamedExternref 分支只在 is_valid_ident(name) 为假时才加括号写成 (...)[](Vec<JsValue> 走同一个 js_ty() 的 Externref 分支,映射成 any[])

6.14.2 类型映射对照

graph LR
  A["Rust 类型"] --> B["TypeScript 类型"]
  C["i32/u32/f64"] --> D["number"]
  E["i64/u64"] --> F["bigint"]
  G["String/&str"] --> H["string"]
  I["bool"] --> J["boolean"]
  K["Option&lt;T&gt;"] --> L["返回 T | undefined<br/>参数 T | null | undefined"]
  M["Result&lt;T, E&gt;"] --> N["T (失败时抛 E)"]
  O["Vec&lt;u8&gt;"] --> P["Uint8Array"]
  Q["Vec&lt;T&gt;"] --> R["T[]"]
  S["#[wasm_bindgen] struct"] --> T["class"]
  style D fill:#10b981,color:#333
  style F fill:#f59e0b,color:#333
  style H fill:#10b981,color:#333
  style P fill:#6366f1,color:#fff
  style T fill:#10b981,color:#333

陷阱:u64 映射为 bigint——和 number 不能直接互操作。如果业务需要传 u64(例如时间戳),TS 侧必须用 BigInt(value) 转换。许多项目为了 TS 友好选择用 f64 表示时间戳(毫秒精度足够,避免 bigint 麻烦)。

6.14.3 自定义 TypeScript 类型

wasm-bindgen 默认生成的类型有时不够精确——比如返回 JsValue 时 TS 只能看到 any。用 #[wasm_bindgen(typescript_type = "...")] 自定义:

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(typescript_type = "{ x: number; y: number }")]
    pub type Point;
}

#[wasm_bindgen]
pub fn get_origin() -> Point {
    let obj = js_sys::Object::new();
    js_sys::Reflect::set(&obj, &"x".into(), &0.into()).unwrap();
    js_sys::Reflect::set(&obj, &"y".into(), &0.into()).unwrap();
    obj.unchecked_into::<Point>()
}

生成的 .d.ts:

export function get_origin(): { x: number; y: number };

typescript_type 的字符串被原样内联到用到它的位置(0.2.118 实测),并不会生成一个名为 Point 的 export type。TS 用户拿到的是精确的对象类型——而不是 any。想在 TS 侧也有一个具名类型,可以配合 #[wasm_bindgen(typescript_custom_section)] 自己写一段 export type Point = ...,再把 typescript_type 写成 "Point"。这对暴露复杂对象的 API 是必备技能。

6.14.4 TypeScript 严格模式陷阱

tsconfig.json 启用 strict 后,wasm-bindgen 生成的代码可能失败:

{
  "compilerOptions": {
    "strict": true,
    "skipLibCheck": true  // ← 撞上问题时的常用逃生阀
  }
}

skipLibCheck 跳过 node_modules 中 .d.ts 的严格检查。这不是 wasm-bindgen 的硬性要求——多数项目在 strict 下直接就能编过;但生成的声明会随 wasm-bindgen 版本变化,真撞上「库声明本身报错」时,skipLibCheck 是标准逃生阀(它只跳过库声明,不影响你自己业务代码的类型检查)。具体是哪个版本、哪种声明形态会触发,取决于你用的版本,本节不给死结论。

6.14.5 .d.ts 与 npm 发布

发布到 npm 时,package.json 必须正确指向 .d.ts:

{
  "name": "my-wasm-lib",
  "main": "my_lib.js",
  "types": "my_lib.d.ts",
  "files": ["my_lib.js", "my_lib.d.ts", "my_lib_bg.wasm"]
}

wasm-pack build 生成 package.json 时会自动写好这些字段(wasm-pack 仓库 src/manifest/mod.rs 里各目标的 types: data.dts_file),wasm-pack publish 发布的就是这份清单。手动调用 wasm-bindgen CLI 时必须自己加。

6.14.6 TypeScript 优先的开发流程

成熟的 wasm-bindgen 项目把 TS 集成到日常开发流:

flowchart LR
  A["写 Rust 代码<br/>+ #[wasm_bindgen]"] --> B["wasm-pack build"]
  B --> C["生成 .wasm + .js + .d.ts"]
  C --> D["TS 项目 import"]
  D --> E["编辑器智能提示"]
  E --> F{"类型不对劲?"}
  F -->|是| A
  F -->|否| G["业务代码"]
  style C fill:#10b981,color:#333
  style E fill:#10b981,color:#333

TS 类型作为契约——Rust 改了 API 但忘了对应改 TS 代码时,编译期就报错。这比"运行时发现接口不对"安全得多。中大型 wasm-bindgen 项目应该把 wasm-pack build 作为 TS 项目构建的依赖任务(Turbo/Nx 编排),保证类型同步。

6.14.7 完整集成示例

// app.ts —— 假定包是用 --target web 构建的:默认的 bundler 产物没有默认导出的 init,
// 模块被 import 时就已完成初始化,直接去掉 init 那两行即可
import init, { User, create_users } from 'my-wasm-lib';

async function main() {
    await init();

    const user = new User('Alice', 30);
    console.log(user.greet());  // "Hi, I'm Alice"
    user.free();  // 主动释放 Rust 内存

    const users = create_users(3);
    for (const u of users) {
        console.log(u.name);
        u.free();
    }
}

注意 u.free() 是手动的——TS 编译器不会提醒你忘记调用。兜底不用自己写:0.2.118 生成的胶水已经为每个导出类建了一个 FinalizationRegistry,构造时登记、free() 时注销(下面是 §6.8 Counter 的实际产物节选):

const CounterFinalization = (typeof FinalizationRegistry === 'undefined')
    ? { register: () => {}, unregister: () => {} }
    : new FinalizationRegistry(ptr => wasm.__wbg_counter_free(ptr >>> 0, 1));
// constructor 里:CounterFinalization.register(this, this.__wbg_ptr, this);
// free() 里:    CounterFinalization.unregister(this);

它登记的「持有值」是指针 this.__wbg_ptr 而不是对象本身——若照 registry.register(u, u) 那样把对象自己当持有值,V8 会直接抛 TypeError(target and holdings must not be same)。但 FinalizationRegistry 不保证及时调用——大对象长时间不释放仍可能导致 WASM 内存压力。所以生产代码仍应主动 free()(或用 using 声明,生成的类挂了 Symbol.dispose),GC 回收只当兜底。

6.15 版本演进与生态健康度

wasm-bindgen 是 wasm-bindgen 工具链的中心——一个项目的所有跨边界代码都依赖它。版本管理失误会让整个项目突然崩溃。理解 wasm-bindgen 的版本节奏和兼容性边界是工程纪律的一部分。

6.15.1 版本号历史与重要里程碑

下面这几条都能在仓库 CHANGELOG.md 里逐条对上(附 PR 编号):

版本 变化 出处
0.2.94(该版本后被 yank,行为延续到 0.2.95+) 模块已经用了对应 target feature 时,自动打开 reference-types / multivalue 变换 PR #4133
0.2.96 废弃 --reference-types 标志,改为自动探测 target feature PR #4237
0.2.100(2025-01-12) 新增 unchecked_return_type / unchecked_param_type 等 TS 类型覆写属性;可选参数的 TS 类型改为 T | undefined | null PR #4394、#4188
0.2.109 句柄表的借用栈从 128 槽提升到 1024 槽,修深调用栈/大量并发异步下的 table index out of bounds;panic=unwind 下 Closure::new()/Closure::once() 等要求 UnwindSafe,并新增 *_aborting 变体;global 不再用 new Function 的 unsafe-eval 技巧,可满足严格 CSP PR #4957(CHANGELOG 误写为 #4951)、#4893、#4910
0.2.118(2026-04-10) 支持多个 #[wasm_bindgen(start)] 函数并新增 (start, private);CLI 的 MSRV 从 1.71 提到 1.76、API 从 1.82 提到 1.86;修了 rustc 1.94+ 产物上的两个 CLI panic(大函数表的 element 段偏移、描述符解释器的栈指针误判) PR #5081、#5102、#5095

看时间线不如看这张表:wasm-bindgen 的 0.2.x 各版本里经常混着 ABI 层面的调整,所以下面这条纪律很重要。

注意一个反直觉的事实:wasm-bindgen 一直停在 0.2.x,版本号只动第三位。按 Cargo 的规则,0.2.117 与 0.2.118 属于兼容版本,cargo update 会自动把依赖升上去;可 crate 与 CLI 之间的内部协议(§6.10 的 schema 版本)并不跟着这套兼容承诺走,这正是版本不匹配问题频发的根源。

6.15.2 版本兼容性矩阵

wasm-bindgen 涉及三个独立组件,必须严格版本一致:

graph TD
  A["wasm-bindgen<br/>Cargo crate"] --> B["wasm-bindgen-cli<br/>命令行工具"]
  B --> C["生成的 .js<br/>胶水代码"]
  C --> D["运行时<br/>JS 调用"]
  style A fill:#10b981,color:#333
  style B fill:#10b981,color:#333
  style C fill:#6366f1,color:#fff
组件 角色 版本约束
wasm-bindgen crate 生成 __wbindgen_* 导出 项目用什么版本
wasm-bindgen-cli 解析自定义段、生成 .js 必须与 crate 完全一致
生成的 .js 胶水 运行时调用 ABI 由 CLI 决定,与 CLI 版本绑定

完全一致意味着精确到 patch 版本——0.2.117 的 crate 不能和 0.2.118 的 CLI 配合。实测用 0.2.118 的 CLI 处理 0.2.117 crate 的产物,报错如下(节选):

error:

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)

6.15.3 版本锁定的工程实践

CI 中必须固化版本,防止意外升级:

# Cargo.toml - 锁定精确版本
[dependencies]
wasm-bindgen = "=0.2.118"  # 等号锁死精确版本
# GitHub Actions - 锁定 CLI 版本
- name: Install wasm-bindgen-cli
  run: cargo install wasm-bindgen-cli --version 0.2.118 --locked

--locked 让 cargo install 使用 wasm-bindgen-cli 发布时附带的 Cargo.lock,不去解析更新的依赖版本。对长期维护项目,这条等于救命——避免半夜的 CI 失败。

wasm-pack 内部自动管理这套版本同步——它从项目的 Cargo.lock 里读出 wasm-bindgen 的版本(wasm-pack 仓库 src/lockfile.rs 的 wasm_bindgen_version()),再下载同版本的预编译 CLI——这是用 wasm-pack 而不是直接调 wasm-bindgen-cli 的核心理由之一。如果项目必须直接调 CLI(自定义构建步骤),版本一致性靠工程纪律。

6.15.4 升级流程

升级 wasm-bindgen 不是简单的 cargo update——必须 staging 验证:

flowchart TD
  A["决定升级 wasm-bindgen"] --> B["读 CHANGELOG"]
  B --> C{"有 breaking change?"}
  C -->|是| D["写迁移计划"]
  C -->|否| E["bump 版本"]
  D --> E
  E --> F["运行完整测试套件"]
  F --> G{"测试通过?"}
  G -->|否| H["定位问题<br/>回滚或修复"]
  G -->|是| I["在 staging 跑 RUM"]
  I --> J{"性能/错误率回归?"}
  J -->|否| K["发布"]
  J -->|是| H
  style K fill:#10b981,color:#333
  style H fill:#dc2626,color:#fff

wasm-bindgen 的 CHANGELOG 按 Added / Changed / Fixed / Removed 分节,没有专门的 breaking 标签;Changed 与 Removed 两节、以及正文里写着 breaking 的条目要重点关注——这些变更可能影响生产行为。

6.15.5 0.2.x 的常见破坏性变更类型

0.2.x 各版本里出现过的、需要改代码或改预期的变更(都能在 CHANGELOG 里查到):

类型 例子 影响面
生成的 TS 类型变了 0.2.100 起可选参数从 T | undefined 变成 T | undefined | null(#4188) TS 侧类型检查可能报新错
原先能编过的写法变成编译错误 0.2.100 起给 enum 加 getter/setter/constructor 直接报错(此前会生成非法 JS,#4278) 需要改 Rust 代码
trait bound 收紧 0.2.109 起 panic=unwind 下 Closure::new() 要求 UnwindSafe(#4893) 需要改闭包或换 *_aborting 变体
默认行为随 target feature 漂移 0.2.94/0.2.96 起 externref、multivalue 由 .wasm 的 target-features 段自动决定 换 rustc 版本可能悄悄改变产物形态

最后一条尤其阴险:你没动 wasm-bindgen 版本,只是升了 rustc,产物形态就可能变。所以升级 wasm-bindgen 也好、升级 rustc 也好,都要按 major 版本的谨慎程度对待。

6.15.6 长期维护的版本策略

graph LR
  A["每月例行评估"] --> B{"当前版本超过 6 个月?"}
  B -->|是| C["计划升级"]
  B -->|否| D["维持现状"]
  C --> E{"业务窗口?"}
  E -->|空闲期| F["升级 + 完整 staging"]
  E -->|忙碌期| G["延后 4 周"]
  style F fill:#10b981,color:#333
  style D fill:#6366f1,color:#fff

工程纪律:

  • 不在生产高峰升级:即使紧急 bug 修复,也用 patch 替代版本跳跃
  • 保留 N-1 版本支持:CDN 上同时托管当前版本和上一个版本,方便快速回滚
  • 每季度做一次预演升级:在 staging 跑一遍下一个 minor,提前发现问题
  • 关注 release notes:订阅 wasm-bindgen 的 GitHub releases,升级前通读 CHANGELOG 对应各节

这套策略在中大型 wasm-bindgen 项目中是必备——避免"几年没升级然后大爆炸"的迁移噩梦。

6.16 wasm-bindgen-test:跨边界代码的测试

WASM 项目的测试比纯 Rust 项目复杂——业务代码涉及 JS 边界,传统 cargo test 跑不起来。wasm-bindgen-test 是 wasm-bindgen 生态的官方测试框架,理解它的工作原理和最佳实践是质量保证的关键。

6.16.1 三种测试模式

graph TD
  A["WASM 项目测试需求"] --> B["纯 Rust 单元测试"]
  A --> C["跨边界单元测试"]
  A --> D["端到端浏览器测试"]
  B --> B1["cargo test<br/>不需要 wasm-bindgen"]
  C --> C1["wasm-bindgen-test<br/>在 Node.js 或浏览器跑"]
  D --> D1["Playwright / Cypress<br/>真实页面 + 用户交互"]
  style B fill:#10b981,color:#333
  style C fill:#6366f1,color:#fff
  style D fill:#f59e0b,color:#333

每种模式的适用场景:

模式 测试什么 速度 何时用
纯 Rust 单元测试 内部逻辑(没 #[wasm_bindgen]) 最快 默认首选
wasm-bindgen-test 跨 JS 边界的函数 中 验证 ABI 正确
端到端测试 完整用户流程 最慢 集成关键场景

6.16.2 wasm-bindgen-test 基础用法

use wasm_bindgen_test::*;

wasm_bindgen_test_configure!(run_in_browser);

#[wasm_bindgen_test]
fn test_add() {
    assert_eq!(add(1, 2), 3);
}

#[wasm_bindgen_test]
async fn test_fetch() {
    let result = fetch_url("https://example.com").await;
    assert!(result.is_ok());
}

运行:

# 在 Node.js 中跑
wasm-pack test --node

# 在 Chrome headless 中跑
wasm-pack test --chrome --headless

# 在 Firefox 中跑
wasm-pack test --firefox --headless

wasm-pack test 内部启动浏览器、加载 .wasm、运行所有 #[wasm_bindgen_test] 函数、收集结果。

6.16.3 测试策略:金字塔模型

graph TD
  A["测试金字塔"] --> B["单元测试<br/>大量、快速"]
  A --> C["集成测试<br/>中量、中速"]
  A --> D["端到端测试<br/>少量、慢"]
  B --> B1["cargo test 内部逻辑<br/>占大头"]
  C --> C1["wasm-bindgen-test<br/>适量"]
  D --> D1["Playwright<br/>少量"]
  style B fill:#10b981,color:#333
  style C fill:#6366f1,color:#fff
  style D fill:#f59e0b,color:#333

工程纪律:优先把逻辑放进无 #[wasm_bindgen] 的内部函数——这部分能用 cargo test 极速测试,不需要启动浏览器。#[wasm_bindgen] 函数只做"包装 + 类型转换",少量集成测试覆盖即可。

// 内部逻辑,纯 Rust,cargo test 可测
fn process_data(data: &[u8]) -> Vec<u8> {
    // 复杂业务逻辑
}

// 边界包装,仅做类型转换
#[wasm_bindgen]
pub fn process(data: &[u8]) -> Vec<u8> {
    process_data(data)
}

// cargo test
#[test]
fn test_process_data() {
    assert_eq!(process_data(b"abc"), vec![...]);
}

// wasm-bindgen-test(少量)
#[wasm_bindgen_test]
fn test_process_via_wasm() {
    assert_eq!(process(b"abc"), vec![...]);
}

6.16.4 测试浏览器 API

某些代码必须有真实浏览器(例如调用 web_sys::window() 上的 local_storage())——只能用 wasm-bindgen-test:

use wasm_bindgen_test::*;
use web_sys::Storage;

wasm_bindgen_test_configure!(run_in_browser);

#[wasm_bindgen_test]
fn test_local_storage() {
    let window = web_sys::window().unwrap();
    let storage = window.local_storage().unwrap().unwrap();

    storage.set_item("key", "value").unwrap();
    assert_eq!(storage.get_item("key").unwrap(), Some("value".to_string()));

    storage.remove_item("key").unwrap();
}

注意 wasm_bindgen_test_configure!(run_in_browser)——告诉测试运行器必须启动真实浏览器(不能在 Node.js 跑)。

6.16.5 异步测试

#[wasm_bindgen_test]
async fn test_async_fetch() {
    use wasm_bindgen::JsCast;
    use wasm_bindgen_futures::JsFuture;

    let window = web_sys::window().unwrap();
    // fetch 返回的是 JS Promise,必须先过 JsFuture 才能 await
    let value = JsFuture::from(window.fetch_with_str("https://api.example.com/data"))
        .await
        .unwrap();
    let response: web_sys::Response = value.dyn_into().unwrap();
    assert_eq!(response.status(), 200);
}

异步测试的运行时由 wasm-bindgen-futures 提供——async fn 的 await 自然工作。

6.16.6 性能基准测试

这里要更正一个已经过时的说法:wasm-bindgen-test 现在自带 benchmark 支持。仓库把 criterion 改造成能在 wasm 上跑的版本,对外导出 #[wasm_bindgen_bench] 和 Criterion(crates/test/src/lib.rs:12 从 wasm-bindgen-test-macro 重导出,宏本体在 crates/test-macro/src/lib.rs:11),用法和写原生 benchmark 一样——benches/ 下写 fn criterion_benchmark(c: &mut Criterion),完整步骤见仓库 guide/src/wasm-bindgen-test/benchmark.md。

不想引 benchmark 那套时,也可以在普通测试里直接用 performance.now() 手工计时。下面这段是读者自己写的示例,不是仓库里的代码:

#[wasm_bindgen_test]
fn bench_processing() {
    let data = vec![0u8; 1024 * 1024];
    let perf = web_sys::window().unwrap().performance().unwrap();

    let start = perf.now();
    for _ in 0..100 {
        let _ = process_data(&data);
    }
    let elapsed = perf.now() - start;

    // 输出到控制台
    web_sys::console::log_1(&format!("100 iterations: {:.2} ms", elapsed).into());

    // 性能 SLO:100 次必须 < 500ms
    assert!(elapsed < 500.0);
}

把性能 SLO 写进测试——任何回归都会让 CI 失败。

6.16.7 测试 CI 集成

# .github/workflows/test.yml
- name: Cargo test (内部逻辑)
  run: cargo test

- name: wasm-bindgen-test - Node.js
  run: wasm-pack test --node

- name: wasm-bindgen-test - Chrome
  run: wasm-pack test --chrome --headless

- name: E2E with Playwright
  run: pnpm test:e2e

执行时间分配(量级示意,实际随项目规模差别很大):

阶段 时间 频率
cargo test 5-30 s 每 push
wasm-bindgen-test --node 30-60 s 每 push
wasm-bindgen-test --chrome 60-120 s 每 PR
Playwright E2E 5-10 min 每 PR + 每发布

时间限制让团队保持纪律——E2E 不能频繁跑,所以必须把绝大多数 bug 在单元测试和集成测试中捕获。

6.16.8 测试反模式

graph TD
  A["wasm-bindgen 测试反模式"] --> B["1. 全用 wasm-bindgen-test<br/>慢且无必要"]
  A --> C["2. 不测异步 / Promise"]
  A --> D["3. 不测错误路径<br/>只测 happy path"]
  A --> E["4. 跨边界测试不 free 资源<br/>泄漏导致后续测试影响"]
  A --> F["5. 共享 WASM 实例<br/>测试间状态污染"]
  style A fill:#dc2626,color:#fff

每条都是真实踩过的坑:

  • 全用 wasm-bindgen-test:绝大部分逻辑该用 cargo test,不必为了跑一个纯函数去启动浏览器
  • 异步未测:跨边界异步是高发 bug 区,必须覆盖
  • happy path only:错误路径在生产中是常见路径,必须测
  • 资源泄漏:每个 #[wasm_bindgen] struct 实例必须 free()
  • 状态污染:每个测试应该重置全局状态(global allocator、static cache)

把这套测试策略嵌入项目从一开始——而不是上线前补——是 wasm-bindgen 项目质量的根本保证。

6.17 wasm-bindgen 性能优化的工程模式

§6.11 介绍了跨边界调用开销的基础——但生产中要把 wasm-bindgen 的性能压到极限,需要系统化的优化模式。这套模式适用于"已经测出 wasm-bindgen 是瓶颈"的场景。

6.17.1 性能优化的金字塔

graph TD
  A["wasm-bindgen 性能优化"] --> B["架构层"]
  A --> C["接口层"]
  A --> D["代码层"]
  A --> E["底层"]
  B --> B1["减少跨边界调用次数"]
  C --> C1["批量化 + 大块数据"]
  D --> D1["类型选择 + 内存复用"]
  E --> E1["手写 ABI / 句柄表"]
  style B fill:#10b981,color:#333
  style C fill:#6366f1,color:#fff
  style D fill:#f59e0b,color:#333
  style E fill:#dc2626,color:#fff

每层优化的收益与代价:

层 收益来源 代码改动
架构层(最优先) 直接砍掉跨边界调用次数,量级最大 重新设计接口
接口层 把 N 次固定开销摊成 1 次 中等改动
代码层 少几次分配/复制 局部调优
底层(最后) 去掉抽象层本身的固定开销 大量手写

优先在架构层和接口层解决——下到底层是无奈选择,因为代价高、收益也最小。具体倍数没有通用值,必须在目标浏览器上实测。

6.17.2 模式一:批量化接口

// 反模式:逐个处理
#[wasm_bindgen]
pub fn add_item(item: &str) { /* ... */ }

// JS 调用:N 次跨边界
items.forEach(item => wasm.add_item(item));

// 推荐:批量接口
#[wasm_bindgen]
pub fn add_items(items: Vec<String>) { /* ... */ }

// JS 调用:1 次跨边界
wasm.add_items(items);

差别在于固定开销的份数:逐个调用要付 N 次「malloc + 复制 + free + 调用」,批量调用只付 1 次,搬运的总字节数则基本不变。N 越大、单项越小,差距越明显——具体倍数按 §6.11 的方法自己测。

6.17.3 模式二:句柄替代数据传递

频繁修改的对象不要每次都传完整数据——用句柄:

// 反模式:每次操作都传完整状态
#[wasm_bindgen]
pub fn render_with_state(state: JsValue, action: &str) -> JsValue {
    let mut s: State = serde_wasm_bindgen::from_value(state).unwrap();
    s.apply(action);
    serde_wasm_bindgen::to_value(&s).unwrap()
}

// 推荐:用 struct 句柄
#[wasm_bindgen]
pub struct Renderer {
    state: State,
}

#[wasm_bindgen]
impl Renderer {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Renderer { Renderer { state: State::default() } }

    pub fn apply(&mut self, action: &str) {
        self.state.apply(action);
    }

    pub fn render(&self) -> Vec<u8> {
        self.state.render()
    }
}

JS 侧:

const r = new Renderer();
r.apply("up");      // 0 数据传递
r.apply("right");   // 0 数据传递
const pixels = r.render();  // 仅最后传输结果

State 完全留在 WASM 内——避免每次序列化反序列化。

6.17.4 模式三:内存视图复用

频繁 JS-WASM 交换大块数据时,复用 buffer 视图:

// 反模式:每次创建新视图
function processFrame(wasm) {
    const ptr = wasm.exports.get_frame();
    const view = new Uint8Array(wasm.exports.memory.buffer, ptr, FRAME_SIZE);
    // 处理 view...
}

// 推荐:缓存视图
let cachedView = null;
let cachedBuffer = null;
let cachedPtr = 0;

function processFrame(wasm) {
    const ptr = wasm.exports.get_frame();
    if (!cachedView || cachedBuffer !== wasm.exports.memory.buffer || cachedPtr !== ptr) {
        cachedBuffer = wasm.exports.memory.buffer;
        cachedPtr = ptr;
        cachedView = new Uint8Array(cachedBuffer, ptr, FRAME_SIZE);
    }
    // 处理 cachedView...
}

注意陷阱:memory.grow 会让 cached buffer 失效——需要每次检查 buffer 是否变化;帧缓冲区若被重新分配,ptr 也会变,同样要重建视图。

6.17.5 模式四:避免 JsValue 包装已知类型

// 反模式:用 JsValue 包装已知类型
#[wasm_bindgen]
pub fn process(input: JsValue) -> JsValue {
    let s: String = input.as_string().unwrap();
    JsValue::from_str(&s.to_uppercase())
}

// 推荐:直接用 String
#[wasm_bindgen]
pub fn process(input: String) -> String {
    input.to_uppercase()
}

类型已知时直接声明——wasm-bindgen 选择最优 ABI。JsValue 仅在类型真正未知时使用。

6.17.6 模式五:闭包的复用

Closure::wrap 创建闭包有实打实的开销——一次表项分配 + 一个 JS 函数对象。频繁场景应复用:

// 反模式:每次创建新闭包
#[wasm_bindgen]
pub fn setup_handler() {
    let cb = Closure::wrap(Box::new(|_: Event| { /* ... */ }) as Box<dyn FnMut(_)>);
    add_event_listener(&cb);
    cb.forget();  // 内存泄漏
}

// 推荐:在结构体中复用
#[wasm_bindgen]
pub struct Handler {
    _click_cb: Closure<dyn FnMut(Event)>,
}

#[wasm_bindgen]
impl Handler {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Handler {
        let cb = Closure::wrap(Box::new(|_: Event| { /* ... */ }) as Box<dyn FnMut(_)>);
        add_event_listener(&cb);
        Handler { _click_cb: cb }
    }
    // Drop 时自动清理
}

6.17.7 模式六:异步操作的 yield 时机

跨边界 async 调用必须 await JsFuture——但 await 有调度开销。批处理可以减少 await 次数:

// 反模式:每个并发任务一个 await
async fn process_all(items: Vec<&str>) -> Vec<String> {
    let mut results = Vec::new();
    for item in items {
        results.push(process_one(item).await);  // N 次调度
    }
    results
}

// 推荐:用 join_all 批量
use futures::future::join_all;

async fn process_all(items: Vec<&str>) -> Vec<String> {
    join_all(items.iter().map(|i| process_one(i))).await
}

join_all 让所有 future 并发执行,只在最后 await 一次——减少调度开销。

6.17.8 优化模式选择决策

flowchart TD
  A["性能瓶颈在 wasm-bindgen?"] --> B{"症状?"}
  B --> C["跨边界次数太多"]
  B --> D["单次传输数据量大"]
  B --> E["JS 频繁创建对象"]
  C --> F["模式一:批量化"]
  D --> G["模式二:句柄"]
  D --> H["模式三:视图复用"]
  E --> I["模式四:避免 JsValue"]
  E --> J["模式五:闭包复用"]
  style F fill:#10b981,color:#333
  style G fill:#10b981,color:#333

把这套模式应用到生产 wasm-bindgen 项目,通常还有相当可观的优化空间——把它们用完了再考虑降级到 raw extern "C"(§6.13)。

6.18 大型 wasm-bindgen 项目的组织模式

小项目的 wasm-bindgen 用法简单——一个 lib.rs 几个 #[wasm_bindgen] 函数就够了。但当项目规模到几千行 Rust + 几十个导出 API 时,组织混乱会让维护成本急剧上升。这里总结大型项目的组织模式。

6.18.1 文件结构演进

graph LR
  A["项目规模"] --> B["小(< 500 行)"]
  A --> C["中(500-3000 行)"]
  A --> D["大(3000+ 行)"]
  B --> B1["单 lib.rs"]
  C --> C1["按功能拆 mod"]
  D --> D1["bindings/ 单独模块<br/>+ 业务 mod 分层"]
  style B1 fill:#10b981,color:#333
  style D1 fill:#6366f1,color:#fff

大型项目的推荐结构:

my-wasm-lib/
├── Cargo.toml
├── src/
│   ├── lib.rs              # 仅 mod 声明 + #[wasm_bindgen] re-export
│   ├── bindings/
│   │   ├── mod.rs          # bindings 入口
│   │   ├── render.rs       # render 相关导出
│   │   ├── parse.rs        # parse 相关导出
│   │   └── stream.rs       # 流式 API 导出
│   ├── core/
│   │   ├── mod.rs          # 核心业务,不带 wasm-bindgen
│   │   ├── parser.rs
│   │   ├── renderer.rs
│   │   └── types.rs
│   └── utils.rs
├── tests/
│   ├── integration_node.rs # wasm-bindgen-test 集成测试
│   └── browser/            # 浏览器测试
└── pkg/                    # 构建产物(gitignore)

6.18.2 关键原则:bindings 与 core 分离

flowchart LR
  A["JS 调用"] --> B["bindings/ 层"]
  B --> C["core/ 业务逻辑"]
  C --> D["内部类型 + 算法"]
  E["Rust 单测"] -.直接测试.-> C
  F["wasm-bindgen 测试"] -.通过 bindings.-> B
  style B fill:#10b981,color:#333
  style C fill:#6366f1,color:#fff

为什么分离:

  • 测试效率:core 层用 cargo test 测,不用编 wasm、不用起浏览器,快出一个量级
  • 重用:core 可以编译到原生平台用作 CLI,不限于 WASM
  • 职责清晰:bindings 仅做类型转换 + 错误处理,业务逻辑不混在一起

6.18.3 bindings 层的职责

// src/bindings/render.rs
use wasm_bindgen::prelude::*;
use crate::core::{render as core_render, RenderOptions as CoreOptions};

#[wasm_bindgen]
pub struct RenderOptions {
    inner: CoreOptions,
}

#[wasm_bindgen]
impl RenderOptions {
    #[wasm_bindgen(constructor)]
    pub fn new() -> RenderOptions {
        RenderOptions { inner: CoreOptions::default() }
    }
}

#[wasm_bindgen]
pub fn render(input: &str, opts: &RenderOptions) -> Result<String, JsValue> {
    core_render(input, &opts.inner)
        .map_err(|e| JsValue::from_str(&e.to_string()))
}

bindings 仅做:

  • 用 wrapper struct 包装内部类型
  • 把 Result<T, E> 转 Result<T, JsValue>
  • 处理字符串、字节等跨边界细节

6.18.4 测试组织

graph TD
  A["大型项目测试"] --> B["单元测试<br/>core/ 内部"]
  A --> C["集成测试<br/>tests/integration_*"]
  A --> D["E2E 测试<br/>真实浏览器"]
  B --> B1["cargo test 极速"]
  C --> C1["wasm-bindgen-test --node"]
  D --> D1["Playwright/Selenium"]
  style A fill:#6366f1,color:#fff

测试分布建议(按时间投入):

  • 单元测试:占大头,cargo test 很快跑完
  • 集成测试:适量,只覆盖跨边界的包装层
  • E2E:少量,只跑核心场景

不要把所有测试都放到 wasm-bindgen-test——开发循环会变慢。

6.18.5 错误处理的统一策略

// src/core/error.rs
#[derive(Debug, thiserror::Error)]
pub enum CoreError {
    #[error("invalid input: {0}")]
    InvalidInput(String),
    #[error("parse failed at line {line}: {msg}")]
    ParseFailed { line: usize, msg: String },
    #[error("internal error: {0}")]
    Internal(String),
}

// src/bindings/error.rs
use crate::core::CoreError;

pub(crate) fn to_js_error(e: CoreError) -> wasm_bindgen::JsValue {
    let obj = js_sys::Object::new();
    js_sys::Reflect::set(&obj, &"kind".into(), &format!("{:?}", e).into()).unwrap();
    js_sys::Reflect::set(&obj, &"message".into(), &e.to_string().into()).unwrap();
    obj.into()
}

每个 #[wasm_bindgen] 函数用同一套错误转换——保证 JS 侧的错误格式一致。

6.18.6 代码 review checklist

flowchart TD
  A["wasm-bindgen 代码 review"] --> B["1. 是否在 bindings/ 层添加新导出"]
  A --> C["2. core 层是否有等价的内部 API"]
  A --> D["3. 是否带单元测试"]
  A --> E["4. 是否带集成测试"]
  A --> F["5. 错误处理是否用统一函数"]
  A --> G["6. 是否影响 .d.ts"]
  A --> H["7. 是否影响 .wasm 体积"]
  style A fill:#6366f1,color:#fff

每条都对应工程纪律——通过这套清单的 PR 才能合并。

6.18.7 性能关注点

大型项目的性能关注点和小项目不同:

维度 小项目 大项目
单调用延迟 不敏感 毫秒级
体积 不重要 < 500KB
启动时间 不重要 < 100ms
内存峰值 不重要 监控 + 控制

表中数字是常见的目标量级示意,具体阈值按业务定。大项目必须从架构阶段就考虑性能——后期重构成本极高。这也是 §6.17 性能优化模式应该尽早应用的原因。

6.19 wasm-bindgen 与现代前端框架的深度集成

把 WASM 模块"塞"进 React/Vue/Svelte 项目不只是 import 调用——涉及生命周期管理、状态同步、SSR、HMR 等。每个框架有自己的最佳实践。

6.19.1 框架集成的共性挑战

graph TD
  A["框架集成挑战"] --> B["WASM 加载时机"]
  A --> C["状态管理"]
  A --> D["SSR 兼容"]
  A --> E["HMR 支持"]
  A --> F["内存清理"]
  B --> B1["在哪初始化 .wasm"]
  C --> C1["WASM 状态如何融入框架 store"]
  D --> D1["浏览器目标的胶水在服务端跑不起来"]
  E --> E1["改 Rust 代码后框架自动刷新"]
  F --> F1["组件卸载时 free WASM 对象"]
  style A fill:#f59e0b,color:#333

每个框架都需要解决——但解法不同。

6.19.2 React 集成模式

import { useEffect, useState, useRef } from 'react';
import init, { processImage } from 'my-wasm-lib';

// 模块级单例:缓存 init() 返回的 Promise,多个组件同时挂载也只初始化一次
let wasmInit = null;

function ImageProcessor({ image }) {
    const [wasmReady, setWasmReady] = useState(false);
    const wasmRef = useRef(null);

    useEffect(() => {
        wasmInit ??= init();
        wasmInit.then(() => setWasmReady(true));
    }, []);

    useEffect(() => {
        if (!wasmReady || !image) return;
        // 业务逻辑
        const result = processImage(image);
        wasmRef.current = result;

        // 清理
        return () => {
            if (wasmRef.current && wasmRef.current.free) {
                wasmRef.current.free();
            }
        };
    }, [wasmReady, image]);

    return wasmReady ? <Canvas /> : <Loading />;
}

关键模式:

  • WASM 单例化:缓存 init() 的 Promise 而不是一个完成标志——只判标志的话,两个组件在初始化完成前同时挂载会各调一次 init()
  • useRef 持有 Rust 对象:避免 React render 触发对象重建
  • useEffect 清理:组件卸载时 free()

6.19.3 Vue 3 集成模式

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import init, { ImageProcessor } from 'my-wasm-lib';

const props = defineProps<{ image: Uint8Array }>();
let wasmInstance: ImageProcessor | null = null;
const result = ref<Uint8Array | null>(null);

onMounted(async () => {
    await init();
    wasmInstance = new ImageProcessor();
    result.value = wasmInstance.process(props.image);
});

onUnmounted(() => {
    wasmInstance?.free();
});
</script>

Vue 的 composition API 让 WASM 集成自然——onMounted / onUnmounted 对应 init / free。

6.19.4 Svelte 集成模式

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import init, { ImageProcessor } from 'my-wasm-lib';

export let image: Uint8Array;
let wasmInstance: ImageProcessor | null = null;
let result: Uint8Array | null = null;

onMount(async () => {
    await init();
    wasmInstance = new ImageProcessor();
    result = wasmInstance.process(image);
});

onDestroy(() => {
    wasmInstance?.free();
});
</script>

<canvas data-result={result} />

Svelte 比 React/Vue 更简洁——但 WASM 集成模式相同。Vue 和 Svelte 两例为简洁起见在组件里直接 await init();同一页面会同时挂载多个实例时,要像 React 例那样把 init() 的 Promise 缓存成模块级单例。

6.19.5 SSR(服务端渲染)的处理

flowchart TD
  A["SSR 请求"] --> B{"环境?"}
  B --> C["服务端 Node.js"]
  B --> D["客户端浏览器"]
  C --> E["浏览器目标的胶水无法加载<br/>渲染 placeholder"]
  D --> F["客户端 hydration<br/>WASM 加载并接管"]
  style C fill:#f59e0b,color:#333
  style D fill:#10b981,color:#333

SSR 框架(Next.js / Nuxt / SvelteKit)的处理:

// Next.js 示例
export default function ImageProcessor({ image }) {
    const [Component, setComponent] = useState(null);

    useEffect(() => {
        // 仅在客户端动态加载
        import('./wasm-image-component').then((m) => {
            setComponent(() => m.default);
        });
    }, []);

    return Component ? <Component image={image} /> : <Placeholder />;
}

关键:用 next/dynamic 或类似机制让 WASM 组件只在客户端加载——避免 SSR 时报错。

6.19.6 HMR(热更新)支持

graph LR
  A["开发循环"] --> B["改 Rust 代码"]
  B --> C["wasm-pack build --dev"]
  C --> D["Vite 检测 .wasm 变化"]
  D --> E["重新加载 WASM"]
  E --> F["框架组件重新渲染"]
  style D fill:#6366f1,color:#fff
  style F fill:#10b981,color:#333

Vite 的 HMR 自然支持 WASM——文件变化触发重新加载。但 React/Svelte 需要确保旧的 WASM 对象被清理(避免内存泄漏)。

6.19.7 状态管理:WASM 与 Redux/Pinia/Vuex

// Redux:把 WASM 状态包装为可序列化形式
const wasmSlice = createSlice({
    name: 'wasm',
    initialState: {
        config: null as WasmConfig | null,
        results: [] as ProcessResult[],
    },
    reducers: {
        setConfig(state, action) {
            state.config = action.payload;
        },
    },
});

// 在 thunk 中调 WASM
export const processBatch = createAsyncThunk(
    'wasm/processBatch',
    async (input: Input) => {
        const wasm = await getWasmInstance();
        return wasm.process(input);
    }
);

注意:WASM 对象不能直接放进 Redux store——它们不可序列化。只放序列化结果。

6.19.8 性能优化模式

flowchart TD
  A["框架 + WASM 性能优化"] --> B["1. WASM 单例"]
  A --> C["2. 大数据用 Transferable"]
  A --> D["3. 长任务移出主线程"]
  A --> E["4. 状态最小化重建"]
  A --> F["5. memo/cache 框架级"]
  style A fill:#6366f1,color:#fff

每条都对应实际场景:

  • WASM 单例:所有组件共享一个实例
  • Transferable:传给 Worker 时零拷贝
  • 长任务:useEffect 中的同步 WASM 调用 > 16ms 会卡顿
  • 状态最小化:避免 React/Vue 的不必要 re-render
  • memo/cache:useMemo / computed 缓存 WASM 调用结果

6.20 wasm-bindgen 与 E2E 测试框架的集成

§6.16 介绍了 wasm-bindgen-test——但生产 WASM 应用还需要 E2E(端到端)测试。Playwright / Cypress / Selenium 等框架与 wasm-bindgen 项目的集成有特殊模式。

6.20.1 测试金字塔的 E2E 层

graph TD
  A["WASM 项目测试金字塔"] --> B["单元(cargo test)<br/>占大头"]
  A --> C["集成(wasm-bindgen-test)<br/>适量"]
  A --> D["E2E(Playwright)<br/>少量"]
  B --> B1["纯 Rust 逻辑"]
  C --> C1["WASM-JS 边界"]
  D --> D1["完整应用流程"]
  style D fill:#10b981,color:#333

E2E 测试数量最少——但保护核心用户路径,价值很高。

6.20.2 Playwright 集成实战

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
    testDir: './e2e',
    fullyParallel: true,
    workers: process.env.CI ? 1 : undefined,
    use: {
        baseURL: 'http://localhost:3000',
        trace: 'on-first-retry',
    },
    webServer: {
        command: 'pnpm dev',
        url: 'http://localhost:3000',
        reuseExistingServer: !process.env.CI,
    },
});
// e2e/wasm.spec.ts
import { test, expect } from '@playwright/test';

test('image filter applies correctly', async ({ page }) => {
    await page.goto('/');

    // 等待 WASM 加载
    await page.waitForFunction(() => window.__wasmReady === true);

    // 上传测试图片
    await page.setInputFiles('input[type="file"]', 'fixtures/test.jpg');

    // 应用滤镜
    await page.click('button:has-text("Apply Grayscale")');

    // 验证结果
    const canvas = page.locator('canvas');
    await expect(canvas).toHaveScreenshot('grayscale-result.png', { threshold: 0.05 });
});

关键模式:

  • window.__wasmReady 标志:WASM 加载完成后设置,让 Playwright 知道何时可以交互
  • 截图对比:图像处理结果用截图断言,比手写逐像素断言省事

6.20.3 等待 WASM 加载的技巧

flowchart TD
  A["Playwright 测试启动"] --> B["页面加载"]
  B --> C{"WASM 加载完?"}
  C -->|否| D["waitForFunction 轮询"]
  D --> C
  C -->|是| E["执行测试动作"]
  style D fill:#f59e0b,color:#333
  style E fill:#10b981,color:#333

实现:

// 应用代码
window.__wasmReady = false;
init().then(() => { window.__wasmReady = true; });

// 测试代码
await page.waitForFunction(() => window.__wasmReady, null, {
    timeout: 30_000,  // 给 .wasm 下载与编译留足余量
});

6.20.4 性能测试(E2E 层)

test('wasm processing performance', async ({ page }) => {
    await page.goto('/');
    await page.waitForFunction(() => window.__wasmReady);

    const stats = await page.evaluate(async () => {
        const start = performance.now();
        for (let i = 0; i < 100; i++) {
            await window.processImage(testData);
        }
        return performance.now() - start;
    });

    expect(stats).toBeLessThan(5000);  // 100 次处理 < 5s
});

E2E 性能测试比 micro-benchmark 更接近真实——包含 JS-WASM 边界 + 浏览器调度。

6.20.5 测试隔离

// 每个测试用独立 wasm 实例
test.describe.parallel('isolated wasm tests', () => {
    test.beforeEach(async ({ page }) => {
        await page.goto('/?isolated=true');  // 每个测试新实例
        await page.waitForFunction(() => window.__wasmReady);
    });

    test('test 1', async ({ page }) => { /* ... */ });
    test('test 2', async ({ page }) => { /* ... */ });
});

WASM 内部状态可能污染——每个测试独立实例避免相互影响。

6.20.6 与 wasm-bindgen-test 的分工

测试场景 用什么
Rust 逻辑测试 cargo test
WASM-JS API 验证 wasm-bindgen-test
真实用户流程 Playwright
性能 SLO wasm-bindgen-test + Playwright
跨浏览器兼容 Playwright(Chrome/Firefox/Safari)

每种工具有最佳适用场景——组合使用而非互斥。

6.21 跨专栏关联:与 Serde 的模式类比

wasm-bindgen 的类型转换机制和《Serde 元编程》专栏中的 Serialize/Deserialize 有深层的结构相似性——都是"Rust 类型 → 外部表示"的双向转换框架。

维度 Serde wasm-bindgen
转换方向 Rust ↔ 通用数据模型(JSON/YAML/...) Rust ↔ JS 值系统
转换发生地 Rust 进程内部 跨越 WASM 边界
抽象 trait Serializer / Deserializer IntoWasmAbi / FromWasmAbi
复杂类型处理 Visitor 模式 RefFromWasmAbi trait
内存模型 统一的 Rust 堆 线性内存 + GC 堆双空间

两者的核心设计模式相同——用 trait 抽象类型系统的差异,让上层代码只管写 Rust 类型,底层自动处理转换。理解了 Serde 的 Serializer trait 如何把 Rust 类型"拉平"为通用数据模型,wasm-bindgen 的 IntoWasmAbi trait 就是同一个思路在 WASM 边界的应用——把 Rust 类型"拉平"为 i32/i64/f32/f64 四种 WASM 原生类型。

差异在于:Serde 的转换在 Rust 进程内部完成,不涉及跨语言边界;wasm-bindgen 的转换跨越了两套完全不同的内存管理模型——Rust 的线性内存和 JS 的 GC 堆。这使得 wasm-bindgen 的转换代码比 Serde 的 impl Serialize 复杂得多——它不仅要处理类型表示的差异,还要处理内存所有权和生命周期的边界问题。

下一章深入 wasm-bindgen 的类型映射细节——每种 Rust 类型如何变成 JS 类型,以及背后的转换代码长什么样。