跳转到内容

Rust 与 WebAssembly

WebAssembly(常简称 Wasm)可以让 Rust 等语言编写的代码在浏览器里运行,和 JavaScript 并肩工作。可以把它想成浏览器里的第二台引擎:页面仍然由 JavaScript 驱动,但遇到繁重的工作,比如图像滤镜、物理模拟、解析大文件,就可以交给编译好的、速度飞快的 Rust 代码。

这一页里,你会写一个很小的 Rust 库,把它编译成 Wasm,再从网页里调用它。这里的内容都没法在 Rust Playground 里运行(需要浏览器和额外的工具),所以大部分示例都是普通代码块,请在你自己的电脑上运行。不过,纯 Rust 的部分可以马上试一试。

WebAssembly 是什么,为什么适合 Rust

Section titled “WebAssembly 是什么,为什么适合 Rust”

WebAssembly 是一种紧凑的二进制格式,用来存放程序。所有主流浏览器都能运行它,速度接近原生,而且和 JavaScript 处在同一个安全沙箱里。它也不只用于浏览器:服务器和边缘计算平台同样能运行 Wasm。

Rust 是最受欢迎的 Wasm 语言之一,原因有这么几个:

  • 没有垃圾回收器,也没有庞大的运行时。Rust 不需要给代码附带额外的“机器”,所以 .wasm 文件可以很小。
  • 工具好用。wasm-bindgen 会替你生成 Rust 和 JavaScript 之间的胶水代码,wasm-pack 一条命令就能构建好一切。
  • 安全又快。你能得到和桌面程序一样的内存安全和性能。

首先,告诉 Rust 它可以为 Wasm 这个目标(target)编译。目标就是“代码将要运行在哪种机器上”;wasm32-unknown-unknown 的意思是“32 位 Wasm,不针对特定的操作系统”:

Terminal window
rustup target add wasm32-unknown-unknown

接着安装 wasm-pack。它会编译你的 crate、运行 wasm-bindgen,再把需要的文件全部放进一个文件夹。可以用官方安装脚本,也可以用 Cargo 构建:

Terminal window
# wasm-pack 官网提供的 Linux / macOS 安装脚本
curl https://wasm-bindgen.github.io/wasm-pack/installer/init.sh -sSf | sh
# 或者,在任何装了 Rust 的系统上
cargo install wasm-pack --locked

Wasm 模块是一个库,而不是带 main 的程序。创建一个:

Terminal window
cargo new --lib hello-wasm
cd hello-wasm

然后编辑 Cargo.toml。crate-type 这一行告诉 Cargo 生成适合 .wasm 的库(cdylib),而 wasm-bindgen 就是连接 Rust 和 JavaScript 的 crate:

[package]
name = "hello-wasm"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"

现在在 src/lib.rs 里写两个函数。#[wasm_bindgen] 属性把每个函数标记为“可以从 JavaScript 调用”:

use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
format!("Hello, {name}! This string was built in Rust.")
}

需要的 Rust 代码就这么多。注意,它们都是普通的函数:把 JavaScript 字符串转成 &str、把返回的 String 再转回 JavaScript 字符串,这些都由 wasm-bindgen 负责。

有一个能省下大量时间的好习惯:把真正的工作放在普通的 Rust 函数里,在你平时的电脑上用 cargo test 或者一个简单的 main 来测试。在浏览器里调试比在终端里调试慢得多。去掉属性之后,同样的逻辑在哪里都能运行:

fn add(a: i32, b: i32) -> i32 {
a + b
}
fn greet(name: &str) -> String {
format!("Hello, {name}! This string was built in Rust.")
}
fn main() {
println!("{}", greet("Ferris"));
println!("2 + 3 = {}", add(2, 3));
}

输出:

Hello, Ferris! This string was built in Rust.
2 + 3 = 5
▶ 动手试一试

这里的问候语保留了英文,好和后面浏览器里真实显示的那句话保持一致。逻辑确认无误之后,#[wasm_bindgen] 包装层就只是通往浏览器的一扇薄薄的门。(标了 #[test] 的测试也能用 cargo test 在本机运行,见测试。)

在 crate 文件夹里运行:

Terminal window
wasm-pack build --target web

