Rust打包为WASM 应用于JavaScript前端

背景

在前端开发中,我们经常会遇到一些性能瓶颈,比如一些复杂的计算,或者一些需要大量计算的算法,这时候我们可以考虑使用WebAssembly来提高性能。

WebAssembly是一种新的低级字节码格式,可以在现代浏览器中运行,它可以让我们在浏览器中运行其他语言编写的代码,比如C、C++、Rust等。

Rust 因为其安全性可靠性以及高性能,以及跨平台的特性,非常适合用来编写WebAssembly模块。

1
2
3
4
5
6
7
### [Rust 和 WebAssembly 用例](https://developer.mozilla.org/zh-CN/docs/WebAssembly/Guides/Rust_to_Wasm#rust_%E5%92%8C_webassembly_%E7%94%A8%E4%BE%8B#rust_和_webassembly_用例)
Rust 和 WebAssembly 有两大主要用例:

- 构建完整应用——整个 Web 应用都基于 Rust 开发!
- 构建应用的组成部分——在现存的 JavaScript 前端中使用 Rust。

目前,Rust 团队正专注于第二种用例,因此我们也将着重介绍它。对于第一种用例,可以参阅 [`yew`](https://github.com/DenisKolodin/yew) 这类项目。

MDN网站上本身有一个简明的教程,用于介绍如何将Rust编写的程序打包为WebAssembly模块,然后在JavaScript中调用:编译 Rust 为 WebAssembly - WebAssembly | MDN

MDN这篇文章写的非常通俗易懂,基本上是面向于零基础的读者, 但是问题是,这篇文章里面仍然有很多容易踩的坑,所以说,我在这结合我自己的实践、以及所遇到的问题,来写一篇更加详细的教程。

所以说这篇文章的大部分内容将会和 MDN 的文章一致,但是我会对其的一些内容进行删改,以及添加一些我认为有必要的内容。

路径以及阶段性目标

在本教程中,我们将使用 Rust 的 npm 包构建工具 wasm-pack 来构建一个 npm 包。这个包只包含 WebAssembly 和 JavaScript 代码,以便包的用户无需安装 Rust 就能使用。他们甚至不需要知道这里包含 WebAssembly!

之后我们会使用 vite 框架来引入这个npm包,然后在前端项目中调用这个WebAssembly模块。

安装 Rust 环境

让我们看看安装 Rust 环境的所有必要步骤。

安装 Rust

前往 Install Rust 页面并跟随指示安装 Rust。这里会安装一个名为“rustup”的工具,这个工具能让你管理多个不同版本的 Rust。默认情况下,它会安装用于惯常 Rust 开发的 stable 版本 Rust Release。Rustup 会安装 Rust 的编译器 rustc、Rust 的包管理工具 cargo、Rust 的标准库 rust-std 以及一些有用的文档 rust-docs

备注: 需要注意,在安装完成后,你需要把 cargo 的 bin 目录添加到你系统的 PATH。一般来说它会自动添加,但需要你重启终端后才会生效。

wasm-pack

要构建我们的包,我们需要一个额外工具 wasm-pack。它会帮助我们把我们的代码编译成 WebAssembly 并制造出正确的 npm 包。使用下面的命令可以下载并安装它:

1
cargo install wasm-pack

可选:安装 Node.js 并获取 npm 账户

在这个例子中我们将会构建一个 npm 包,因此你需要确保安装 Node.js 和 npm 已经安装。

另外,我们将会把包发布到 npm 上,因此你还需要一个 npm 账号。它们是免费的。发布这个包并不是必须的,但是发布它非常简单,因此在本例中我们默认你会发布这个包。

这一步主要是为了,让你能够在本地测试你的WebAssembly模块,以及发布到npm上。之后其他的项目可以直接通过npm来同步更新你的WebAssembly模块,而无需手动操作。

Get npm! 页面按照说明下载并安装 Node.js 和 npm。在选择版本时,选择一个你喜欢的版本;本例不限定特定版本。

npm signup page 注册 npm 账户,并填写表格。

接下来,在命令行中运行 npm adduser:

1
2
3
4
> npm adduser
Username: yournpmusername
Password:
Email: (this IS public) you@example.com

你需要完善你的用户名,密码和邮箱。如果成功了,你将会看到:

1
Logged in as yournpmusername on https://registry.npmjs.org/.

在这里你可能会遇到两个问题: 1. 你被重定向到了 cnpm 而不是 npm 的网站。cnpm不支持公开账号的注册,它只允许你注册一个私人账号。

遇到这个问题是因为你之前可能遇到过 npm 无法安装某些模块的问题,之后在教程的指引下,使用了npmmirrow 淘宝镜像作为 npm 的源,当更改npm的源的时候,账号验证的源也会被设置为淘宝的镜像源。

解决方式有两个,一个是直接切换npm的源,但是这样,当你下次又有无法安装的而模块的时候,你又需要切换回来,最好的办法是自己手动指定一下登入账号使用的源:

1
npm login --registry http://registry.npmjs.com

  1. 密码和账号输入时无法确认是否输入正确且无法复制粘贴。

使用

1
npm login --username <username> --password <password> --email <email>

可以在命令行就指定这些参数,方便检查有无出错。 但是请注意请不要将这行命令添加到公开的文件里,以保护你的账号安全。

构建我们的 WebAssembly npm 包

万事俱备,来创建一个新的 Rust 包吧。打开你用来存放你私人项目的目录,做这些事:

1
2
$ cargo new --lib hello-wasm
Created library `hello-wasm` project

这里会在名为 hello-wasm 的子目录里创建一个新的库,里面有下一步之前你所需要的一切:

1
2
3
+-- Cargo.toml
+-- src
+-- lib.rs

首先,我们有一个 Cargo.toml 文件,这是我们配置构建的方式。如果你用过 Bundler 的 Gemfile 或者 npm 的 package.json,你应该会感到很熟悉。Cargo 的用法和它们类似。

接下来,Cargo 在 src/lib.rs 生成了一些 Rust 代码:

1
2
3
4
5
6
7
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
}

