WASM初探

Share

前言

最近开发上线了xhair.pro, 其核心的数据是通过爬虫下载.dem文件并使用一些开源的解析库如demoinfocs-golang,demoparser解析获取最终的数据

bilibili

在本地下载解析上传可以随意配置环境,但浏览器无法直接运行Golang或Rust的代码。想要将解析的能力制作成web应用,需要使用WebAssembly技术。

WASM

WebAssembly 是一种新的编码方式,可以在现代的 Web 浏览器中运行——它是一种低级的类汇编语言,具有紧凑的二进制格式,可以接近原生的性能运行,并为诸如 C/C++、C# 和 Rust 等语言提供编译目标,以便它们可以在 Web 上运行。它也被设计为可以与 JavaScript 共存,允许两者一起工作。

我们可以把Rust、Go等语言编译成.wasm后缀的二进制文件,浏览器可以通过WebAssembly的API加载该模块,并调用相关能力

实践

不同编程语言需要通过不同的方式编译至wasm,我尝试了Go与Rust。

Golang实践

  1. 首先需要配置Go的环境 go 安装
  2. 初始化项目
mkdir wasm-example
cd wasm-example
go mod init wasm-example
  1. 编写代码

创建main.go文件,与普通go代码区别在于需要引用"syscall/js"包,示例代码如下

package main

import (
	"fmt"
	"syscall/js"
	dem "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs"
	events "github.com/markus-wa/demoinfocs-golang/v4/pkg/demoinfocs/events"
)

func main() {
	c := make(chan struct{}, 0)
	dem.DefaultParserConfig = dem.ParserConfig{
		MsgQueueBufferSize: msgQueueBufferSize,
	}
    // 注册后,将下面的parse函数全局注册,浏览器可以调用parseDemo函数方法
	js.Global().Set("parseDemo", js.FuncOf(parse))
	fmt.Println("WASM Go Initialized")
	<-c
}

func parse(){
    // 调用demoinfocs包解析数据
    ......
}

  1. 编译至wasm

需要设置环境变量GOOS=js GOARCH=wasm

GOOS=js GOARCH=wasm go build -o main.wasm main.go
  1. 前端调用
  • 浏览器需先加载wasm_exec.js

所有编译至wasm的Go应用通用,代码可见 wasm_exec.js

  • 加载wasm模块
// wasm_exec.js内声明的Go类,在此实例化
const go = new Go();
// 调用WebAssembly Api加载wasm模块
WebAssembly.instantiateStreaming(
	fetch("http://example.com/main.wasm"),
	go.importObject,
)
	.then((result) => {
		console.log("WASM loaded");
		go.run(result.instance);
	})
	.catch((err) => {
		console.error("WASM load failed:", err);
	});

// parseDemo函数已在全局注册,可以直接调用window.parseDemo()
wasm-example/
├── go.mod (约为package.json)
├── main.go (约为index.js)
├── main.wasm (编译产物,浏览器下载导入该模块)
├── wasm_exec.js (js和Go代码的桥梁,需加载执行后才可导入wasm模块)
├── index.html (前端代码)

Rust实践

类似Go,借助wasm_bindgenwasm-pack

简要描述下~

  1. 安装Rust环境
  2. 使用cargo(类似npm)创建项目并安装依赖
  3. 编写rust代码

导入wasm_bindgen并在需要的函数上添加#[wasm_bindgen]

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn hello() -> String {
    "Hello, WebAssembly!".to_string()
}
  1. 编译产物
wasm-pack build --target web --release
  1. 前端使用

Go使用的wasm_exec.js代码是固定的,需要手动处理一些绑定,而Rust则是每次生成,因此使用更便捷一些

<script type="module">
    import init, { hello } from './pkg/wasm_example.js';
    async function run() {
        await init();
        hello();
    }
    run();
</script>

效果

我开发的工具部署在 https://www.xhair.pro/zh-CN/tools/demo

xhair.pro

优化

上线后我注意到在调用parse函数解析.dem文件时Loading动画不正常同时鼠标无法与任何内容交互

可以将解析动作放入web worker中执行

以下示例worker.js,加载了wasm模块,并执行wasm函数并与主线程通信