--target web 的意思是“生成浏览器能直接用 <script type="module"> 标签加载的文件”,不需要 webpack 之类的打包工具。我们运行之后,新生成的 pkg/ 文件夹里有这些文件:

pkg/
├── hello_wasm_bg.wasm 编译好的 Rust 代码
├── hello_wasm.js 加载它、并包装每个函数的 JavaScript 胶水代码
├── hello_wasm.d.ts 你的函数的 TypeScript 类型
├── hello_wasm_bg.wasm.d.ts
└── package.json

hello-wasm 里的连字符在文件名中变成了下划线。wasm-pack build 默认生成 release(优化过的)构建;实验阶段可以加上 --dev,构建更快,但不做优化。

在 Cargo.toml 旁边新建 index.html(这样 pkg/ 就和它在同一个文件夹里):

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello from Rust</title>
</head>
<body>
<p id="message">Loading…</p>
<script type="module">
import init, { add, greet } from "./pkg/hello_wasm.js";
await init(); // 下载并启动 .wasm 文件
const text = greet("Ferris");
const sum = add(2, 3);
document.getElementById("message").textContent = `${text} 2 + 3 = ${sum}`;
</script>
</body>
</html>

默认导出的 init() 会下载 .wasm 文件并启动它。之后,add 和 greet 就是普通的 JavaScript 函数了。

浏览器不会从 file:// 地址加载模块,所以你需要一个小小的本地 Web 服务器。如果装了 Python,这是最快的办法:

Terminal window
python3 -m http.server 8080

在浏览器里打开 http://localhost:8080。我们打开时,页面显示:

Hello, Ferris! This string was built in Rust. 2 + 3 = 5

这句话是由在浏览器里运行的 Rust 拼出来的。🦀

哪些值跨边界很便宜(哪些不是)

Section titled “哪些值跨边界很便宜(哪些不是)”

Rust 和 JavaScript 使用内存的方式不一样。Wasm 模块有自己的一块内存,值要在两边之间来回传递。有些传递是免费的,有些则要复制一次:

Rust 中的类型 JavaScript 中的类型 代价
i32、u32、f64、bool number、boolean 便宜:直接传递
&str、String string 复制一次,外加 UTF-16 与 UTF-8 之间的转换
&[u8]、Vec<u8> Uint8Array 复制进(或复制出)Wasm 内存
&mut [u8] Uint8Array 先复制进去,调用结束后再复制回来

所以黄金法则是:少调用几次,每次多干点活。调用一次 Rust 处理整张图片很快;而每个像素调用一次、总共调用一百万次,大部分时间都花在跨越边界上了。

看看具体是什么样子。下面这个函数一次调用就把整个像素缓冲区调亮:

use wasm_bindgen::prelude::*;
/// 让每个像素都变亮。JS 传入一个 Uint8Array。
#[wasm_bindgen]
pub fn brighten(pixels: &mut [u8], amount: u8) {
for p in pixels.iter_mut() {
*p = p.saturating_add(amount);
}
}

在 JavaScript 里,await init() 之后,用一个 Uint8Array 调用 brighten(pixels, 20),数组就会被原地更新。循环本身是纯 Rust,所以可以在本机测试:

fn brighten(pixels: &mut [u8], amount: u8) {
for p in pixels.iter_mut() {
// saturating_add 到 255 就停住,而不是溢出
*p = p.saturating_add(amount);
}
}
fn main() {
let mut pixels = [10, 100, 250];
brighten(&mut pixels, 20);
println!("{pixels:?}");
}

输出:

[30, 120, 255]
▶ 动手试一试

注意 250 + 20 变成了 255,既没有崩溃,也没有绕回到小数字:saturating_add 到最大值就停住。处理颜色时,这正是你想要的效果。

页面在使用 .wasm 文件之前,每个字节都得先下载下来。几个建议:

  • 永远发布 release 构建。我们这个小例子,wasm-pack build --dev 生成的 .wasm 文件有 58,591 字节,默认的 release 构建则是 21,530 字节。(这是在我们机器上关闭 wasm-opt 时的数字,你的结果会有所不同。)
  • 让 wasm-pack 运行 wasm-opt。在 release 模式下,wasm-pack 默认会对输出运行 Binaryen 的 wasm-opt 优化器,通常能让文件更小。
  • 在服务器上开启压缩。我们那个 21,530 字节的文件,gzip 之后只有 8,464 字节。
  • 试试针对体积的设置,然后测量:
