Chapter 03

Rust 编译到 Wasm:wasm-pack 实战

使用 wasm-pack 工具链将 Rust 代码编译为 WebAssembly 并在浏览器中运行

wasm-pack 工具链

wasm-pack 是什么?

wasm-pack 是 Rust WebAssembly 工作组开发的官方工具,它整合了 Rust 编译器(rustc)、wasm-bindgen(JS/Wasm 绑定生成器)和 wasm-opt(体积优化工具),让 Rust → Wasm 的编译流程极其简单。

安装工具链

# 安装 Rust(如果还没有)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# 添加 Wasm 编译目标
rustup target add wasm32-unknown-unknown

# 安装 wasm-pack
cargo install wasm-pack
# 或者更快的安装方式:
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh

创建 Rust Wasm 库

# 创建新的 Rust 库项目
cargo new --lib hello-wasm
cd hello-wasm
# Cargo.toml
[package]
name = "hello-wasm"
version = "0.1.0"
edition = "2021"

[lib]
# 编译为 C 动态库(wasm-pack 需要)
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"

[dev-dependencies]
wasm-bindgen-test = "0.3"

#[wasm_bindgen] 宏

// src/lib.rs
use wasm_bindgen::prelude::*;

// 导出函数到 JavaScript
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

// 导出结构体
#[wasm_bindgen]
pub struct Counter {
    count: u32,
}

#[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_count(&self) -> u32 {
        self.count
    }
}

// 从 JavaScript 导入函数
#[wasm_bindgen]
extern "C" {
    pub fn alert(s: &str);

    #[wasm_bindgen(js_namespace = console)]
    fn log(s: &str);
}

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

编译与使用

# 编译为 Web 目标(ES Modules)
wasm-pack build --target web

# 编译为 bundler 目标(Webpack/Vite)
wasm-pack build --target bundler

# 编译为 Node.js 目标
wasm-pack build --target nodejs

# Release 模式(开启优化)
wasm-pack build --release --target web
<!-- 在 HTML 中使用(target: web)-->
<script type="module">
  import init, { add, Counter } from './pkg/hello_wasm.js';

  async function run() {
    await init();  // 加载并初始化 Wasm 模块

    console.log(add(2, 3));   // 5

    const counter = new Counter();
    counter.increment();
    counter.increment();
    console.log(counter.get_count());  // 2
  }

  run();
</script>

pkg/ 目录结构

pkg/
├── hello_wasm.js         # JS 胶水代码(自动生成)
├── hello_wasm.d.ts       # TypeScript 类型声明(自动生成)
├── hello_wasm_bg.wasm    # 实际的 Wasm 二进制
├── hello_wasm_bg.wasm.d.ts
└── package.json

wasm-bindgen 深度解析

#[wasm_bindgen] 宏的工作原理

#[wasm_bindgen] 是一个过程宏(Procedural Macro),它在编译时分析标注的 Rust 代码,自动生成:

Wasm 侧胶水代码
额外的 Rust 函数,负责将 JavaScript 传来的 JS 类型转换为 Rust 类型(如:从线性内存读取字符串、将 JS 对象转为 Rust 结构体)。这些函数会被编译进 .wasm 文件。
JavaScript 胶水代码(.js 文件)
自动生成的 JS 包装器,暴露类型友好的 JS API。例如,如果你的 Rust 函数接收 &str,JS 侧会自动处理字符串编码,你在 JS 中只需传普通字符串。
TypeScript 类型声明(.d.ts 文件)
为生成的 JS 绑定自动创建 TypeScript 类型声明,让 TypeScript 项目有完整的类型提示。

wasm-bindgen 支持的类型

use wasm_bindgen::prelude::*;

// 基础类型 — 直接映射到 Wasm 数值类型
#[wasm_bindgen]
pub fn numeric_types(
    i: i32,    // → Wasm i32
    u: u32,    // → Wasm i32(JS 侧视为无符号)
    f: f64,    // → Wasm f64
    b: bool,   // → Wasm i32(0 或 1)
) -> f64 { f }

// 字符串 — 通过内存传递,wasm-bindgen 自动编解码
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
    // name 是从 JS 内存复制到 Rust 字符串的,这里有一次内存分配
    format!("Hello, {}!", name)
}

// 可选类型 — 使用 Option
#[wasm_bindgen]
pub fn maybe_add(a: i32, b: Option<i32>) -> i32 {
    a + b.unwrap_or(0)
    // JS 中可以传 null/undefined 给 Option 参数
}

