鱼肚的博客

技术宅改变世界

使用Rust构建wasm包并发布到npm

wasm-pack-image

WebAssembly既拥有大量的前端输入(Rust、C++、Go、AssemblyScript),又拥有大量的运行时支持,可以内嵌在大量的语言中运行,也可以独立运行,可以说是编程界的(未来)最佳配角了,结合npm使用自然不在话下。

考虑到WebAssembly目前的发展度还不够成熟,为了避免踩坑,还是先尝试下最传统的使用场景:使用Rust编译wasm包,并通过npm发布,最后用于浏览器和Node.js之中。

本文中我会使用Rust构建一个npm包,并分别在浏览器和Node.js中打印出 "Hello Wasm!" 的语句。

在开始之前,需要配置下开发环境:

  • 安装Rust:https://www.rust-lang.org/tools/install
  • 安装 wasm-pack: https://rustwasm.github.io/wasm-pack/installer/
  • 安装Node.js及相关工具包

新建工程

可以使用 wasm-pack 工具快速地初始化一个 wasm 包的工程

1$ wasm-pack new hello-wasm
2[INFO]: ⬇️  Installing cargo-generate...
3🐑  Generating a new rustwasm project with name 'hello-wasm'...
4🔧   Creating project called `hello-wasm`...
5✨   Done! New project created /private/tmp/wasm/hello-wasm
6[INFO]: 🐑 Generated new project at /hello-wasm

这个命令会创建一个Rust的工程,包含如下的代码结构:

1$ cd hello-wasm
2$ tree .
3.
4├── Cargo.toml
5├── LICENSE_APACHE
6├── LICENSE_MIT
7├── README.md
8├── src
9│   ├── lib.rs
10│   └── utils.rs
11└── tests
12    └── web.rs
13
142 directories, 7 files

里面比较重要的有两个文件,一个是 Cargo.toml ,是整个项目的配置文件,类似于 package.json。

1[package]
2name = "hello-wasm"
3version = "0.1.0"
4
5[lib]
6crate-type = ["cdylib", "rlib"]
7
8[dependencies]
9wasm-bindgen = "0.2.63"
10

这里的 name 即对应于 npm 包的 name,可以按需修改。注意这里不支持 @scope,如果需要 scope,可以在下面构建步骤中添加。

另一个是 src/lib.rs ,包含着核心代码:

1use wasm_bindgen::prelude::*;
2#[wasm_bindgen]
3extern {
4    fn alert(s: &str);
5}
6
7#[wasm_bindgen]
8pub fn greet() {
9    alert("Hello, hello-wasm!");
10}

修改代码

默认生成的代码里面,通过wasm_bindgen声明了使用外部的 alert 函数,并导出了一个 greet 函数,在调用的时候会使用 alert 提示一段文本。

这就限制了它只能用在浏览器里面,因为Node.js中默认不带有全局的 alert 函数。

为了使这个包既能用于浏览器之中,也能用于Node.js之中,我需要把它修改成调用 console.log,而非 alert。

因为 console.log 不是 Rust 的内置方法,所以也需要使用 wasm_bindgen 声明,类似于 alert。

wasm_bindgen的帮助文档中给了两个使用 console.log的方法,分别如下:

第一种,使用 extern 声明

1#[wasm_bindgen]
2extern "C" {
3    #[wasm_bindgen(js_namespace = console)]
4    fn log(s: &str);
5    #[wasm_bindgen(js_namespace = console, js_name = log)]
6    fn log_u32(a: u32);
7    #[wasm_bindgen(js_namespace = console, js_name = log)]
8    fn log_many(a: &str, b: &str);
9}
10
11fn bare_bones() {
12    log("Hello from Rust!");
13    log_u32(42);
14    log_many("Logging", "many values!");
15}

这个示例中给了三个函数,分别覆盖 console.log 可接受参数的一部分子集。因为Rust是强类型的语言,并且不像TS那样可以声明 union 类型(也许可以,但是我还没学到),所以用起来不会像 JS 或 TS 那样灵活。虽然 TS 号称是强类型语言,但是和这种真正的强类型比起来还是方便太多了。