我们完全不需要使用这些测试代码,所以继续吧,我们删掉它。

来写点 Rust 代码吧!

让我们在 src/lib.rs 写一些代码替换掉原来的:

1
2
3
4
5
6
7
8
9
10
11
12
13
extern crate wasm_bindgen;

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern {
pub fn alert(s: &str);
}

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

上面是MDN给的代码,它在后面给出了每个部分的解释,你可以点击这里跳转到原文进行查看:来写点 Rust 代码吧!

不过我认为将注释和代码放在一起会更方便于参考,读者可以自行决定使用哪种方式。这里将会给出一个缺少部分细节的高级说明;如果想要了解更多 Rust 知识,请查看在线书籍 The Rust Programming Language

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/*
第一行就像在说“哇 Rust,我们在用一个叫做 wasm_bindgen 的库”。
在 Rust 当中,库被称为“crates”,因为我们使用的是一个外部库,所以有 "extern"。
明白了吗? Cargo ships crates.
*/
extern crate wasm_bindgen;

/*第三行包括了一个将库中的代码引入到你的代码中的使用命令。
在这个情况下,将会引入 wasm_bindgen::prelude 的全部模块。
我们将在下一节中使用这些内容。 */
use wasm_bindgen::prelude::*;

/*
在 #[] 中的内容叫做 "属性",并以某种方式改变下面的语句。(其实有点类似于C#的属性)
#[wasm_bindgen] 后面跟着的语句是一个 extern,它将告诉 Rust 这下面的函数将由外部提供,我们想调用一些外部定义的函数。
而前面的这个属性标注告诉我们 这些函数将由 "wasm-bindgen" 来进行导入。(类似于 import {alert} from "wasm-bindgen")

这里的 alert 是 js 中的一个函数,它可以打开一个消息通知窗口,参数s为str类型,用于指示显示的内容。

当你想调用新的 JavaScript 函数时,你可以在这里写他们,wasm-bindgen 将负责为你设置一切。

并非一切都得到支持,但我们正在努力!
*/
#[wasm_bindgen]
extern {
pub fn alert(s: &str);
}

