返回文章列表

Rust WASM 实战:我用它给前端加了个涡轮增压

662·5 分钟阅读
RustWASMWebAssembly前端性能

写 Rust 的人迟早会碰到一个场景:前端有个计算密集的活,纯 JS 跑不动。 图像处理、音视频编解码、大数据集实时计算 —— 这些场景 JS 引擎再怎么优化也到不了底层语言的水平。 WASM 就是为这种时候准备的。

为什么要用 WASM?先想清楚这个问题

说实话,很多人学 WASM 的动机是错的。"Rust 那么好,我要用它写整个前端!" —— 别,真的别。

WASM 不是来替代 JavaScript 的。它是来补位的。

什么场景值得上 WASM:

  • 图像/视频/音频处理(像素级运算)
  • 加密、压缩、编解码
  • 物理模拟、游戏引擎
  • 大数据集的实时计算(表格排序、过滤、聚合)

什么场景别折腾:

  • 普通的表单验证、DOM 操作
  • 路由、状态管理
  • 基本的 CRUD 页面

一句话:

JS 能轻松搞定的事,别用 WASM。JS 搞不定或者搞得很慢的事,WASM 来。

我最终的使用模式是:JS 负责 UI 和业务逻辑,Rust WASM 负责性能关键的计算模块。两种语言各干各擅长的事。

环境搭建:两个工具,选一个

Rust 生态里搞 WASM 主要两个工具链:

工具 适合场景 特点
wasm-pack 把 Rust 编译成 npm 包,供 JS 项目调用 输出 .wasm + JS 胶水代码,直接 bun install
Trunk 用 Rust 写整个前端(配合 Yew/Leptos) 热重载、资源管理,更像 webpack

我这次的实战项目用的是 wasm-pack,因为我要把它集成到现有的 React 项目里。

先装工具:

# 安装 wasm-pack
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
 
# 确认 Rust 工具链有 wasm32 target
rustup target add wasm32-unknown-unknown

创建项目:

cargo new --lib rust-wasm-demo
cd rust-wasm-demo

Cargo.toml 长这样:

[package]
name = "rust-wasm-demo"
version = "0.1.0"
edition = "2024"
 
[lib]
crate-type = ["cdylib", "rlib"]  # cdylib 是给 WASM 用的
 
[dependencies]
wasm-bindgen = "0.2"

crate-type = ["cdylib"] 这行很关键,漏了会编译不出 .wasm 文件。我第一次搞的时候就卡在这,死活找不到 .wasm,排查了半小时。

第一个 WASM 模块:从最简单的开始

先跑通一个最简单的例子,确认整个链路没问题:

// src/lib.rs
use wasm_bindgen::prelude::*;
 
// 这个宏让 JS 能调用这个函数
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
    format!("Hello from Rust, {}!", name)
}
 
// 带计算的例子:斐波那契
#[wasm_bindgen]
pub fn fibonacci(n: u32) -> u64 {
    if n <= 1 {
        return n as u64;
    }
    let mut a: u64 = 0;
    let mut b: u64 = 1;
    for _ in 2..=n {
        let temp = a + b;
        a = b;
        b = temp;
    }
    b
}

编译:

wasm-pack build --target web

--target web 输出的是 ES module 格式,可以直接在现代浏览器里 import。还有 --target bundler(给 webpack/vite 用)和 --target nodejs

编译完会在 pkg/ 目录下生成:

  • rust_wasm_demo_bg.wasm — 编译后的 WASM 二进制
  • rust_wasm_demo.js — JS 胶水代码
  • rust_wasm_demo.d.ts — TypeScript 类型定义

在 HTML 里用:

<script type="module">
  import init, { greet, fibonacci } from './pkg/rust_wasm_demo.js';
 
  async function run() {
    await init();  // 初始化 WASM 模块
 
    console.log(greet("Rustacean"));  // "Hello from Rust, Rustacean!"
 
    // 来感受一下 WASM 的速度
    const start = performance.now();
    fibonacci(50);
    console.log(`WASM: ${performance.now() - start}ms`);
  }
 
  run();
</script>

wasm demo

跑通了?恭喜,整个链路没问题。但这个例子太玩具了,下面来点真东西。

实战:浏览器里的图像处理

我的真实需求是:用户上传图片,实时做像素级的滤镜处理(灰度、模糊、锐化等),纯 JS 太慢。

第一步:Rust 端的图像处理函数