self.onmessage = async (e) => {
	if (e.data.type === "loadWasm") {
		try {
			await import("/demoparser.js");
			await self.wasm_bindgen("/demoparser.wasm");
			self.postMessage({ type: "wasmLoaded", success: true });
		} catch (error) {
			self.postMessage({ type: "wasmError", error: error.message });
		}
	} else if (e.data.type === "callFunction") {
		try {
			const { functionName, args = [] } = e.data.data;
			const result = self.wasm_bindgen[functionName](...args);
			self.postMessage({ type: "functionResult", functionName, result });
		} catch (error) {
			self.postMessage({
				type: "functionError",
				functionName: e.data.functionName,
				error: error.message,
			});
		}
	}
};

需要一个Context Provider用于初始化worker并传递到子组件

export function WasmWorkerProvider({ children }: { children: ReactNode }) {
  const workerRef = useRef<Worker>(null);

  useEffect(() => {
    workerRef.current = new Worker("/wasmworker.js");
    workerRef.current.postMessage({ type: "loadWasm" });

    return () => workerRef.current?.terminate();
  }, []);

  return (
    <WasmWorkerContext.Provider
      value={{
        callFunction: (data) =>
          new Promise((resolve) => {
            workerRef.current.postMessage({ type: "callFunction", data });
            workerRef.current.onmessage = (e) => resolve(e.data.result);
          }),
      }}
    >
      {children}
    </WasmWorkerContext.Provider>
  );
}

使用wasm函数,例如

// context获取
const { callFunction } = useWasmWorker();
const header = await callFunction({
	functionName: "parseHeader",
	args: [data],
});

需要注意的是,在worker环境中只有self没有window,所以可能需要修改部分已有代码

// rust wasm_bindgen自动生成的
window.wasm_bindgen = Object.assign(__wbg_init, { initSync }, __exports);

// 修改为
self.wasm_bindgen = Object.assign(__wbg_init, { initSync }, __exports);

总结

wasm可以在浏览器运行Go、Rust等代码,虽然有些性能损耗,但对于我的应用场景,从一个几百M的文件中解析数据,依然还是会比上传文件至服务端计算快得多。

在Next.js中使用worker和wasm以及Golang和Rust编译至wasm运行都没找到什么优质的文档,也许是对于大多数场景直接请求API让服务端执行代码更常见吧。

Read more

家用服务器公网访问方案

前言 因为ipv4地址数量受限,家庭宽带几乎都没有公网IP。在局域网内可以使用类似192.168.0.100这样的内网IP进行访问,但是想要在外访问或是提供给朋友使用是不行的。 方案一:内网穿透 可以使用具备公网IP的服务器进行中转,用户访问具备公网IP的服务器,该公网服务器再与家庭服务器通信,最终用户访问到家庭内网。 方案对比 方案 价格 优缺点 使用免费服务如ngrok、cloudflare tunnel 无 速度受限,部分功能需要收费如绑定域名 购买云服务器搭建 中 价格高,需要部署维护 购买FRP服务如sakura frp 低 有流量和带宽限制 个人建议 临时需求可以使用ngrok,例如给朋友看正在开发的网站 长期需求建议购买FRP服务,服务器安装frp软件后,可以通过网页配置隧道如下图,可以使用香港的服务器省去域名的备案,但不太适合云盘等大流量应用。 方案二:DDNS 内网穿透需要付费,并且有带宽和流量限制,额外的一个中转服务器也会影响访问速度。 好在ipv6已经逐渐普及,若服务端和客户端同时支持ipv6网络,

By pureink

前端表单开发经验

前言 表单是前端开发中常见的场景,注册登录、调查问卷,乃至租赁一个云服务器都是在填写表单。 本文根据我的表单开发经验分享表单开发中,为什么需要表单库进行辅助。 原生表单 前端技术上,表单对应form标签,MDN有详细的介绍。 以一个注册表单为例,我们需要如下标签 这离用户体验还很远,想要完善它的功能,都要依靠JS,原生表单有很多能力缺口,例如: 1. 无法展示错误信息 为了告知用户错误的原因,常见在输入框底部进行红色文案的提示,如下图。HTML会给校验失败的元素添加CSS 伪类 :invalid,但这还远远不够。 2. 不支持自定义校验 内置支持require、pattern正则这些能力,但确认密码的校验需要和第一次填写的密码进行对比,没有相关的属性可以用。 React中的表单 尝试在React下实现一个完整的注册表单。 我们维护了两个状态对象,一个formValue代表表单值,一个error用于渲染错误信息。每一个input输入框传入value与onChange使其受控,用户输入后会修改formValue并进行校验。同时提交时也会重新进行校验,满足条

By pureink