/*
这里的 #[wasm_bindgen] 和上面的类似,但是这里,#[wasm_bindgen] 后面 跟着的不再是 extern,而是 pub fn。

这代表我们希望能够在 JavaScript 中使用这个 Rust 函数。
这和 extern 正相反:我们并非引入函数,而是要把函数给外部世界使用。(类比到js里面就是 export {greet})

这个函数的名字是 greet,它需要一个参数,一个字符串(写作 &str)。
它调用了我们前面在 extern 块中引入的 alert 函数。
它传递了一个让我们串联字符串的 format! 宏的调用。

*/
#[wasm_bindgen]
pub fn greet(name: &str) {
/*
format! 在这里有两个参数,一个格式化字符串和一个要填入的变量。
格式化字符串是 "Hello, {}!" 部分。
它可以包含一个或多个 {},变量将会被填入其中。

传递的变量是 name,也就是这个函数的参数。
所以当我们调用 greet("Steve")时我们就能看到 "Hello, Steve!"。
*/
alert(&format!("Hello, {}!", name));
}

把我们的代码编译到 WebAssembly

为了能够正确的编译我们的代码,首先我们需要配置 Cargo.toml。打开这个文件,将内容改为如下所示:

Cargo.toml

1
2
3
4
5
6
7
8
9
10
11
12
13
[package]
name = "hello-wasm"
version = "0.1.0"
authors = ["Your Name <you@example.com>"]
description = "A sample project with wasm-pack"
license = "MIT/Apache-2.0"
repository = "https://github.com/yourgithubusername/hello-wasm"

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"

你需要改为自己的仓库,同时 Cargo 需要通过 git 来完善 authors 部分。

最重要的是添加底下的部分。第一个部分 — [lib] — 告诉 Rust 为我们的包建立一个 cdylib 版本;在本教程中我们不会讲解它的含义。有关更多信息,请参阅 CargoRust Linkage 文档。

第二个部分是 [dependencies] 部分。在这里我们告诉 Cargo 我们需要依赖哪个版本的 wasm-bindgen ;在这个例子中,它是 0.2.z 版本的 (不是 0.3.0 或者其他版本)。

构建包

现在我们已经完成了所有配置项,开始构建吧!在命令行输入以下命令:

1
wasm-pack build --scope mynpmusername

或者

1
wasm-pack build

上面的方式会附带你的签名。

这个命令将做一系列事情 (这会花一些时间,特别是当你第一次运行 wasm-pack)。想了解详细情况,查看这篇在 Mozilla Hacks 上的文章。简单来说,wasm-pack build 将做以下几件事:

  1. 将你的 Rust 代码编译成 WebAssembly。
  2. 在编译好的 WebAssembly 代码基础上运行 wasm-bindgen,生成一个 JavaScript 文件将 WebAssembly 文件包装成一个模块以便 npm 能够识别它。
  3. 创建一个 pkg 文件夹并将 JavaScript 文件和生成的 WebAssembly 代码移到其中。
  4. 读取你的 Cargo.toml 并生成相应的 package.json
  5. 复制你的 README.md (如果有的话) 到文件夹中。

最后的结果?你在 pkg 文件夹下有了一个 npm 包。

报错了?对咯!

教程的说明就截止到上面了,但是大概率,你报错了。

MDN的文章毕竟面对的是全球的开发者,但是在国内有一个比较烦的问题就是网络的访问,在执行wasm-pack build的时候,它会这样报错:

1
2
3
4
5
6
7
8
9
10
E:\_Project\Rust\hello-wasm>wasm-pack build
[INFO]: 🎯 Checking for the Wasm target...
[INFO]: 🌀 Compiling to Wasm...
warning: no edition set: defaulting to the 2015 edition while the latest is 2024
Compiling hello-wasm v0.1.0 (E:\_Project\Rust\hello-wasm)
Finished `release` profile [optimized] target(s) in 0.99s
Error: failed to download from https://github.com/WebAssembly/binaryen/releases/download/version_117/binaryen-version_117-x86_64-windows.tar.gz
To disable `wasm-opt`, add `wasm-opt = false` to your package metadata in your `Cargo.toml`.
Caused by: failed to download from https://github.com/WebAssembly/binaryen/releases/download/version_117/binaryen-version_117-x86_64-windows.tar.gz
To disable `wasm-opt`, add `wasm-opt = false` to your package metadata in your `Cargo.toml`.

这里它说是无法下载 wasm-opt 这个东西,但是实际上我直接通过链接是能够直接下载的,网上说更改代理能够解决问题,我这里没有尝试这种方式,但是我还是将这种方法贴在这里:

