如何导出函数给 JS?

解读

在国内前端与底层性能模块混合开发的场景里,**“把 Rust 函数导出给 JavaScript 调用”**几乎成了 Rust 岗面试的“必答题”。面试官真正想确认的是:

  1. 你是否理解 WebAssembly 的 FFI 边界规则
  2. 能否熟练使用 wasm-bindgen 这一套“事实标准”工具链;
  3. 是否知道 ABI 可移植性、内存布局、异常安全、包体积优化 这些上线后必踩的坑。
    回答时切忌只背“#[wasm_bindgen]”这一行宏,而要展示“编译→绑定→发布→运行时调试”的完整闭环经验。

知识点

  • wasm32-unknown-unknown 目标三元组与 LLVM 后端
  • wasm-bindgen 宏展开:生成 .wasm、_bg.wasm、.js、.d.ts 四件套
  • #[wasm_bindgen] 属性:导出名、重命名、skip_typescript、getter/setter
  • js-sys / web-sys:对 JS 全局对象与 DOM API 的零成本绑定
  • serde-wasm-bindgen:把 Rust 的 struct/enum 与 JS Object 互转,避免手写序列化
  • wasm-pack 工作流:build、test、pack、publish 到 npm 与字节内部 Nexus
  • wee_alloc / lol_alloc:替换默认 jemalloc,把体积压到 10 KB 级
  • wasm-opt / wasm-strip / wasm-snip:二进制体积与启动速度优化
  • 异步边界:Rust Future ↔ JS Promise,#[wasm_bindgen(async)] 与 wasm-bindgen-futures
  • 异常传播:Rust Result<T, E> 经 JsValue::from(err) 映射到 JS Error 事件
  • 多线程:SharedArrayBuffer + Atomics 前提下的 wasm-bindgen-rayon 方案
  • 国内部署:CDN 对 application/wasm MIME 的支持、Gzip/Br 压缩、HTTP 缓存策略

答案

  1. 环境准备

    rustup target add wasm32-unknown-unknown
    cargo install wasm-pack
    
  2. 新建库工程

    cargo new --lib my_math
    cd my_math
    
  3. Cargo.toml 关键段

    [lib]
    crate-type = ["cdylib"]
    
    [dependencies]
    wasm-bindgen = "0.2"
    
  4. 核心代码 src/lib.rs

    use wasm_bindgen::prelude::*;
    
    // 导出普通函数
    #[wasm_bindgen]
    pub fn add(a: i32, b: i32) -> i32 {
        a + b
    }
    
    // 导出结构体 + 方法
    #[wasm_bindgen]
    pub struct Calculator {
        value: f64,
    }
    
    #[wasm_bindgen]
    impl Calculator {
        #[wasm_bindgen(constructor)]
        pub fn new() -> Self {
            Calculator { value: 0.0 }
        }
    
        pub fn add(&mut self, v: f64) {
            self.value += v;
        }
    
        pub fn result(&self) -> f64 {
            self.value
        }
    }
    
  5. 一键构建

    wasm-pack build --target web --out-dir pkg
    

    产物:

    • my_math_bg.wasm(二进制)
    • my_math.js(ES 模块胶水)
    • my_math.d.ts(TypeScript 声明)
  6. 前端使用(国内 Vite / Webpack 5 均支持)

    import init, { add, Calculator } from './pkg/my_math.js';
    
    async function run() {
      await init(); // 加载并实例化 wasm
      console.log('3 + 4 =', add(3, 4));
    
      const calc = new Calculator();
      calc.add(10);
      calc.add(32);
      console.log('calc result:', calc.result());
    }
    run();
    
  7. 体积与性能优化

    • 在 Cargo.toml 加 wee_alloc 特征,体积立减 ~60 KB;
    • wasm-opt -Oz 再压 15~30%;
    • 开启 wasm-pack build --weak-refs --reference-types 利用最新 GC 提案,减少 JS 胶水;
    • 若接口多,开启 wasm-snip 删除 panic 路径,进一步瘦身。
  8. 单元测试

    wasm-pack test --headless --chrome
    

    保证 CI 里跑通,再合并主干——国内大厂对“无 headless 测试不发布”是硬性红线。

拓展思考

  • 宿主不止浏览器
    在 Node.js 端同样 wasm-pack build --target nodejs,但注意 NAPI 与 WASI 两套生态的竞争;字节内部 Serverless 平台已支持 WASI preview2,可直接把 Rust 函数当“冷启动 <50 ms”的轻量服务发布,无需 JS 胶水。

  • 复杂数据传递
    若需要把 Vec<复杂结构>、HashMap、BigInt 传给 JS,手写 #[wasm_bindgen] 会爆炸,推荐 serde-wasm-bindgen 一行 to_value(&rust_data)? 解决,性能损耗 <5%,却省掉大量维护成本。

  • 异步与流式
    大文件上传、图片解码 场景,用 wasm-bindgen-futures 把 Rust Stream 转成 JS AsyncIterator,前端可以 for await (const chunk of rustStream) 逐片消费,避免一次性把 200 MB 数据拷进 WASM 线性内存。

  • 调试与错误上报
    国内线上环境常禁用 console.log,务必在 Rust 侧用 web_sys::console::error_1(&err); 把堆栈打到 Sentry,并开启 wasm-pack build --debug 保留函数名,方便 Sentry 解析 source-map 级别的符号。

  • 安全与合规
    WASM 模块一旦通过 postMessage 传给 WebWorker,就跨越了 同源隔离;金融类业务需评估 SharedArrayBuffer + Spectre 风险,必要时降级到 cross-origin-isolated 头部或干脆回退到 JS 实现。

掌握以上“编译→绑定→优化→调试→上线”全链路,面试时就能从“会用 #[wasm_bindgen]”跃迁到“能带团队落地百万 QPS 的 Rust-WASM 网关”,稳稳拿到 Offer。