# Cargo.toml 加上这些依赖
[dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
web-sys = { version = "0.3", features = [
    "console",
    "ImageData",
    "CanvasRenderingContext2d",
    "HtmlCanvasElement",
    "HtmlImageElement",
] }

web-sys 是访问 Web API 的桥梁。注意 features 要按需开启,全开的话编译会很慢。

use wasm_bindgen::prelude::*;
use js_sys::Uint8ClampedArray;
 
/// 对图像数据做灰度处理
/// pixels 是 RGBA 格式,每 4 个字节是一个像素
#[wasm_bindgen]
pub fn grayscale(pixels: &mut [u8], width: u32, height: u32) {
    let total = (width * height * 4) as usize;
    // 确保数据长度对得上
    assert_eq!(pixels.len(), total, "像素数据长度和宽高不匹配");
 
    // 每 4 个字节处理一个像素(R, G, B, A)
    for i in (0..total).step_by(4) {
        let r = pixels[i] as f64;
        let g = pixels[i + 1] as f64;
        let b = pixels[i + 2] as f64;
 
        // ITU-R BT.601 标准的灰度公式
        // 不是简单的平均值,人眼对绿色更敏感
        let gray = (0.299 * r + 0.587 * g + 0.114 * b) as u8;
 
        pixels[i] = gray;
        pixels[i + 1] = gray;
        pixels[i + 2] = gray;
        // Alpha 通道不动
    }
}
 
/// 简单的盒式模糊(Box Blur)
/// 这个算法简单但对教学来说够用,生产环境建议用高斯模糊
#[wasm_bindgen]
pub fn box_blur(pixels: &mut [u8], width: u32, height: u32, radius: u32) {
    let w = width as usize;
    let h = height as usize;
    let r = radius as usize;
 
    // 复制一份原始数据,模糊需要读取相邻像素
    let original = pixels.to_vec();
 
    for y in 0..h {
        for x in 0..w {
            let mut r_sum: u32 = 0;
            let mut g_sum: u32 = 0;
            let mut b_sum: u32 = 0;
            let mut count: u32 = 0;
 
            // 在半径范围内采样
            for dy in -(r as i32)..=(r as i32) {
                for dx in -(r as i32)..=(r as i32) {
                    let nx = x as i32 + dx;
                    let ny = y as i32 + dy;
 
                    if nx >= 0 && nx < w as i32 && ny >= 0 && ny < h as i32 {
                        let idx = ((ny as usize) * w + (nx as usize)) * 4;
                        r_sum += original[idx] as u32;
                        g_sum += original[idx + 1] as u32;
                        b_sum += original[idx + 2] as u32;
                        count += 1;
                    }
                }
            }
 
            let idx = (y * w + x) * 4;
            pixels[idx] = (r_sum / count) as u8;
            pixels[idx + 1] = (g_sum / count) as u8;
            pixels[idx + 2] = (b_sum / count) as u8;
        }
    }
}

注意几个点:

  1. 像素数据是 &mut [u8],wasm-bindgen 会自动处理 WASM 线性内存和 JS ArrayBuffer 之间的映射,你直接操作切片就行。
  2. 模糊算法需要复制原始数据,因为你在修改像素的同时要读取相邻的原始像素值。不复制的话,越改越乱。
  3. 灰度公式不是简单平均值,人眼对绿色最敏感、蓝色最不敏感,所以权重不一样。踩过坑,简单平均出来的灰度图偏暗。

第二步:JS 端调用

<canvas id="canvas"></canvas>
<input type="file" id="upload" accept="image/*">
<div>
  <button onclick="applyGrayscale()">灰度</button>
  <button onclick="applyBlur()">模糊</button>
</div>
 
<script type="module">
  import init, { grayscale, box_blur } from './pkg/rust_wasm_demo.js';
 
  await init();
  const canvas = document.getElementById('canvas');
  const ctx = canvas.getContext('2d');
 
  // 处理图片上传
  document.getElementById('upload').addEventListener('change', (e) => {
    const img = new Image();
    img.onload = () => {
      canvas.width = img.width;
      canvas.height = img.height;
      ctx.drawImage(img, 0, 0);
    };
    img.src = URL.createObjectURL(e.target.files[0]);
  });
 
  // 灰度滤镜
  window.applyGrayscale = () => {
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    const pixels = imageData.data;  // 这是个 Uint8ClampedArray
 
    const start = performance.now();
    grayscale(pixels, canvas.width, canvas.height);
    console.log(`灰度处理: ${(performance.now() - start).toFixed(2)}ms`);
 
    ctx.putImageData(imageData, 0, 0);
  };
 
  // 模糊滤镜
  window.applyBlur = () => {
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    const pixels = imageData.data;
 
    const start = performance.now();
    box_blur(pixels, canvas.width, canvas.height, 3);
    console.log(`模糊处理: ${(performance.now() - start).toFixed(2)}ms`);
 
    ctx.putImageData(imageData, 0, 0);
  };
</script>

我在一张 4000x3000 的照片上测试,灰度处理:

  • 纯 JS: ~45ms
  • WASM: ~12ms

模糊处理(半径 3)差距更大:

  • 纯 JS: ~800ms
  • WASM: ~180ms

差距不算巨大,但关键是在低端设备上 JS 版本会掉帧,WASM 版本依然流畅。而且图片越大、算法越复杂,差距越明显。

进阶:调用 JS 函数

有时候 Rust 代码需要调用 JS 的能力,比如 console.log、DOM 操作、Fetch API 等。

use wasm_bindgen::prelude::*;
 
// 声明一个外部的 JS 函数
#[wasm_bindgen]
extern "C" {
    // 导入浏览器的 alert 函数
    fn alert(s: &str);
 
    // 导入 console.log
    #[wasm_bindgen(js_namespace = console)]
    fn log(s: &str);
}
 
// 也可以用 #[wasm_bindgen] 标注的 struct 来包装更复杂的 JS 对象
#[wasm_bindgen]
extern "C" {
    type HTMLDocument;
 
    #[wasm_bindgen(method, getter)]
    fn title(this: &HTMLDocument) -> String;
 
    #[wasm_bindgen(method, setter)]
    fn set_title(this: &HTMLDocument, title: &str);
}
 
#[wasm_bindgen]
pub fn show_alert() {
    alert("Hello from Rust!");
    log("这条消息出现在控制台");
}

更实用的场景:Rust 处理完数据后,调用 JS 回调通知 UI 更新:

#[wasm_bindgen]
pub fn process_with_callback(data: &mut [u8], callback: &js_sys::Function) {
    // 做一些耗时处理
    for chunk in data.chunks_exact_mut(4) {
        let gray = (0.299 * chunk[0] as f64
            + 0.587 * chunk[1] as f64
            + 0.114 * chunk[2] as f64) as u8;
        chunk[0] = gray;
        chunk[1] = gray;
        chunk[2] = gray;
    }
 
    // 调用 JS 回调
    let this = JsValue::null();
    callback.call0(&this).unwrap();
}

进阶:Web Workers 多线程

WASM 默认跑在主线程上,复杂计算还是会卡 UI。解决方案:Web Workers。

但 WASM 在 Worker 里不能直接操作 DOM。我用的模式是:

use wasm_bindgen::prelude::*;
use js_sys::{SharedArrayBuffer, Uint8Array};
 
/// 在 Worker 里运行的计算函数
/// 通过 SharedArrayBuffer 和主线程通信
#[wasm_bindgen]
pub fn process_in_worker(buffer: &SharedArrayBuffer, width: u32, height: u32) {
    let array = Uint8Array::new(buffer);
    let mut pixels = vec![0u8; array.length() as usize];
    array.copy_to(&mut pixels);
 
    // 在这里做耗时计算
    grayscale(&mut pixels, width, height);
 
    // 写回共享内存
    array.copy_from(&pixels);
}
// main.js — 主线程
const worker = new Worker('worker.js');
const buffer = new SharedArrayBuffer(width * height * 4);
const pixels = new Uint8ClampedArray(buffer);
 
// 把图像数据写入共享内存
pixels.set(imageData.data);
 
worker.postMessage({ buffer, width, height });
 
worker.onmessage = () => {
  // Worker 处理完了,更新 canvas
  imageData.data.set(pixels);
  ctx.putImageData(imageData, 0, 0);
};

这样计算完全在 Worker 线程,主线程的 UI 保持流畅。

性能对比:别光听我说,自己跑跑看

我在不同场景下做了简单的 benchmark:

场景 纯 JS WASM 提升
灰度 4000x3000 45ms 12ms 3.75x
模糊半径3 4000x3000 800ms 180ms 4.4x
斐波那契 5000万次 1200ms 85ms 14x
JSON 解析 1MB 15ms 18ms 0.83x ❌

注意最后一行:JSON 解析 WASM 反而更慢。 因为数据要在 JS 和 WASM 之间复制,这个开销抵消了计算上的优势。所以不是所有场景都适合 WASM,数据密集型 IO 操作反而可能更慢。

结论

几条经验:

  1. WASM 是手术刀,不是锤子。 用在性能关键的计算路径上,别想用它重写整个前端。

  2. wasm-bindgen + web-sys 的 DX 已经很好了。 早期这些工具还很粗糙,现在体验已经很顺滑了。

  3. 包体积是个需要持续关注的问题。 一个函数 80KB 还行,但如果你引入了一堆依赖,很容易膨胀到几 MB。定期用 twiggy 分析一下。

  4. 调试体验还是不如 JS。 错误信息不够友好,断点调试比较麻烦。建议先在 Rust 这边写好单元测试,确认逻辑没问题再编译成 WASM。

  5. 如果项目是用 React/Vue 的,wasm-pack 是最省心的选择。 如果整个项目都想用 Rust 写,看看 Yew 或 Leptos,但要做好踩坑的准备。

学 WASM 最大的收获不是"能在浏览器里跑 Rust",而是让我重新思考了前端性能优化的边界。

以前遇到性能问题,第一反应是"优化 JS 算法"或者"上 Web Worker"。

现在多了一个维度:"这个计算用 Rust 来做会不会快很多?"

有时候答案是肯定的,有时候不是 —— 但至少你知道有这个选项。