// 错误处理 — 返回 Result 可以抛出 JS 异常
#[wasm_bindgen]
pub fn parse_number(s: &str) -> Result<f64, JsValue> {
    s.parse::<f64>()
        .map_err(|e| JsValue::from_str(&e.to_string()))
        // 如果解析失败,JS 侧会收到一个 throw 的 Error 对象
}

// Vec<u8> — 通过 Uint8Array 传递,适合图像/音频数据
#[wasm_bindgen]
pub fn process_bytes(data: &[u8]) -> Vec<u8> {
    // data 以 Uint8Array 传入,返回值也是 Uint8Array
    data.iter().map(|&b| 255 - b).collect()
}

wasm-bindgen 与 JavaScript 对象交互

use wasm_bindgen::prelude::*;
use web_sys::{Document, Element, HtmlCanvasElement, CanvasRenderingContext2d};

// web-sys crate 提供所有 Web API 的 Rust 绑定
#[wasm_bindgen]
pub fn draw_circle(canvas_id: &str) -> Result<(), JsValue> {
    // 获取 DOM 中的 canvas 元素
    let document: Document = web_sys::window()
        .ok_or("no window")?          // window 可能不存在(非浏览器环境)
        .document()
        .ok_or("no document")?;

    let canvas = document
        .get_element_by_id(canvas_id)
        .ok_or("canvas not found")?
        .dyn_into::<HtmlCanvasElement>()?;  // 动态类型转换

    let ctx = canvas
        .get_context("2d")?
        .ok_or("failed to get 2d context")?
        .dyn_into::<CanvasRenderingContext2d>()?;

    // 绘制填充圆形
    ctx.begin_path();
    ctx.arc(150.0, 150.0, 100.0, 0.0, std::f64::consts::TAU)?;
    ctx.set_fill_style(&JsValue::from_str("#654FF0"));
    ctx.fill();

    Ok(())
}

wasm-pack 编译目标详解

--target web
生成 ES 模块(ESM),使用 import init from './pkg/...',需要先调用 await init() 初始化。适合直接在浏览器中使用,不经过打包工具。
--target bundler
默认目标,生成适合 Webpack/Vite/Rollup 处理的模块。打包工具会处理 Wasm 的加载细节,用户代码可以直接 import 导出的函数,无需手动 init。
--target nodejs
生成 CommonJS 模块,同步加载 Wasm 文件。适合 Node.js 后端使用,通过 require() 导入。
--target deno
生成 Deno 兼容的 ES 模块,使用 import 和 URL 导入。Deno 对 Wasm 的支持与浏览器 API 完全兼容。

Cargo.toml 优化配置

# Cargo.toml — 用于生产的优化配置
[package]
name = "my-wasm-lib"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]  # cdylib 是生成 .wasm 的必要 crate 类型

[dependencies]
wasm-bindgen = "0.2"

# web-sys:提供浏览器 Web API 的 Rust 绑定
[dependencies.web-sys]
version = "0.3"
features = ["Window", "Document", "HtmlCanvasElement"]
# 注意:只启用需要的 feature,否则编译时间大幅增加

# Release 模式优化
[profile.release]
opt-level = "z"    # z = 体积优化(vs 3 = 速度优化),Wasm 通常优先体积
lto = true          # 链接时优化(跨 crate 死代码消除),可减少 30-40% 体积
panic = "abort"    # abort 比 unwind 减少约 20KB 的 panic 展开代码

常见问题与调试

内存泄漏:Rust 结构体在 JS 侧的生命周期

#[wasm_bindgen] 标注的 Rust 结构体在 JS 中通过引用计数管理。当 JS 对象被垃圾回收时,Rust 侧的 drop 会被调用。但如果结构体持有大量资源(如缓冲区),最好显式调用 .free() 方法来提前释放:

const processor = new ImageProcessor();
processor.process(imageData);
processor.free();  // 立即释放 Rust 侧内存,不要等 GC
字符串传递的隐藏开销

每次从 JS 传字符串到 Rust,wasm-bindgen 都会:(1) 在 JS 中调用 TextEncoder 编码为 UTF-8;(2) 在 Wasm 内存中分配空间;(3) 复制数据。对于大字符串或高频调用,这个开销不可忽视。考虑改用 &[u8] 直接传字节数组,或使用共享内存(SharedArrayBuffer)避免复制。

在 Wasm 中不能使用 Rust 的 std::io

wasm32-unknown-unknown 目标没有文件系统或网络访问,std::io::Filestd::net::TcpStream 等无法编译。如果需要系统调用,使用 wasm32-wasip1 目标配合 WASI 运行时(见第8章)。纯计算库不受影响。

本章小结

本章核心要点