Rust中也支持泛型和可变参数列表,只是可能定义起来会比较复杂,例子中暂时没涉及到。暂时不必灰心,且慢慢了解吧。

第二种,使用web_sys库

Rust中有 crates 模块仓库,类似于 npm 仓库。其中有一个 web_sys 库就可以提供 console.log 工具。

用法如下:

1fn using_web_sys() {
2    use web_sys::console;
3    console::log_1(&"Hello using web-sys".into());
4    let js: JsValue = 4.into();
5    console::log_2(&"Logging arbitrary values looks like".into(), &js);
6}

同时需要修改Cargo.toml,添加web_sys依赖。

1[dependencies]
2wasm-bindgen = "0.2.63"
3web-sys = { version = "0.3.53", features = ['console'] }

这里,我采用了第二种用法,最新的代码如下:

1#[wasm_bindgen]
2pub fn greet() {
3    use web_sys::console;
4    console::log_1(&"Hello Wasm!".into());
5}

构建&发布

构建使用 wasm-pack 工具提供的 build 子命令即可。

1# scope 可以在构建时修改 npm 包的 name,如这里就改成了 @banyudu/hello-wasm
2# 按需调整成自己的scope,或不用scope
3$ wasm-pack build --scope banyudu --target nodejs
4[INFO]: 🎯  Checking for the Wasm target...
5[INFO]: 🌀  Compiling to Wasm...
6   Compiling proc-macro2 v1.0.28
7   Compiling unicode-xid v0.2.2
8   Compiling wasm-bindgen-shared v0.2.76
9   Compiling syn v1.0.75
10   Compiling log v0.4.14
11   Compiling cfg-if v1.0.0
12   Compiling bumpalo v3.7.0
13   Compiling lazy_static v1.4.0
14   Compiling wasm-bindgen v0.2.76
15   Compiling cfg-if v0.1.10
16   Compiling quote v1.0.9
17   Compiling wasm-bindgen-backend v0.2.76
18   Compiling wasm-bindgen-macro-support v0.2.76
19   Compiling wasm-bindgen-macro v0.2.76
20   Compiling js-sys v0.3.53
21   Compiling console_error_panic_hook v0.1.6
22   Compiling web-sys v0.3.53
23   Compiling hello-wasm v0.1.0 (/private/tmp/wasm/hello-wasm)
24warning: function is never used: `set_panic_hook`
25 --> src/utils.rs:1:8
26  |
271 | pub fn set_panic_hook() {
28  |        ^^^^^^^^^^^^^^
29  |
30  = note: `#[warn(dead_code)]` on by default
31
32warning: 1 warning emitted
33
34    Finished release [optimized] target(s) in 17.69s
35[INFO]: ⬇️  Installing wasm-bindgen...
36[INFO]: Optimizing wasm binaries with `wasm-opt`...
37[INFO]: Optional fields missing from Cargo.toml: 'description', 'repository', and 'license'. These are not necessary, but recommended
38[INFO]: ✨   Done in 18.26s
39[INFO]: 📦   Your wasm pkg is ready to publish at /private/tmp/wasm/hello-wasm/pkg.

构建完成后,再执行发布操作,因为wasm是跨平台的,不区分宿主环境,所以不用像Node.js的C++包那样把二进制文件单独提出来下载,直接放在 npm 包中即可:

1$ wasm-pack publish --access public -t nodejs --tag nodejs
2npm notice 
3npm notice 📦  @banyudu/hello-wasm@0.2.0
4npm notice === Tarball Contents === 
5npm notice 2.2kB  README.md         
6npm notice 13.0kB hello_wasm_bg.wasm
7npm notice 80B    hello_wasm.d.ts   
8npm notice 2.0kB  hello_wasm.js     
9npm notice 262B   package.json      
10npm notice === Tarball Details === 
11npm notice name:          @banyudu/hello-wasm                     
12npm notice version:       0.2.0                                   
13npm notice filename:      @banyudu/hello-wasm-0.2.0.tgz           
14npm notice package size:  7.9 kB                                  
15npm notice unpacked size: 17.5 kB                                 
16npm notice shasum:        114f7e2c5eeaeac79714a14b5165b21cbd005c0b
17npm notice integrity:     sha512-44pOZDcQE0aSu[...]FcyfvfBwgIfkQ==
18npm notice total files:   5                                       
19npm notice 
20+ @banyudu/hello-wasm@0.2.0
21[INFO]: 💥  published your package!

导入和运行

node.js

新建一个 node.js 工程,并安装@banyudu/hello-wasm@0.2.0

1npm i @banyudu/hello-wasm@0.2.0

然后新建一个 index.js,内容如下:

1require('@banyudu/hello-wasm').greet()

运行 node index.js,可以看到 Hello Wasm!的输出,说明能正常加载运行。

浏览器

在前端项目中使用 wasm 似乎略有一些复杂。

使用create-react-app新建一个React工程,并在代码中引入 @banyudu/hello-wasm。

1import "./styles.css";
2import { greet } from '@banyudu/hello-wasm'
3
4export default function App() {
5  return (
6    <div className="App">
7      <button onClick={() => greet()}>Click Me to say Hello Wasm in console</button>
8    </div>
9  );
10}

这个时候会报如下的错:

Module parse failed: magic header not detected

这是因为wasm没有被正确的加载,需要使用wasm-loader处理。

使用react-app-rewired添加自定义配置 config-overrides.js:

1const path = require('path');
2
3module.exports = function override(config, env) {
4    // Make file-loader ignore WASM files
5    const wasmExtensionRegExp = /\.wasm$/;
6    config.resolve.extensions.push('.wasm');
7    config.module.rules.forEach(rule => {
8        (rule.oneOf || []).forEach(oneOf => {
9            if (oneOf.loader && oneOf.loader.indexOf('file-loader') >= 0) {
10                oneOf.exclude.push(wasmExtensionRegExp);
11            }
12        });
13    });
14
15    // Add a dedicated loader for WASM
16    config.module.rules.push({
17        test: wasmExtensionRegExp,
18        include: path.resolve(__dirname, 'src'),
19        use: [{ loader: require.resolve('wasm-loader'), options: {} }]
20    });
21
22    return config;
23};

再运行React项目,还是有错误,现在的错误是:WebAssembly module is included in initial chunk.

这个错误相对来说好理解一点,也就是说导入wasm模块必须异步进行,将代码改成如下的形式:

1import "./styles.css";
2
3const handleClick = () => {
4  import('@banyudu/hello-wasm').then(wasm => {
5    wasm.greet()
6  })
7}
8
9export default function App() {
10  return (
11    <div className="App">
12      <button onClick={handleClick}>Click Me to say Hello Wasm in console</button>
13    </div>
14  );
15}

但是错误还没有结束,现在变成了TypeError: TextDecoder is not a constructor.

wasm-pack在构建的时候支持多种模式,有nodejs,也有web等,这个错看起来像是引用了node.js的模块导致的,所以我怀疑是wasm-pack构建的时候不能设置成node.js。

果然使用wasm-pack build --scope banyudu 重新构建之后就正常了。

前端示例代码在Github仓库中。

前端项目中使用wasm略显复杂,而且看起来wasm-pack也无法同时提供node.js和web兼容的npm包?也许以后会有更便捷的方式。

总结

以上就是一个完整的使用Rust构建wasm包,发布到npm,并在node.js和React项目中分别引用的实际过程。

通过使用体验来看,WebAssembly在Node.js中表现较好,只要是新版本Node.js,使用的时候不需要特殊配置,而前端React项目中使用比较复杂,需要修改Webpack配置,且引入的方式也必须是dynamic的。

暂时还算不上很理想,期待以后会有更好的发展。