Vite + Vue + Web Assembly
wasm 是什么
Wasm 是由一些底层语言(C、C++、Rust、Go)编写并编译出来的二进制文件,可以在浏览器中引入。可以在 JavaScript 中通过一些特定的方法调用二进制文件中的方法。
Wasm 更多被应用于编写浏览器环境下的工具库以及一些视觉领域的计算密集型的模块。
由于 Wasm 模块是由底层语言编译而来,所以 wasm 具有跨平台特性。
更多信息可以查看 掘金:WASM:起于前端,不止前端。
这次要做的
在 Vite + Vue3 的项目中内置 wasm 模块,在页面中可以调用编译后的 wasm 模块中的方法。
在特定目录下编写 wasm 的代码,在 Vite 的热更新时自动对 wasm 进行编译。
项目结构
.
├── README.md
├── index.html
├── node_modules
│ ├── ...
├── package.json
├── pnpm-lock.yaml
├── public
│ └── vite.svg
├── src
│ ├── App.vue
│ ├── assets
│ ├── components
│ ├── main.js
│ └── style.css
├── vite.config.js
Rust
安装 Rust 工具链
$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
$ cargo install wasm-pack
创建 Rust 项目
$ cargo new --lib wasm_hello
创建好的项目结构如下:
$ tree
.
├── Cargo.toml
└── src
└── lib.rs
编辑这两个文件
[package]
name = "wasm_hello"
version = "0.1.0"
edition = "2021"
[dependencies]
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = ["console"] }
[lib]
crate-type = ["cdylib"]
use wasm_bindgen::prelude::*; // 引入 wasm_bindgen
use web_sys::console; // 引入 web_sys::console
// 通过 #[wasm_bindgen] 暴露函数给 JavaScript 调用
#[wasm_bindgen]
pub fn greet() {
// 使用 console.log 输出 "Hello, World!"
console::log_1(&"Hello, World!".into());
}
编译
$ wasm-pack build --target web
[INFO]: 🎯 Checking for the Wasm target...
[INFO]: 🌀 Compiling to Wasm...
Compiling js-sys v0.3.76
Compiling web-sys v0.3.76
Compiling wasm_hello v0.1.0 (/Volumes/workspace/vue-wasm/wasm_hello)
Finished `release` profile [optimized] target(s) in 2.72s
[INFO]: ⬇️ Installing wasm-bindgen...
[INFO]: Optimizing wasm binaries with `wasm-opt`...
[INFO]: Optional fields missing from Cargo.toml: 'description', 'repository', and 'license'. These are not necessary, but recommended
[INFO]: ✨ Done in 3.02s
[INFO]: 📦 Your wasm pkg is ready to publish at /Volumes/workspace/vue-wasm/wasm_hello/pkg.
此时在项目目录下的 pkg
目录中就得到了编译后的 wasm 文件。
pkg/wasm_hello.js
pkg/wasm_hello_bg.wasm
在 Vue 项目中使用
无论是使用 Go 还是 Rust 开发 wasm 模块,都需要一个对应的 JavaScript 运行时,来引入 wasm 模块并调用。
在 Rust 中,编译时会同时生成 .wasm
和 .js
文件,这个 js 文件就是我们需要的 JavaScript 运行时。
修改 main.js
,引入 wasm。
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import wasm from '../wasm-rust/wasm_hello.js'
wasm().then((module) => {
module.greet(); // 调用 Rust 中的 greet 函数
});
createApp(App).mount('#app');
运行项目,可以在浏览器中看见输出结果。
进行 DOM 操作
使用 web_sys::{Document, Element, HtmlElement, Window};
可以在 Rust 中对 DOM 进行一系列操作。
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{Document, Element, HtmlElement, Window};
// 初始化函数,通过 wasm_bindgen 暴露给 JavaScript
#[wasm_bindgen(start)]
pub fn start() -> Result<(), JsValue> {
// 获取全局 Window 对象
let window: Window = web_sys::window().ok_or("No global `window` exists")?;
// 获取 Document 对象
let document: Document = window.document().ok_or("No global `document` exists")?;
// 创建一个 div 元素
let div: HtmlElement = document.create_element("div")?.dyn_into()?;
div.set_inner_html("Hello from Rust!");
// 创建一个按钮元素
let button: HtmlElement = document.create_element("button")?.dyn_into()?;
button.set_inner_html("Click Me");
// 将按钮的点击事件与 Rust 函数绑定
let closure = Closure::wrap(Box::new(move || {
div.set_inner_html("You clicked the button!");
}) as Box<dyn Fn()>);
button
.add_event_listener_with_callback("click", closure.as_ref().unchecked_ref())?;
closure.forget(); // 必须调用 forget 否则闭包会被回收
// 将按钮和 div 添加到 body 中
let body: HtmlElement = document.body().ok_or("No body element exists")?;
body.append_child(&div)?;
body.append_child(&button)?;
Ok(())
}
Go
安装 Go 工具链
对 Go 的版本要求是 1.11 +,因为 Go 从 1.11 开始支持 Web Assembly。
$ go version
go version go1.23.4 darwin/arm64
创建 Go 项目
$ mkdir wasm_go
$ cd wasm_go
$ go mod init wasm-go
$ vim main.go
package main
import (
"syscall/js"
)
// Go 提供给 JavaScript 的方法
func helloWorld(this js.Value, args []js.Value) interface{} {
message := "Hello, World from Go WebAssembly!"
js.Global().Get("console").Call("log", message)
return message
}
func main() {
// 导出 helloWorld 函数
js.Global().Set("helloWorld", js.FuncOf(helloWorld))
// 防止主线程退出
select {}
}
此时目录下文件结构为
$ tree
.
├── go.mod
├── main.go
└── main.wasm
编译
$ GOOS=js GOARCH=wasm go build -o main.wasm
此时我们可以得到编译后的 wasm 文件。
$ tree
.
├── go.mod
├── main.go
└── main.wasm
应用到前端项目中
整合 Go 项目到 Vue 项目中
在集成之前,我们把 go 的工作目录移动到 Vue 的工作目录下,并且改名叫 wasm
。现在我们只有 Vue 项目这一个工作目录。目录结构如下。
$ tree -L 3
.
├── README.md
├── index.html
├── node_modules
│ ├── ...
├── package.json
├── pnpm-lock.yaml
├── public
│ └── vite.svg
├── src
│ ├── App.vue
│ ├── assets
│ │ ├── go.svg
│ │ └── vue.svg
│ ├── components
│ │ └── HelloWorld.vue
│ ├── main.js
│ └── style.css
├── vite.config.js
├── wasm
│ ├── dist
│ │ └── main.wasm
│ ├── go.mod
│ ├── lib // 这三个文件会在下面介绍
│ │ ├── wasm-exec-module.js
│ │ ├── wasm-hot-reload.js
│ │ └── wasm_exec.js
│ └── main.go
└── wasm-rust
├── wasm_hello.js
└── wasm_hello_bg.wasm
14 directories, 20 files
复制运行时 JavaScript 文件
在使用 Go 编写 wasm 时,需要我们执行一条命令来创建 JavaScript 运行时文件。实际上是从 Go 的安装目录下把这个文件复制到我们的项目目录中。
$ cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./wasm/lib
处理 wasm 文件加载问题
打开 wasm_exec.js
文件,不难发现 Go 在这个运行时 JavaScript 脚本中,将一个类 Go
放到了全局对象 globalThis
中。这显然在我们日常的工程化开发中不太实用。我们首先要将这个文件改装一下,让它可以在我们的项目中更好的使用。
修改 wasm/lib/wasm_exec.js
文件,将 globalThis.Go
使用 ES6 模块导出。
// ...
const Go = globalThis.Go
export default Go
创建 wasm/lib/wasm-exec-module.js
,导出一个可以加载 wasm 模块的函数。
import Go from './wasm_exec.js';
export default async function loadWasm(wasmPath) {
const go = new Go();
// 为 Go 提供一个函数,用于接收模块
let wasmModule;
globalThis.setWasmModule = (module) => {
wasmModule = module;
};
// 加载 WebAssembly 模块
const wasm = await WebAssembly.instantiateStreaming(fetch(wasmPath), go.importObject);
go.run(wasm.instance);
// 返回接收到的模块
delete globalThis.setWasmModule; // 删除临时全局函数,避免污染
return wasmModule;
}
在加载完毕后,将全局函数删除掉,避免全局变量污染。同时将加载好的 wasm 模块返回,提供给后续步骤使用。
引入并加载 wasm 模块
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import loadWasm from '../wasm/lib/wasm-exec-module.js'
const app = createApp(App)
app.mount('#app')
async function initWasm() {
// 加载 WebAssembly 模块
const wasmModule = await loadWasm('../wasm/dist/main.wasm')
const app = createApp(App)
app.provide('wasm', wasmModule)
app.mount('#app')
}
initWasm().catch(console.error)
使用 Vite 插件实现热更新
import { exec } from 'child_process';
export default function runShellPlugin(command) {
return {
name: 'run-shell-plugin',
handleHotUpdate({ file }) {
console.log(`File changed: ${file}`);
if (file.indexOf('wasm/dist/main.wasm') > -1) {
return
}
// 执行指定的 Shell 命令
exec("cd wasm-go && GOOS=js GOARCH=wasm go build -o dist/main.wasm", (err, stdout, stderr) => {
if (err) {
console.error(`Error executing command: ${err.message}`);
return;
}
if (stderr) {
console.error(`Command stderr: ${stderr}`);
}
if (stdout) {
console.log(`Command stdout: ${stdout}`);
}
});
},
};
}
然后需要在 vite.config.js
中引入这个插件。
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import wasmHotReload from './wasm/lib/wasm-hot-reload.js'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
wasmHotReload()
],
})
这时,当我们更新 go 代码后,也会自动执行 wasm 的编译了。