如何暴露 Rust struct 到 JS 类?

解读

在国内前端与后端协同日益紧密的招聘场景下,面试官抛出“把 Rust struct 变成 JS 可以 new 的类”这一问,核心考察的是候选人对 FFI 边界、内存模型、工具链与工程化落地的综合掌控力
他们并不满足于“能跑”,而是追问:

  1. 是否理解 Rust 所有权与 JS GC 的语义鸿沟
  2. 能否选出 国内生产环境验证过的技术栈(wasm-bindgen 还是 napi-rs?);
  3. 跨语言对象生命周期、异常传播、多线程安全 有无工程级方案。
    回答时若只贴一段 #[wasm_bindgen] 宏,会被视为“demo 级”;必须补充 内存释放策略、类型收敛、包体积优化、CI 发布流程 才能拿到高分。

知识点

  1. FFI 边界规则:Rust 与 JS 处在不同运行时,直接传递裸指针属于未定义行为;必须通过 wasm-bindgen 或 napi-rs 生成胶水层 把 Rust 结构体包装成不透明句柄(Opaque)。
  2. 所有权转移模型
    • wasm-bindgen 场景下,Rust 侧 Box::new 堆分配 后把原始指针交给 JS,JS 持有句柄,drop 时机由 __wbindgen_add_to_stack_pointer 或 manual_free 控制
    • napi-rs 场景下,使用 Reference 计数 + finalize callback,当 JS GC 回收时触发 Rust drop。
  3. 方法暴露范式
    • impl 块上加 #[wasm_bindgen] 或 #[napi] 即可成为 JS 原型方法;
    • 若方法返回 &str 或 &[u8] 等借用类型,必须 显式复制到 JS Heap,否则编译期报 lifetime 错误。
  4. 线程安全:若 Rust 侧启用了 rayon、tokio 等并发,必须先进入 wasm32 的 Web Worker 或 napi 的 async_worker,否则主线程阻塞会直接击穿前端体验。
  5. 国内工程痛点
    • 包体积:wasm 默认把 fmt、panicking 全部打进去,需 wee_alloc + opt-level=z + wasm-opt -Oz 才能压到 50 KB 以内;
    • 合规:金融、政务项目要求 国密算法 SM2/SM3/SM4 必须在 Rust 侧实现并通过 GM/T 0003 检测,wasm-bindgen 无加密出口限制,比 node 原生扩展更易过审。
  6. 调试与监控
    • 在 Chrome DevTools 的 Custom Object Formatter 里注册 #[wasm_bindgen] 的 toJSON,方便前端同学打印;
    • 生产环境需把 console_error_panic_hook 打开,把 Rust panic 映射成 JS 异常,并上报 Sentry。

答案

下面给出 国内团队落地最多、面试最加分的 wasm-bindgen 方案,同时附带 内存释放与异常处理 的完整闭环,可直接写进简历。

  1. 定义 Rust 结构体与实现
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
#[derive(Clone)]
pub struct User {
    name: String,
    age: u8,
}

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

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

    #[wasm_bindgen(setter)]
    pub fn set_name(&mut self, name: String) {
        self.name = name;
    }

    #[wasm_bindgen]
    pub fn to_json(&self) -> Result<JsValue, JsValue> {
        serde_wasm_bindgen::to_value(&self).map_err(|e| JsValue::from_str(&e.to_string()))
    }

    /// 显式释放,防止 JS 忘记调 free
    #[wasm_bindgen]
    pub fn free(self) {
        drop(self);
    }
}
  1. 编译与优化脚本(package.json 片段,国内镜像加速
"scripts": {
  "build:wasm": "wasm-pack build --target web --out-dir pkg --features wee_alloc",
  "postbuild:wasm": "wasm-opt -Oz pkg/*.wasm -o pkg/*.wasm && gzip-size pkg/*.wasm"
}
  1. JS 侧使用范式(TypeScript)
import init, { User } from './pkg/rust_struct.js';

await init(); // 实例化 WebAssembly.Module

const user = new User('张三', 28);
console.log(user.name);      // 张三
user.set_name('李四');
console.log(user.toJson());  // { name: '李四', age: 28 }
user.free();                 // **手动释放,防止内存泄漏**
  1. 内存兜底策略

    • 提供 WeakRef + FinalizationRegistry 兜底,若前端忘记 free,在 GC 时自动回调 Rust drop;
    • React/Vue 的 onUnmounted 生命周期里统一 free,避免 HMR 重复创建导致 wasm 内存暴涨。
  2. 异常与 panic 处理

#[wasm_bindgen(start)]
pub fn main() {
    console_error_panic_hook::set_once();
}

JS 侧可用 try/catch 捕获 Rust panic,错误栈同时包含 Rust 文件名与行号,方便定位。

拓展思考

  1. 若面试官追问“napi-rs 与 wasm-bindgen 如何选型”,可答:

    • 纯前端项目、对包体积敏感、需跑在浏览器→ wasm-bindgen
    • Node 插件、需直接调用系统库(如 openssl、sqlite)→ napi-rs,可利用 node-gyp 国内镜像 加速编译,且无需担心 wasm 的 4 GB 内存上限。
  2. 若被问到“如何把 Rust struct 映射到 JS 的 class 继承体系”,可补充:

    • wasm-bindgen 目前 不支持 JS class extends,需手动在 JS 侧做代理层;
    • napi-rs 可通过 class_factory + wrap 实现原生继承,但 V8 API 版本升级时会出现 ABI 断裂,需锁死 Node 版本或做 prebuild + github action 矩阵发布
  3. 对于高并发场景(如日志采集 SDK),可进一步说明:

    • 在 Rust 侧使用 crossbeam-channel 把日志批量落盘,JS 侧只负责非阻塞 send;
    • 通过 Atomics.wait/notify 实现 wasm32 的线程间同步,避免 postMessage 序列化开销,在 Chrome 实验室特性下能把吞吐提升 3 倍。

掌握以上深度,既能应对国内大厂 Rust 面,也能让前端团队放心把核心链路交给你重构