WebAssembly 入门

什么是 WebAssembly?

WebAssembly(简称 Wasm)是一种基于堆栈的虚拟机的二进制指令格式。Wasm 旨在作为编程语言的可移植编译目标,支持在 Web 上部署客户端和服务器应用程序。

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

谁在使用 WebAssembly?

  1. Google:利用 WebAssembly 技术让 Gmail 运行速度更快,而他们 ChromeLab 团队制作的图片压缩工具 Squoosh 也将几种常见的图片编解码器编译成 WebAssembly,实现高效的图片压缩;
  2. Mozilla:将 WebAssembly 发展为新一代 Web 技术;
  3. Unity:利用 WebAssembly 构建高性能游戏引擎;
  4. AutoCAD:利用 WebAssembly 实现在 Web 上运行的 CAD 应用;
  5. SketchUp:利用 WebAssembly 实现在 Web 上运行的 3D 建模应用;
  6. Figma:利用 WebAssembly 在 Web 上运行的协作设计工具;
  7. Dropbox:利用 WebAssembly 构建客户端加速器;
  8. 微软:使用 WebAssembly 加速 Edge 浏览器中的 JavaScript;
  9. TensorFlow.js:使用 WebAssembly 在浏览器中运行 TensorFlow 模型;
  10. HandBrake:使用 WebAssembly 构建视频转换器以提高性能;
  11. WordPress In Browser:通过 WebAssembly,你可以在浏览器上运行这个经典的 PHP CMS 应用程序,而无需任何服务器;
  12. 网页中的计算机博物馆 站长构建了一个 WebAssembly 应用程序,允许我们在页面上加载一些古老的操作系统,非常有趣。

我如何使用它?

通过 WebAssembly - WebAssembly | MDN 文档的介绍,我们可以看到它一共有4个加载方法。其中1和2会直接返回一个生成WebAssembly实例的Promise,3和4则会返回一个 WebAssembly.Module,后者执行完之后会返回一个WebAssembly实例。从它们的名字也可以看出来:1和2是实例化的,3和4是只编译了(并未实例化)。

  1. WebAssembly.instantiate()
    用于编译和实例化 WebAssembly 代码的主要 API,返回一个 Module 及其第一个 Instance 实例。
  2. WebAssembly.instantiateStreaming()
    直接从流式底层源编译和实例化 WebAssembly 模块,返回 Module 及其第一个 Instance 实例。
  3. WebAssembly.compile()
    将 WebAssembly 二进制文件编译为 WebAssembly.Module,无需实例化。
  4. WebAssembly.compileStreaming()
    直接从流式传输底层源代码编译 WebAssembly.Module,并将其实例化为单独的步骤。

无论是从 Streaming 还是 bufferSource 初始化 Web Assembly 模块,都只是一个加载 .wasm 文件的过程,我们可以根据实际需求选择合适的加载方式。通过上述方法加载后,我们会得到一个 WebAssembly.Instance 对象。通过这个对象我们可以访问其内部的变量和函数。

一个简单的例子

通过 WebAssembly.instantiateStreaming 实例化 WebAssembly 模块

演示页面可以在这里访问 –>

index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Simple add example</title>
</head>
<body>
<script>
WebAssembly.instantiateStreaming(fetch("add.wasm")).then((obj) => {
console.log(obj.instance.exports.add(1, 2)); // "3"
});
</script>
</body>
</html>

本例中的 .wasm 文件是从 .wat 文件编译而来的。什么是 .wat 文件?WAT - WebAssembly 文本格式。

