本文图片来自网络,参考链接在文章底部。
新一代web标准
WebAssembly (以下简称 wasm) 是一个底层字节码标准。
WASM 让你使用更多的编程语言来写程序(比如 C, C++, Rust 及其他), 然后编译成WebAssembly字节码。
W3C在2019年12月5日宣布,WebAssembly核心规范成为一种正式的Web标准,这是继HTML、CSS、JS以来的第四大Web标准。
WebAssembly 的到来扩展了仅仅用开放的 Web 平台技术就可以实现的应用程序的范围。在当今机器学习和人工智能越来越普遍的世界中,重要的是在不损害用户安全性的情况下在 Web 上运行高性能程序。” ——W3C 项目负责人 Philippe LeHégaret 。
高性能的秘密
先回顾一下浏览器中JS在V8引擎上的执行过程:
图中的每一个颜色条都代表了不同的任务:
- Parsing——表示把源代码变成解释器可以运行的代码所花的时间;
- Compiling + optimizing——表示基线编译器和优化编译器花的时间。一些优化编译器的工作并不在主线程运行,不包含在这里。
- Re-optimizing——当 JIT 发现优化假设错误,丢弃优化代码所花的时间。包括重优化的时间、抛弃并返回到基线编译器的时间。
- Execution——执行代码的时间
- Garbage collection——垃圾回收,清理内存的时间
再来看看WASM模块的执行过程
可以很明显的看到,执行WASM模块只需:获取,然后编译为机器码,再执行
1.无GC
WASM是围绕无GC语言支持的,也就是说,这些语言不需要GC,所以他们不需要任何复杂的运行时去跟踪栈内存并进行垃圾回收,这带来的性能提升是巨大的。
2.解析
JS到达浏览器时,先被解析成AST(抽象语法树),AST是一种中间代码,解析过后的AST再经过编译成为机器码。
而 WebAssembly 则不需要这种转换,因为它本身就是中间代码,浏览器要做的只是解码。
3.文件获取
WebAssembly 比 JavaScript 的压缩率更高,所以文件获取也更快。即便通过压缩算法可以显著地减小 JavaScript 的包大小,但是压缩后的 WebAssembly 的二进制代码依然更小。(注:随着前端工程化的完善,这带来的收益有时可忽略不计)
4.编译优化
JavaScript是在代码执行阶段编译的,因为它是弱类型语言,当变量类型发生变化时,同样的代码会被编译成不同的版本。
而WASM支持的语言都是静态类型语言,也就是说,编译器优化代码前,不需要运行代码以获取变量类型,也不用做不同版本的编译。
实际上,WASM的大部分优化在编译阶段已经完成了。
我们知道,有时JIT会反复进行"抛弃优化代码 -> 重优化"的过程,比如当循环中发现本次循环所使用的变量类型和上次循环的类型不一样,或者原型链中插入了新的函数,都会使 JIT 抛弃已优化的代码。这个过程叫做重优化; 重优化会造成额外开销。同样的,由于WASM的静态类型特点,它没有重优化特点。
5.执行效率
虽然自己也可以写出高效率的JS代码,但前提是得非常了解JIT的优化机制
而JIT也会针对不同浏览器做不同的优化,内部实现很复杂。
WASM就是为了编译器设计的,这就使得它可以提供更加理性的指令给机器。
在执行效率方面,不同语言、不同代码有不同的效果,一般来讲,执行效率最少提升10%,最多可提升几倍。
WASM在浏览器中的使用场景
WASM主要用于CPU密集场景(高性能计算),我根据自己的了解列举了一些重度使用WASM的web应用,大家看看是不是都很熟悉呢:
- 图片/视频编辑。(Clipchamp、Mastershot)
- 游戏:需要快速打开的小游戏AAA 级,资源量很大的游戏。(egret白鹭引擎、unity Web、Unreal Engine 4 web)
- 实时合作编辑(Figma)
- 音乐播放器(网易云音乐Web等)
- 音视频解码(各直播平台Web端、Bilibili、爱奇艺)
- VR和虚拟现实CAD (微信AR)
- 模拟/仿真平台(AutoCAD、Google Earth)。
- 加密解密(各加密货币Web端支付计算椭圆签名、JSVMP)
- 开发者工具:编辑器,编译器,调试器…(vim web、wasi-fs-access、SQLlite web)
- 人工智能(TensorFlow.js)
- 原生应用迁移的Web版本(adobe web产品、微信web端、飞书)。
- 代替JavaScript的web框架(Yew、Blazor、Tokamak、Prism)
开始使用WASM
使用方式一:浏览器现在已经原生支持使用fetch或XMLhttpRequst加载WASM模块:
fetch('module.wasm').then(res=>{
res.arrayBuffer()
}).then(bytes=>{
WebAssembly.instantiate(bytes, importObject)
}).then(wasm=>{
// Do something with the compiled results!
})
request = new XMLHttpRequest();
request.open('GET', 'simple.wasm');
request.responseType = 'arraybuffer';
request.send();
request.onload = function() {
var bytes = request.response; WebAssembly.instantiate(bytes,importObject).then(results => {
results.instance.exports.exported_func();
});
};
第二种使用方式是使用WASM编译生成的JS文件异步加载。(详见下文)
示例:前端计算MD5
背景
网盘等软件的”秒传“手段,是通过客户端从文件中获取一个特征值,然后和服务器上保存的所有数据特征值表查找,如果已存在,则无需再上传数据。计算文件的MD5就是一种常用的获取特征值的手段。
接下来构建一个简单的计算MD5的项目,对比JS和Rust编写的WASM模块的计算速度。
项目搭建
- 使用Vite+Vue3快速构建一个项目模板:
npm init vite@latest md5-demo -- --template vue
// 或
yarn create vite md5-demo --template vue
- 安装依赖和SparkMD5
npm install
npm install SparkMD5
//或
yarn
yarn add SparkMD5
修改App.vue:
<template>
<div>
<input type="file" ref="input" />
<button @click="jsStart">JS-sparkMD5计算</button>
<button @click="RustStart">Rust-md5计算</button>
</div>
</template>
<script setup lang="ts">
import { ref } from "@vue/reactivity";
import { onMounted } from "@vue/runtime-dom";
import SparkMD5 from "spark-md5";
import init, { RustMD5 } from "wasm";
const input = ref<{ files: Array<Blob> }>(null);
let wasm = null;
onMounted(async () => {
wasm = await init();
});
const jsStart = () => {
const file = input.value?.files[0];
if (!file) return;
const reader = new FileReader();
reader.readAsBinaryString(file);
reader.onload = (e) => {
console.time("JS计算md5");
const result = SparkMD5.hash(e.target?.result as string);
console.timeEnd("JS计算md5");
console.log(result);
};
};
const RustStart = () => {
//todo
};
</script>
代码很简单,通过input上传文件,我们提供两个按钮,分别用sparkMD5和WASM模块计算input上传的文件的md5,并使用console.time记录计算时间。
Rust编写WASM模块
关于Rust,有兴趣的同学可以去官网或MDN了解,这是一个新生代系统级开发语言,目前也在前端工程化发挥着重大作用,推荐大家学习。本示例将使用Rust编写。
安装Rust
前往 Install Rust 页面并跟随指示安装 Rust。
wasm-pack
要构建我们的包,我们需要一个额外工具 wasm-pack。它会帮助我们把我们的代码编译成 WebAssembly 并制造出正确的 npm 包。使用下面的命令可以下载并安装它:
cargo install wasm-pack
创建模块
//在项目根目录下:
cargo new --lib wasm
cd wasm-md5
修改Cargo.toml,引入wasm-bindgen和md5包:
[package]
name = "wasm"
version = "0.1.0"
edition = "2018"
[lib]
crate-type = ["cdylib", "rlib"]
[package.metadata]
wasm-opt = false
[dependencies]
md5 = "0.7.0"
wasm-bindgen = "0.2"
在lib.rs中编写我们的函数:
use md5;
use wasm_bindgen::*;
#[wasm_bindgen(js_name = RustMD5)]
pub fn hasher(data: &str) -> String {
let digest = md5::compute(data);
let res = format!("{:x}",digest);
return res
}
这段代码很好理解,[wasm_bindgen]代表修饰的函数将被输出到wasm模块中;js_name的修饰可以重命名在wasm模块中的函数;在这个函数中,我们接收一段string,然后计算它的MD5,返回一个string结果。
最后,我们进行编译:
wasm-pack build --target web
引入模块
按照最开始的介绍,我们可以使用fetch或者ajax获取模块。其实,既然编译后的文件有ES6 module的JS文件,我们完全可以把它放进node_modules中作为我们自己的模块的一部分:
这里,我们使用更简单的方法:使用vite插件vite-plugin-wasm-pack:
yarn add vite-plugin-wasm-pack
这个插件做的事情很简单:指定wasm模块目录,每次编译后,它自动将编译结果link到node_modules中。
vite.config.js:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import wasmPack from 'vite-plugin-wasm-pack'
export default defineConfig({
plugins: [vue(),wasmPack('./wasm')]
})
为了编译wasm模块方便,添加命令:
package.json:
{
"scripts": {
"wasm": "wasm-pack build ./wasm --target web"
},
}
最后,在App.vue中引入模块:
import init,{RustMD5} from 'wasm'
注意,用以上方法编译的wasm模块自带一个init选项,在init异步完成后,才可以使用wasm中的函数:
init().then(wasm=>{
wasm.RustMD5(str)
})
App.vue完整代码:
<template>
<div>
<input type="file" ref="input" />
<button @click="jsStart">JS-sparkMD5计算</button>
<button @click="RustStart">Rust-md5计算</button>
</div>
</template>
<script setup lang="ts">
import { ref } from "@vue/reactivity";
import { onMounted } from "@vue/runtime-dom";
import SparkMD5 from "spark-md5";
import init, { RustMD5 } from "wasm";
const input = ref<{ files: Array<Blob> } | null>(null);
let wasm = null;
onMounted(async () => {
wasm = await init();
});
const jsStart = () => {
const file = input.value?.files[0];
if (!file) return;
const reader = new FileReader();
reader.readAsBinaryString(file);
reader.onload = (e) => {
console.time("JS计算md5");
const result = SparkMD5.hash(e.target?.result as string);
console.timeEnd("JS计算md5");
console.log(result);
};
};
const RustStart = () => {
const file = input.value?.files[0];
if (!file) return;
const reader = new FileReader();
reader.readAsBinaryString(file);
reader.onload = (e) => {
console.time("rust计算md5");
const result = RustMD5(e.target?.result as string);
console.timeEnd("rust计算md5");
console.log(result);
};
wasm.RustMD5;
};
</script>
运行项目
yarn dev
input上传一个100M的文件,对比一下两种方法计算速度,你将可以看到,JS计算速度和WASM差距在3到4倍,十分显著(在我的电脑上,JS计算速度在7s左右,而WASM只需要2s)。
当然,实际的秒传实现中,对于大文件,会使用分割+多线程(worker)来计算,但是同样的,WASM模块也可以利用这些手段进行优化。所以不论采取哪种手段,只要在计算上遇到瓶颈,WASM的提升都是十分巨大的。