如果你尝试通过cargo 去提前下载好 wasm-opt 的话,就会遇到另外一个问题,它一会给你报错无法找到c++编译器,但是实际上我的电脑里面安装了vs2019,所以说实际上根本不应该存在这个问题。当我将vs2019的cl.exe添加到环境变量之后,它又会报错说无法找到clang,之后我又去llvm的官网下载了clang,但是它又报错说c++ 'algorithm' file not found,这个问题我解决不了,暂时也不知道是为什么,这让我浪费了很多时间。

对于新学者来说,最好的办法就是按照它的提示的后者,将wasm-opt = false添加到你的Cargo.toml文件中,这样就可以跳过这个步骤,直接编译你的WebAssembly模块。wasm-opt 是一个优化工具,它会对你的WebAssembly模块进行优化,但是实际上,这个优化工具并不是必须的,你可以直接跳过这个步骤,直接编译你的WebAssembly模块。

对了,还有一个问题,就是它虽然叫你需要在 package metadata 中添加wasm-opt = false,但是实际上,你并不知道在哪里添加,需要翻阅官方文档:Cargo.toml Configuration - Hello wasm-pack!

Cargo.toml 配置

wasm-pack 可以通过 Cargo.toml 中的 package.metadata.wasm-pack 键进行配置。 每个选项都有一个默认值,不是必需的。

共有三个配置文件:devprofilingrelease。 这些对应于传递给 wasm-pack build--dev--profiling--release 标志。

可用的配置选项及其默认值如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
[package.metadata.wasm-pack.profile.dev]
# Should `wasm-opt` be used to further optimize the wasm binary generated after
# the Rust compiler has finished? Using `wasm-opt` can often further decrease
# binary size or do clever tricks that haven't made their way into LLVM yet.
#
# Configuration is set to `false` by default for the dev profile, but it can
# be set to an array of strings which are explicit arguments to pass to
# `wasm-opt`. For example `['-Os']` would optimize for size while `['-O4']`
# would execute very expensive optimizations passes
wasm-opt = ['-O']

[package.metadata.wasm-pack.profile.dev.wasm-bindgen]
# Should we enable wasm-bindgen's debug assertions in its generated JS glue?
debug-js-glue = true
# Should wasm-bindgen demangle the symbols in the "name" custom section?
demangle-name-section = true
# Should we emit the DWARF debug info custom sections?
dwarf-debug-info = false

[package.metadata.wasm-pack.profile.profiling]
wasm-opt = ['-O']

[package.metadata.wasm-pack.profile.profiling.wasm-bindgen]
debug-js-glue = false
demangle-name-section = true
dwarf-debug-info = false

# `wasm-opt` is on by default in for the release profile, but it can be
# disabled by setting it to `false`
[package.metadata.wasm-pack.profile.release]
wasm-opt = false

[package.metadata.wasm-pack.profile.release.wasm-bindgen]
debug-js-glue = false
demangle-name-section = true
dwarf-debug-info = false

通过翻阅文档,我们知道,我们需要在package.metadata.wasm-pack.profile.release中添加wasm-opt = false,这样就可以跳过这个步骤了。 修改后的Cargo.toml文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[package]
name = "hello-wasm"
version = "0.1.0"
authors = ["Your Name <you@example.com>"]
description = "A sample project with wasm-pack"
license = "MIT/Apache-2.0"
repository = "https://github.com/yourgithubusername/hello-wasm"

[package.metadata.wasm-pack.profile.release]
wasm-opt = false

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"

对代码体积的一些说明

如果你检查生成的 WebAssembly 文件体积,它可能有几百 kB。我们没有让 Rust 去压缩生成的代码,从而大大减少生成包的体积。这和本次教程主题无关,但如果你想了解更多,查看 Rust WebAssembly 工作组文档上关于 减少 .wasm 体积 的说明。

把我们的包发布到 npm

把我们的新包发布到 npm registry:

1
2
cd pkg
npm publish --access=public

我们现在有了一个 npm 包,使用 Rust 编写,但已经被编译为 WebAssembly 了。 现在这个包已经可以被 JavaScript 使用了,而且使用它完全不需要用户安装 Rust;包中的代码是 WebAssembly 代码,而不是 Rust 源码!

在网站上使用我们的包

让我们建立一个使用我们包的网站!人们通过各种打包工具使用 npm 包,在本教程中,我们将使用 vite。它比其他某些打包工具要更为的便捷,但是你可以使用任何你喜欢的工具。