WebAssembly 具有二进制格式和文本格式。二进制格式 (.wasm) 是一种用于基于堆栈的虚拟机的紧凑二进制指令格式,旨在成为其他高级语言(如 C、C++、Rust、C#、Go、Python 等)的可移植编译目标。文本格式 (.wat) 是一种人类可读的格式,旨在帮助开发人员查看 WebAssembly 模块的源代码。文本格式还可用于编写可编译为二进制格式的代码。

add.wat
1
2
3
4
5
6
7
8
(module
(func $add (param $lhs i32) (param $rhs i32) (result i32)
local.get $lhs
local.get $rhs
i32.add
)
(export "add" (func $add))
)

我们可以通过 wabt.wat 文件编译为 .wasm 二进制文件。
更多演示可在 webassembly-examples 中找到。

如果我们以另一种方式实例化它会怎么样?

WebAssembly.instantiate()WebAssembly.instantiateStreaming() 的结果完全相同,区别在于它们接受的输入参数不同,使用 WebAssembly.instantiate() 其输入参数需要使用 ArrayBuffer。

index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Simple add example</title>
</head>
<body>
<script>
fetch("add.wasm")
.then((response) => response.arrayBuffer())
.then((bytes) => WebAssembly.instantiate(bytes))
.then((result) => console.log(result.instance.exports.add(1, 2)));
</script>
</body>
</html>

我们如何获取 WebAssembly 模块代码?

WebAssembly 二进制文件可以从多种语言(C/C++、Rust、Assembly Script、C#……)编译而来,请参阅我想要…… - WebAssembly
这里我以 C 代码编译 .wasm 为例。

安装编译工具 Emscripten

按照 Emscripten 文档 中的安装步骤进行操作。执行

bash
1
source ./emsdk_env.sh

我们可以在我们的 shell 中使用 emcc.c 文件编译为 .wasm 文件。

编写用于编译的 C 代码

我仍然使用 a+b 作为示例。此 C 文件模块仅包含一个 add 函数,该函数以两个数字作为输入并返回它们的相加之和。

add.c
1
2
3
4
5
6
#include <emscripten/emscripten.h>

EMSCRIPTEN_KEEPALIVE
double add(double number1,double number2) {
return number1 + number2;
}

有一个奇怪的 #include <emscripten/emscripten.h>,它是什么?emscripten.h 提供了一些公共的 C++ API,详细信息请参阅 emscripten.h

您可以看到我在 add 函数中添加了 EMSCRIPTEN_KEEPALIVE 宏,它告诉编译器保留它并导出它,这允许我们在使用 JavaScript 访问 WebAssembly 实例时访问它。

编译 .wasm 文件

在 shell 中执行以下命令

bash
1
emcc add.c -s WASM=1 -o add.html

上述命令参数解释如下:

  • emcc 是 Emscripten 的命令行命令
  • -s WASM=1 告诉 Emscripten 输出 wasm 文件,如果不指定此参数,则默认输出 asm.js
  • -o add.html 告诉编译器生成一个名为 add.html 的 HTML 文档来运行代码,以及用于编译和实例化 wasm 的 wasm 模块和相应的 JavaScript 粘合代码,以便 wasm 可以在所使用的 web 环境中使用

运行上述命令后,你的 WebAssembly 目录中应该还会有三个文件:

  • add.wasm 二进制 wasm 模块代码
  • add.js 包含粘合代码的 JavaScript 文件,通过它将原生 C 函数翻译成 JavaScript/wasm 代码
  • add.html 用于加载、编译和实例化 wasm 代码并在浏览器上显示 wasm 代码输出的 HTML 文件

使用 JavaScript 运行

用 http 服务器打开上面生成的add.html文件,打开页面后使用 devtools 可以发现我们编译的 .wasm 文件已经被自动加载了,这是因为我们编译的add.js已经帮我们生成了实例化 WebAssembly 所需的相关代码。

我们可以找到instantiateAsync函数,它是加载我们WebAssembly Module的关键。

add.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function instantiateAsync(binary, binaryFile, imports, callback) {
if (
!binary &&
typeof WebAssembly.instantiateStreaming == "function" &&
!isDataURI(binaryFile) &&
!ENVIRONMENT_IS_NODE &&
typeof fetch == "function"
) {
return fetch(binaryFile, { credentials: "same-origin" }).then(
(response) => {
var result = WebAssembly.instantiateStreaming(response, imports);
return result.then(callback, function (reason) {
err("wasm streaming compile failed: " + reason);
err("falling back to ArrayBuffer instantiation");
return instantiateArrayBuffer(binaryFile, imports, callback);
});
}
);
} else {
return instantiateArrayBuffer(binaryFile, imports, callback);
}
}

在这个例子中,add.js 初始化了 WebAssembly 实例,并将其放入全局模块 Module 中,因此我们可以通过 Module.asm.add 访问 C 模块中的 add 函数。

在其他应用程序中实现 WebAssembly

通过上面的 demo,我们知道可以用 JavaScript 来初始化 WebAssembly 并使用,但是有一个问题,emcc生成的js胶水代码会创建一个名为 Module的 全局变量。但是如果我:

  1. 不想使用 Module 作为变量名,因为我可能已经有了这个名字

  2. 一开始就不想加载 WebAssembly 模块

  3. 在 Node.js 环境中不需要使用 WebAssembly 模块

  4. 想简化 js 胶水代码以实现更快的加载速度怎么办?

以下是我的答案:

  1. 自定义我们的 WebAssembly 模块名称
    使用 emcc 编译时,添加 -s EXPORT_NAME="CustomModuleName" 参数,自定义模块名称

  2. 异步加载 WebAssembly 模块
    使用 emcc 编译时,使用 -o target.mjs 导出一个 ES6 JavaScript 模块,或者使用 -s MODULARIZE 对其进行模块化。-s MODULARIZE 可以与 -s EXPORT_NAME="CustomModuleName" 一起使用。加载 js 粘合代码后,可以使用 CustomModuleName() 实例化 WebAssembly。其中,CustomModuleName 函数返回一个 Promise。

  3. 当我不需要 Node.js 相关代码时
    使用 emcc 编译时,添加 -s ENVIRONMENT=web,编译后的粘合 js 代码将不包含 Node.js 相关逻辑。

  4. 精简 glue js 代码
    使用 emcc 编译时,添加 -O3 参数进行压缩,本例中 add.c 压缩效果为:add.js(10KB/53KB),add.wasm(72B/916B)。

您可以在 Emscripten FAQ 中找到更多答案。

WebAssembly + Web Worker?

当我们使用 WebAssembly 进行比较复杂的计算时,为了避免影响浏览器渲染过程,我们可以将其放入 Web Worker 中运行。
可以参考 使用 WebAssembly 和 Web Workers - SitePen

此 Demo 可在此访问

index.html
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
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Simple add example</title>
</head>
<body>
<input id="a" type="text" />
<input id="b" type="text" />
<button id="submit">Caculate</button>
<div>The Result Is <span id="result"></span></div>
<script>
if (window.Worker) {
const myWorker = new Worker("worker.js");
document.querySelector("#submit").addEventListener("click", () => {
const a = document.querySelector("#a").value;
const b = document.querySelector("#b").value;
myWorker.postMessage({
type: "excute",
params: {
functionName: "add",
arguments: [Number(a), Number(b)],
},
});
});

myWorker.onmessage = function (e) {
console.log("Message received from worker", e);
const { params, result } = e.data;
if (params.functionName === "add") {
document.querySelector("#result").innerHTML = result;
}
};
} else {
console.log("Your browser doesn't support web workers.");
}
</script>
</body>
</html>
worker.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
importScripts("add.js");
let wasmInstance = null;
const init = async () => {
wasmInstance = (await Module()).asm;
};

init();

onmessage = async function (e) {
console.log("Worker: Message received from main script");
const { type, params } = e.data;
const { functionName, arguments } = params || {};
if (type === "excute") {
if (!wasmInstance) {
await init();
}
postMessage({ params, result: wasmInstance[functionName](...arguments) });
}
};