如何暴露 Rust struct 到 JS 类?
解读
在国内前端与后端协同日益紧密的招聘场景下,面试官抛出“把 Rust struct 变成 JS 可以 new 的类”这一问,核心考察的是候选人对 FFI 边界、内存模型、工具链与工程化落地的综合掌控力。
他们并不满足于“能跑”,而是追问:
- 是否理解 Rust 所有权与 JS GC 的语义鸿沟;
- 能否选出 国内生产环境验证过的技术栈(wasm-bindgen 还是 napi-rs?);
- 对 跨语言对象生命周期、异常传播、多线程安全 有无工程级方案。
回答时若只贴一段 #[wasm_bindgen] 宏,会被视为“demo 级”;必须补充 内存释放策略、类型收敛、包体积优化、CI 发布流程 才能拿到高分。
知识点
- FFI 边界规则:Rust 与 JS 处在不同运行时,直接传递裸指针属于未定义行为;必须通过 wasm-bindgen 或 napi-rs 生成胶水层 把 Rust 结构体包装成不透明句柄(Opaque)。
- 所有权转移模型:
- wasm-bindgen 场景下,Rust 侧 Box::new 堆分配 后把原始指针交给 JS,JS 持有句柄,drop 时机由 __wbindgen_add_to_stack_pointer 或 manual_free 控制;
- napi-rs 场景下,使用 Reference 计数 + finalize callback,当 JS GC 回收时触发 Rust drop。
- 方法暴露范式:
- impl 块上加 #[wasm_bindgen] 或 #[napi] 即可成为 JS 原型方法;
- 若方法返回 &str 或 &[u8] 等借用类型,必须 显式复制到 JS Heap,否则编译期报 lifetime 错误。
- 线程安全:若 Rust 侧启用了 rayon、tokio 等并发,必须先进入 wasm32 的 Web Worker 或 napi 的 async_worker,否则主线程阻塞会直接击穿前端体验。
- 国内工程痛点:
- 包体积:wasm 默认把 fmt、panicking 全部打进去,需 wee_alloc + opt-level=z + wasm-opt -Oz 才能压到 50 KB 以内;
- 合规:金融、政务项目要求 国密算法 SM2/SM3/SM4 必须在 Rust 侧实现并通过 GM/T 0003 检测,wasm-bindgen 无加密出口限制,比 node 原生扩展更易过审。
- 调试与监控:
- 在 Chrome DevTools 的 Custom Object Formatter 里注册 #[wasm_bindgen] 的 toJSON,方便前端同学打印;
- 生产环境需把 console_error_panic_hook 打开,把 Rust panic 映射成 JS 异常,并上报 Sentry。
答案
下面给出 国内团队落地最多、面试最加分的 wasm-bindgen 方案,同时附带 内存释放与异常处理 的完整闭环,可直接写进简历。
- 定义 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);
}
}
- 编译与优化脚本(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"
}
- 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(); // **手动释放,防止内存泄漏**
-
内存兜底策略
- 提供 WeakRef + FinalizationRegistry 兜底,若前端忘记 free,在 GC 时自动回调 Rust drop;
- 在 React/Vue 的 onUnmounted 生命周期里统一 free,避免 HMR 重复创建导致 wasm 内存暴涨。
-
异常与 panic 处理
#[wasm_bindgen(start)]
pub fn main() {
console_error_panic_hook::set_once();
}
JS 侧可用 try/catch 捕获 Rust panic,错误栈同时包含 Rust 文件名与行号,方便定位。
拓展思考
-
若面试官追问“napi-rs 与 wasm-bindgen 如何选型”,可答:
- 纯前端项目、对包体积敏感、需跑在浏览器→ wasm-bindgen;
- Node 插件、需直接调用系统库(如 openssl、sqlite)→ napi-rs,可利用 node-gyp 国内镜像 加速编译,且无需担心 wasm 的 4 GB 内存上限。
-
若被问到“如何把 Rust struct 映射到 JS 的 class 继承体系”,可补充:
- wasm-bindgen 目前 不支持 JS class extends,需手动在 JS 侧做代理层;
- napi-rs 可通过 class_factory + wrap 实现原生继承,但 V8 API 版本升级时会出现 ABI 断裂,需锁死 Node 版本或做 prebuild + github action 矩阵发布。
-
对于高并发场景(如日志采集 SDK),可进一步说明:
- 在 Rust 侧使用 crossbeam-channel 把日志批量落盘,JS 侧只负责非阻塞 send;
- 通过 Atomics.wait/notify 实现 wasm32 的线程间同步,避免 postMessage 序列化开销,在 Chrome 实验室特性下能把吞吐提升 3 倍。
掌握以上深度,既能应对国内大厂 Rust 面,也能让前端团队放心把核心链路交给你重构。