[profile.release]
opt-level = "z" # 针对体积优化("s" 是温和一点的版本)
lto = true
codegen-units = 1

要对自己诚实,一定要测量。在我们这个只有两个函数的例子里,这些设置并没有帮上忙:文件变成了 22,817 字节,反而略微变大了。它们往往在更大的 crate 里才见效。format! 和 panic 信息之类的东西也会引入代码,所以避开它们的 crate 可以小得多。

几个例子,每个都附上了公司自己写的文章:

  • Figma 把它用 C++ 写的设计编辑器编译成了 WebAssembly,并报告加载时间缩短到了原来的三分之一(英文)。(虽然不是 Rust,但能看出 Wasm 在大型应用里的威力。)
  • Cloudflare Workers 通过 workers-rs crate 支持 Rust(英文):你的 Rust 代码被编译成 Wasm,在 Cloudflare 遍布全球的服务器上运行。
  • Shopify Functions 是用来定制网店的 WebAssembly 模块(英文),Shopify 推荐用 Rust 编写,认为它是性能最好的语言。
  • 1Password 的浏览器扩展把页面填充功能迁移到了 WebAssembly(英文),报告说速度至少快了一倍。

忘了 crate-type = ["cdylib"]。没有它,wasm-pack 会马上停下来。好在错误信息会准确地告诉你该加什么:

Error: crate-type must be cdylib to compile to WebAssembly. Add the following to your Cargo.toml file:
[lib]
crate-type = ["cdylib", "rlib"]

意思是:要编译成 WebAssembly,crate-type 必须是 cdylib,请把上面这两行加进 Cargo.toml。

双击打开 index.html。页面会一直停在“Loading…”,浏览器控制台(Chrome)显示:

Access to script at 'file:///…/pkg/hello_wasm.js' from origin 'null' has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes: chrome, chrome-extension, chrome-untrusted, data, http, https, isolated-app.

这是说:CORS 策略阻止了从 file:// 地址加载脚本,跨源请求只支持 http、https 等协议。解决方法:用本地 Web 服务器(比如 python3 -m http.server)提供这个文件夹,然后改为打开 http://localhost 地址。

在 await init() 之前调用函数。这时 Wasm 模块还没加载好,胶水代码没有东西可调用。我们删掉 init() 那一行后,Chrome 显示 Cannot read properties of undefined (reading '__wbindgen_free')。一定要先 await init()。

从 JavaScript 调用几个 Rust 函数是个很好的开始。如果你想用 Rust 编写整个 Web 应用,可以看看下面这些框架。它们都编译成 Wasm,让你用 Rust 编写组件,有点像 React:

  • Leptos(英文):一个快速、现代的框架,还能在服务器端渲染页面。
  • Yew(英文):一个成熟的、基于组件的框架。
  • WebAssembly 是一种快速、安全的二进制格式,浏览器(以及服务器)都能运行;Rust 没有垃圾回收器,所以很适合编译成 Wasm。
  • 用 rustup target add wasm32-unknown-unknown 添加目标,再安装 wasm-pack。
  • 用 #[wasm_bindgen] 标记函数,设置 crate-type = ["cdylib", "rlib"],然后用 wasm-pack build --target web 构建。
  • 在 JavaScript 里先 await init(),再调用你的函数;通过 http:// 而不是 file:// 打开页面。
  • 数字跨边界很便宜,字符串和 slice 则要复制,所以要少调用、每次多干活。
  • 发布 release 构建并开启压缩;调整体积的设置要先测量再相信。

小测验

1. 哪条命令能添加浏览器 WebAssembly 用的 Rust 目标?

2. #[wasm_bindgen] 属性有什么作用?

3. 页面一直停在“Loading…”,控制台对一个 file:/// 地址报了 CORS 错误。怎么解决?

4. 下面哪一个从 JavaScript 传给 Rust 的代价最小?

得分: 0 / 4