从这里开始,将会和MDN的教程有所不同,因为我没有使用webpack,而是使用vite,所以说这里将会给出使用vite的教程。

让我们离开pkg目录,并创建一个新目录site,尝试以下操作:

1
2
3
cd ../..
mkdir site
cd site

创建一个新文件 package.json,然后输入如下代码:

1
2
3
4
5
6
7
8
9
10
11
{
"scripts": {
"dev": "vite",
},
"dependencies": {
"@mynpmusername/hello-wasm": "^0.1.0",
"vite": "^2.6.0",
"vite-plugin-top-level-await": "^1.5.0",
"vite-plugin-wasm": "^3.4.1"
}
}

如果上面你并没有将自己的包发布到npm上,那么你需要手动将上一步生成的pkg文件夹拷贝到site文件夹下,然后在package.json中将dependencies中的@mynpmusername/hello-wasm改为file:./pkg

这样它将会从本地的pkg文件夹中引入你的WebAssembly模块。

如果上面你将自己的包发布到npm上了,那么请注意,你需要在依赖项部分的 @ 之后填写自己的用户名。

接下来,我们需要配置 Vite 来使用 WebAssembly。创建一个新文件 vite.config.js,并输入如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { defineConfig } from 'vite'
import wasm from 'vite-plugin-wasm'
import topLevelAwait from 'vite-plugin-top-level-await'

export default defineConfig({
plugins: [
wasm(),
topLevelAwait()
],
optimizeDeps: {
exclude: ['hello_wasm'],
}
})

这里的optimizeDeps是为了解决一个问题,就是vite会将你的WebAssembly模块当作一个模块来处理,但是实际上它并不是一个模块,所以说我们需要将它排除在外,防止vite对它进行处理,使得模块找不到对应的文件。

vite-plugin-wasm 是一个 Vite 插件,它会帮助你加载 WebAssembly 模块。vite-plugin-top-level-await 是一个 Vite 插件,它会帮助你使用顶层 await。

接下来,我们需要一个 HTML 文件。创建一个index.html并写入如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>hello-wasm example</title>
</head>
<body>
hello-wasm
<button id="testButton">Test Button</button>
<script type="module" src="./index.js"></script>
</body>
</html>

需要注意的是,MDN 里面使用的是<script src="./index.js"></script>,但是实际上,vite是不支持这种方式的,你需要使用<script type="module" src="./index.js"></script>这种方式来引入你的js文件。

这样的话,你可以在index.js中直接使用import {greet} from '@mynpmusername/hello-wasm'来引入你的WebAssembly模块,而不需要额外的处理。

现在我们来写index.js文件:

1
2
3
4
5
6
7
8
9
import * as wasm from "hello-wasm";

wasm.greet("WebAssembly");
//debug
console.debug("loaded");
document.querySelector("#testButton").addEventListener("click", () => {
console.debug("clicked button");
wasm.greet("WebAssembly");
});

加载后,它将从该模块调用greet函数,并传入字符串“WebAssembly”参数。注意这里看上去没有什么特别的,但是我们正在调用 Rust 代码!就 JavaScript 代码所知,这只是一个普通模块。

我们已经完成了所有的文件!让我们试一下:

1
2
npm install
npm run dev

这将启动一个 Web 服务器。访问 vite 给出的链接,比如,http://localhost:5178/ 你应该会在屏幕上看到一个内容为 Hello, WebAssembly! 的警告框。点击按钮时也会看到这个警告框。

我们已经成功地从 JavaScript 调用了 Rust,并从 Rust 调用了 JavaScript。

结论

本教程到此结束。希望你觉得它有用。

非常感谢来自 Rust 和 WebAssembly 社区的所有人,他们的工作使得这一切成为可能。 同样也非常感谢这位作者,他的文章非常通俗易懂,且提供了github上的代码仓库,让我们可以直接下载代码进行测试: 我使用 vue3 + WebAssembly 做了个文件校验网站,性能提升600%_vue webassembly-CSDN博客 jethroHuang/fast_hash

它的这里面还使用了worker来进一步提高性能,这里就不再赘述了,有兴趣的可以去看看。关于worker的内容,可以参考这篇文章:Web Workers API - Web APIs | MDN


Reference