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,不针对特定的操作系统”:
rustup target add wasm32-unknown-unknown接着安装 wasm-pack。它会编译你的 crate、运行 wasm-bindgen,再把需要的文件全部放进一个文件夹。可以用官方安装脚本,也可以用 Cargo 构建:
# wasm-pack 官网提供的 Linux / macOS 安装脚本curl https://wasm-bindgen.github.io/wasm-pack/installer/init.sh -sSf | sh
# 或者,在任何装了 Rust 的系统上cargo install wasm-pack --locked为 Wasm 编写一个 Rust 库
Section titled “为 Wasm 编写一个 Rust 库”Wasm 模块是一个库,而不是带 main 的程序。创建一个:
cargo new --lib hello-wasmcd 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 负责。
先在本机测试逻辑
Section titled “先在本机测试逻辑”有一个能省下大量时间的好习惯:把真正的工作放在普通的 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 在本机运行,见测试。)
用 wasm-pack 构建
Section titled “用 wasm-pack 构建”在 crate 文件夹里运行:
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.jsonhello-wasm 里的连字符在文件名中变成了下划线。wasm-pack build 默认生成 release(优化过的)构建;实验阶段可以加上 --dev,构建更快,但不做优化。
从 JavaScript 调用
Section titled “从 JavaScript 调用”在 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,这是最快的办法:
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 文件保持小巧
Section titled “让 .wasm 文件保持小巧”页面在使用 .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 = truecodegen-units = 1要对自己诚实,一定要测量。在我们这个只有两个函数的例子里,这些设置并没有帮上忙:文件变成了 22,817 字节,反而略微变大了。它们往往在更大的 crate 里才见效。format! 和 panic 信息之类的东西也会引入代码,所以避开它们的 crate 可以小得多。
Wasm 在真实世界中的应用
Section titled “Wasm 在真实世界中的应用”几个例子,每个都附上了公司自己写的文章:
- Figma 把它用 C++ 写的设计编辑器编译成了 WebAssembly,并报告加载时间缩短到了原来的三分之一(英文)。(虽然不是 Rust,但能看出 Wasm 在大型应用里的威力。)
- Cloudflare Workers 通过
workers-rscrate 支持 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()。
下一步:完全用 Rust 写 Web 应用
Section titled “下一步:完全用 Rust 写 Web 应用”从 JavaScript 调用几个 Rust 函数是个很好的开始。如果你想用 Rust 编写整个 Web 应用,可以看看下面这些框架。它们都编译成 Wasm,让你用 Rust 编写组件,有点像 React:
- 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
- 从 Rust 编译为 WebAssembly — MDN:一步一步构建并运行 wasm-pack 项目的教程。
- The wasm-bindgen Guide(英文):支持的所有类型、如何调用浏览器 API,还有大量示例。
- wasm-pack 文档(英文):
build的全部选项、目标和配置。 - wasm32-unknown-unknown — The rustc book(英文):这个目标支持什么、有哪些限制。
- WebAssembly — MDN:Wasm 是什么、浏览器如何运行它,和 Rust 无关的基础知识。