只需Web开发的一般知识就能通过本文轻松上手WebAssembly。要通过本文的可运行代码示例尝试WebAssembly,你只需要一个编辑器、任意现代浏览器和本文随附的,带有C和Rust工具链的Docker映像。
WebAssembly已经诞生三年了。它可以在所有现代浏览器中使用,还有一些公司甚至开始勇敢地在生产环境中使用它了(说的自然是Figma)。它背后的名字如雷贯耳:Mozilla、Microsoft、Google、Apple、Intel、RedHat——它们和其他很多公司的一些最优秀的工程师一直在为WebAssembly做出贡献。人们普遍认为它是Web技术的下一次重大变革,但更主流的前端社区并不急于采用它。我们都知道HTML、CSS和JavaScript是Web的三大基础,要改造世界需要花费的时间远不止三年这么短。尤其是人们一搜索它的概念就会蹦出下面这种内容:
WebAssembly是一种用于基于栈的虚拟机的虚拟指令集架构二进制指令格式。
如果你看了后感到一头雾水,那肯定很难有兴趣继续研究下去。
这篇文章的目的是以一种更容易理解的方式来解释WebAssembly,并引导你完成一些在Web页面上使用WebAssembly的具体示例。如果你是对WebAssembly感到好奇的开发人员,却从未有过尝试的机会,那么本文会很合适你——如果你很喜欢龙的话那就更好了。
在我自己深入研究这一主题之前,我对WebAssembly的印象就是某种龙:强大、快速、危险诱人,但又神秘而致命。在我的Web技术思维导图上,WebAssembly也属于“此处有龙出没”类别:探索这些技术时请自行承担风险。
那些担心其实是没有根据的,前端开发的基石并没有被打碎。WebAssembly仍然属于客户端应用程序领域,因此它仍在你的浏览器沙箱中运行。它仍然依赖熟悉的JavaScript API。它还允许你直接提供二进制文件,从而极大扩展了可以在客户端上执行的操作的范围。
本文将介绍其工作原理,如何将代码编译为Wasm以及何时在项目中使用WebAssembly。
在WebAssembly诞生之前,JavaScript是由浏览器执行的编程语言中唯一一种全功能的。为Web编写代码的人们知道如何使用JS表达想法,并知道客户端计算机可以运行他们的代码。
编程小白也能理解以下JavaScript代码的含义,虽说它“解决”的任务没什么意义:将随机数除以2并将其添加到数字数组11088次。
function div() {
return Math.random() / 2;
}
const arr = [];
for (let i = 0; i < 11088; i++) {
arr[i] = div();
}
上面的代码是人类能读懂的,但对于通过Web接收代码的客户端计算机的CPU而言却毫无意义,可后者必须运行它。CPU理解的是机器指令,它们按照处理器生成结果所必须的(相当平淡的)步骤序列来编码。 要运行我们这一小段代码,我的CPU(Intel x86-64)需要516条指令。这些指令以汇编语言(机器语言的文字表示)显示时是下面的样子。指令名称是很难懂的,要理解它们,你需要一本处理器随附的厚厚手册。
x86_64汇编的一些指令
在每个时钟周期(2GHz表示每秒20亿个周期),处理器将尝试获取一个或多个指令并执行它们。通常,有许多指令是同时传送的(称为指令级并行性)。
为了尽可能快速地运行你的代码,处理器采用了一些技巧,例如流水线、分支预测、推测执行、预取等。处理器有复杂的缓存系统,以尽快获取指令数据(以及指令本身)。从主内存中获取数据比从缓存中获取数据要慢几十倍。
不同的CPU实现了不同的指令集架构(ISA),因此PC中的CPU(很可能基于Intel x86)将无法理解智能手机中CPU(最可能是某种ARM架构)的机器代码。
好消息是——如果你为Web编写代码,则不必介意处理器架构之间的差异。现代浏览器是高效的编译器,可以将你的代码愉快地转换为客户端计算机的CPU可以理解的内容。
为了了解WebAssembly如何工作,我们不得不谈论一下编译器。编译器的工作是获取人类可读的源代码(JavaScript、C、Rust,诸如此类),并将其转变为一组指令,供目标处理器理解。在发出机器代码之前,编译器首先将你的代码转换为中间表示(IR),即对程序进行精确的“重写”,而这种重写与源语言和目标语言无关。
编译器将查看IR,研究如何优化它,可能会因此生成另一个IR,然后生成下一个IR,直到它确定无法做进一步的优化为止。因此,你在编辑器中编写的代码可能与计算机将执行的代码完全不同。
为了具体说明,以下是一些C代码的加法和乘法运算。
#include <stdio.h>
int main()
{
int result = 0;
for (int i = 0; i < 100; ++i) {
if (i > 10) {
result += i * 2;
} else {
result += i * 11;
}
}
printf("%d\n", result);
return 0;
}
下面是由编译器生成的,LLVM IR格式的内部表示形式,这种格式很流行。
define hidden i32 @main() local_unnamed_addr #0 {
entry:
%0 = tail call i32 (i8*, ...) @iprintf(…), i32 10395)
ret i32 0
}
这里的重点是,在执行优化时,编译器就会得出计算的结果,而不是让处理器在运行时进行数学运算。因此,i32 10395部分正好是上面的C代码最终将输出的数字。 编译器有很多魔术来加速:避免在运行时执行“效率低下”的人工代码,并用更优化的机器版本代替。
编译器的工作机制
大多数现代编译器还有一个“中端”,可在后端和前端之间执行优化。
编译器管道是一头复杂的怪兽,但我们可以将其拆分为两部分:前端和后端。编译器前端解析源代码,对其进行分析,然后转换为IR;然后编译器后端针对目标优化IR,并生成目标代码。
前端和后端
现在我们回到Web上。
如果我们可以有一种所有浏览器都可以理解的中间表示会怎么样呢?
然后,我们可以将其用作程序编译的目标,而不必担心与客户端系统的兼容性。我们还可以使用任何语言编写程序,不再只限于JavaScript。浏览器将获取我们代码的中间表示,并上演那些后端魔术:将IR转换为客户端架构的机器指令。
这就是WebAssembly的全部目的!
为了实现用单一格式表示任何语言编写代码的梦想,WebAssembly的开发人员必须做出一些战略性的架构选择。
为了使浏览器能够在最短的时间内获取代码,格式必须紧凑。二进制是你可以获得的最紧凑的文件。
为了使编译高效,我们需要在不牺牲可移植性的情况下尽可能接近机器指令。由于所有指令集架构都依赖于硬件,并且不可能针对能运行浏览器的所有系统进行定制,因此WebAssembly的创建者选择了虚拟ISA:一组用于抽象机器的指令。它不对应任何实际的CPU,但可以用软件有效地处理。
虚拟ISA非常底层,足以轻松转换为特定的机器指令。与实际的CPU不同,用于WebAssembly的抽象机不依赖寄存器——现代处理器在操作数据之前放置数据的位置。相反,它使用栈数据结构:例如,一条add指令将从栈中弹出两个最高的数字,将它们加在一起,然后将结果推回栈顶部。
现在,当我们终于了解“基于栈的虚拟机的虚拟指令集架构和二进制格式”的含义时,就该释放WebAssembly的能量了!
我们将实现一个简单的算法来绘制一条称为龙曲线的简单分形曲线。这里最重要的不是源码:我们将向你展示创建WebAssembly模块,并在浏览器中运行它所需要的操作。
这里不会直接使用emscripten这类好用的高级工具,而是直接使用一个Clang编译器,带有LLVM WebAssembly后端。
最后,我们的浏览器将能够绘制以下图片:
龙曲线和折点
我们将从画布的起点画一条线,然后左右交替转向,以实现所需的分形。
程序的目标是生成一个坐标数组,供我们的直线使用。将其变成图片是JavaScript的工作。负责生成数组的代码是用老字号的C语言编写的。
不用担心,你用不着花费几小时来设置开发环境,因为我们已经将你可能用到的所有工具烘焙到了一个Docker映像中。你在计算机上唯一需要的就是Docker本身,因此,如果你以前从未使用过它——现在是时候安装它了,只需按照对应你操作系统的指南操作即可。
提示:命令行示例假定你使用的是Linux或Mac。要在Windows上运行,你可以使用WSL(建议升级到WSL2)或更改语法以支持Power Shell:使用反引号代替\来换行,并使用${pwd}:/temp代替$(pwd):$(pwd)。
启动你的终端并创建一个文件夹,在其中放置示例:
mkdir dragon-curve-llvm && cd dragon-curve-llvm
touch dragon-curve.c
现在打开文本编辑器,并将以下代码放入新创建的文件中:
// dragon-curve-llvm/dragon-curve.c
#ifndef DRAGON_CURVE
#define DRAGON_CURVE
// Helper function for generating x,y coordinates from "turns"
int sign(int x) { return (x % 2) * (2 - (x % 4)); }
// Helper function to generate "turns"
// Adapted from https://en.wikipedia.org/wiki/Dragon_curve#[Un]folding_the_dragon
int getTurn(int n)
{
int turnFlag = (((n + 1) & -(n + 1)) << 1) & (n + 1);
return turnFlag != 0 ? -1 : 1; // -1 for left turn, 1 for right
}
// fills source with x and y points [x0, y0, x1, y1,...]
// first argument is a pointer to the first element of the array
// that will be provided at runtime.
void dragonCurve(double source[], int size, int len, double x0, double y0)
{
int angle = 0;
double x = x0, y = y0;
for (int i = 0; i < size; i++)
{
int turn = getTurn(i);
angle = angle + turn;
x = x - len * sign(angle);
y = y - len * sign(angle + 1);
source[2 * i] = x;
source[2 * i + 1] = y;
}
}
#endif
现在我们需要使用LLVM的Clang及其WebAssembly后端和链接器将其编译为WebAssembly。运行下面的命令让Docker容器来处理。这只是对带有一组标志的clang二进制文件的调用。
docker run --rm -v $(pwd):$(pwd) -w $(pwd) zloymult/wasm-build-kit \
clang --target=wasm32 -O3 -nostdlib -Wl,--no-entry -Wl,--export-all -o dragon-curve.wasm dragon-curve.c
结果,你将看到一个dragon-curve.wasm文件出现在文件夹中。它是一个包含我们程序中所有530字节的二进制文件!你可以像这样检查它:
docker run --rm -v $(pwd):$(pwd) -w $(pwd) zloymult/wasm-build-kit \
wasm-objdump dragon-curve.wasm -s
wasm-objdump dragon-curve.wasm
可以使用WebAssembly工具链中一个叫做Bynarien的出色工具来优化二进制文件的体积。
docker run --rm -v $(pwd):$(pwd) -w $(pwd) zloymult/wasm-build-kit \
wasm-opt -Os dragon-curve.wasm -o dragon-curve-opt.wasm
这样我们可以从生成的文件中删除一百个左右的字节。
二进制的文件一个缺陷是它们不能被人类理解。幸运的是,WebAssembly具有两种格式:二进制和文本。你可以使用WebAssembly Binary toolkit在两者之间转换。试着运行:
docker run --rm -v $(pwd):$(pwd) -w $(pwd) zloymult/wasm-build-kit \
wasm2wat dragon-curve-opt.wasm > dragon-curve-opt.wat
现在,我们在文本编辑器中检查生成的dragon-curve-opt.wat文件。
.wat内容
这些有趣的括号称为s表达式(就像在老派的Lisp中一样)。它们用于表示树状结构。所以我们的Wasm文件是一棵树。树的根是一个module。它的工作原理很像你熟悉的JavaScript模块。它有导入和导出。
WebAssembly的基本构建块是在栈上运行的指令。
wasm指令
指令被组合成可以从模块导出的函数。
导出sign和getTurn
你可能会看到代码周围散布着if、else和loop语句,这是WebAssembly最突出的特性之一:通过使用所谓的结构化控制流(就像高级语言),它可以避免GOTO跳转并允许一次性解析源。
结构化控制流
现在看一下导出的sign函数,并查看基于栈的虚拟ISA的工作方式。
sign函数
还有另一个重要的实体,称为表(Table)。表是线性数组,就像内存一样,但是它们仅存储函数引用。无论它们是否是WebAssembly模块的一部分,它们都用于间接调用函数。
我们的函数接收一个整数参数(param i32)并返回一个整数结果(result i32)。一切都在栈上完成。首先,我们推入值:整数2,其后是函数的第一个参数(local.get 0),然后是整数4。然后应用i32.rem_s指令从栈中删除两个值(第一个函数参数和整数4),将第一个值除以第二个值,然后将余数推回栈。现在,最上面的值是余数和数字2。i32.sub从栈中弹出它们,从一个中减去另一个,然后推入结果。前五个指令等效于(2 - (x % 4))。
Wasm使用简单的线性内存模型:你可以将WebAssembly内存视为简单的字节数组。
在我们的.wat文件中,它是通过(export memory(memory0))从模块中导出的。也就是说我们可以从外部在WebAssembly程序的内存上操作,这就是我们下面要做的。
为了让浏览器绘制一条龙曲线,我们需要一个HTML文件。
touch index.html
放一个带有空canvas标签的样板,并初始化我们的初始值:size是曲线的步数,len是单步的长度,x0和y0设置起始坐标。
<!-- dragon-curve-llvm/index.html -->
<!DOCTYPE html>
<html>
<head>
<title>Dragon Curve from WebAssembly</title>
</head>
<body>
<canvas id="canvas" width="1920" height="1080"></canvas>
<script>
const size = 2000;
const len = 10;
const x0 = 500;
const y0 = 500;
</script>
</body>
</html>
现在,我们需要加载.wasm文件并实例化WebAssembly模块。与JavaScript不同,我们不需要等待整个模块加载就可以使用它——WebAssembly是在数据流入时即时编译和执行的。 我们使用标准的fetch API加载模块,并使用内置的WebAssembly JavaScript API对其实例化。WebAssembly.instantiateStreaming返回一个用模块对象解析的promise,其中包含我们模块的实例。现在,我们的C函数作为实例的exports可用,并且我们可以根据需要从JavaScript中使用它们。
<!-- dragon-curve-llvm/index.html -->
<!DOCTYPE html>
<html>
<head>
<title>Dragon Curve from WebAssembly</title>
</head>
<body>
<canvas id="canvas" width="1920" height="1080"></canvas>
<script>
const size = 2000;
const len = 10;
const x0 = 500;
const y0 = 500;
WebAssembly.instantiateStreaming(fetch("/dragon-curve.wasm"), {
// for this example, we don't import anything
imports: {},
}).then((obj) => {
const { memory, __heap_base, dragonCurve } = obj.instance.exports;
dragonCurve(__heap_base, size, len, x0, y0);
const coords = new Float64Array(memory.buffer, __heap_base, size);
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
ctx.beginPath();
ctx.moveTo(x0, y0);
[...Array(size)].forEach((_, i) => {
ctx.lineTo(coords[2 * i], coords[2 * i + 1]);
});
ctx.stroke();
// If you want to animate your curve, change the last four lines to
// [...Array(size)].forEach((_, i) => {
// setTimeout(() => {
// requestAnimationFrame(() => {
// ctx.lineTo(coords[2 * i], coords[2 * i + 1]);
// ctx.stroke();
// });
// }, 100 * i);
// });
});
</script>
</body>
</html>
仔细看看instance.exports。除了生成坐标的dragonCurve C函数之外,我们还返回了一个表示WebAssembly模块线性内存的memory对象。这里需要小心,因为它可能包含重要内容,例如我们用于虚拟机的指令栈。
技术上讲,我们需要一个内存分配器来避免混乱。但对于这个简单的示例,我们将读取内部__heap_base属性,其为我们提供了一个可以安全使用的内存区域(堆)的偏移量。
我们将这个偏移量赋给dragonCurve函数的“好”内存,调用它,然后将填充了坐标的堆内容提取为一个Float64Array。
本章的灵感来自Surma的精彩文章“无需Emscripten将C编译为WebAssembly”
剩下的只是根据从Wasm模块提取的坐标在画布上画一条线。现在我们要做的就是在本地提供HTML。我们需要一个基本的Web服务器,否则将无法从客户端获取Wasm模块。所幸Docker映像已完成了所有设置:
docker run --rm -v $(pwd):$(pwd) -w $(pwd) -p 8000:8000 zloymult/wasm-build-kit \
python -m http.server
转到http://localhost:8000
,龙曲线就在眼前!
上面的“纯LLVM”方法的目标是极为简单的;我们编译程序时没用系统库,还以最糟糕的方式管理内存:计算堆的偏移量,这样我们得以揭开WebAssembly内存模型的神秘面纱。但在实际的应用程序中,我们希望适当地分配内存并使用系统库,其中“系统”是我们的浏览器:WebAssembly仍在沙箱中运行,无法直接访问你的操作系统。
所有这些都可以在emscripten的帮助下完成:这是一个用于编译WebAssembly的工具链,它可以模拟浏览器内部的许多系统功能:使用STDIN、STDOUT和文件系统,甚至可以将OpenGL图形自动转换为WebGL。它还集成了我们之前用来压缩二进制文件的Bynarien,因此我们用不着专门优化体积了。
Emscripten诞生早于WebAssembly:首先,它被用来将C/C++代码编译为JavaScript和asm.js,而且现在还能这么干!
Emscripten
是时候正常使用WebAssembly了!我们的C代码不会变。先创建一个单独的文件夹以便对比代码,并复制我们的源码。
cd .. && mkdir dragon-curve-emscripten && cd dragon-curve-emscripten
cp ../dragon-curve-llvm/dragon-curve.c .
我们已经把ecmsripten打包进了Docker映像,因此你无需在系统上安装任何程序即可运行以下命令:
docker run --rm -v $(pwd):$(pwd) -w $(pwd) zloymult/wasm-build-kit \
emcc dragon-curve.c -Os -o dragon-curve.js \
-s EXPORTED_FUNCTIONS='["_dragonCurve", "_malloc", "_free"]' \
-s EXPORTED_RUNTIME_METHODS='["ccall"]' \
-s MODULARIZE=1
如果命令成功执行,你将看到两个新文件:小巧的dragon-curve-em.wasm,以及一个15Kb的怪物dragon-curve-em.js(缩小后),其中包含WebAssembly模块的实例化逻辑和各种浏览器polyfills。那就是目前在浏览器中运行Wasm的代价:我们仍需要大量JavaScript胶水才能将它们固定在一起。
这是我们所做的:
现在我们可以创建HTML文件,并粘贴新内容:
touch index.html
<!DOCTYPE html>
<html>
<head>
<title>Dragon Curve from WebAssembly</title>
</head>
<script type="text/javascript" src="./dragon-curve.js"></script>
<body>
<canvas id="canvas" width="1920" height="1080">
Your browser does not support the canvas element.
</canvas>
<script>
Module().then((instance) => {
const size = 2000;
const len = 10;
const x0 = 500;
const y0 = 500;
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
const memoryBuffer = instance._malloc(2 * size * 8);
instance.ccall(
"dragonCurve",
null,
["number", "number", "number", "number"],
[memoryBuffer, size, len, x0, y0]
);
const coords = instance.HEAPF64.subarray(
memoryBuffer / 8,
2 * size + memoryBuffer / 8
);
ctx.beginPath();
ctx.moveTo(x0, y0);
[...Array(size)].forEach((_, i) => {
ctx.lineTo(coords[2 * i], coords[2 * i + 1]);
});
ctx.stroke();
instance._free(memoryBuffer);
});
</script>
</body>
</html>
有了ecmscripten,我们不必直接使用浏览器API来实例化WebAssembly,就像在上一个示例中使用WebAssembly.instantiateStreaming所做的那样。
相反,我们使用emscripten提供给我们的Module函数。当我们编译程序时,Module将返回一个带有我们定义的所有导出的promise。当这个promise被解析时,我们可以使用_malloc
函数在内存中为坐标保留一个位置。它返回一个带有偏移量的整数,然后将其保存到memoryBuffer
变量中。它比上一个示例中不安全的heap_base方法安全得多。
参数2 * size * 8
表示我们将分配足够长的数组,以便为每个步骤存储两个坐标(x,y),每个坐标占用8个字节的空间(float64)。
Emscripten有一种调用C函数的特殊方法——ccall
。我们用它来调用dragonCurve函数,其以memoryBuffer提供的一个偏移量填充内存。画布代码与前面的示例相同。我们还利用emscripteninstance._free
方法在使用后清理内存。
C能这么顺利地转换为WebAssembly,原因之一是它使用简单的内存模型并且不依赖垃圾回收。否则,我们将不得不将整个语言运行时烘焙到我们的Wasm模块中。从技术上讲这是可行的,但是它将把二进制文件撑大好多圈,并影响加载和执行时间。
当然,并不是只有C和C++可以编译为WebAssembly。拥有LLVM前端的语言是最好的备选,Rust则是其中最突出的。
Rust的妙处在于它有一个出色的内置软件包管理器Cargo,与老字号的C语言相比,它很容易发现和重用现有库。
我们将展示将现有的Rust库转换为WebAssembly模块有多容易——这里要用上非常棒的wasm-pack工具链,它让我们能够飞速引导Wasm项目。
我们的Docker镜像已经内置了wasm-pack,用它开始一个新项目。如果你仍在上一个示例中的dragon-curve-ecmscripten文件夹中,请返回上一级。Wasm-pack使用与rails new或create-react-app相同的方法来生成项目:
docker run --rm -v $(pwd):$(pwd) -w $(pwd) -e "USER=$(whoami)" zloymult/wasm-build-kit wasm-pack new rust-example
现在,你可以进入rust-example文件夹并在编辑器中打开。我们已经将龙曲线的C代码转换为Rust,并打包成一个Cargo crate。 Rust项目中的所有依赖项都在Cargo.toml文件中管理,其行为与package.json或Gemfile很像。在编辑器中打开它,找到当前仅包含wasm-bindgen的[dependencies]部分,然后添加我们的外部crate。
# Cargo.toml
[dependencies]
# ...
dragon_curve = {git = "https://github.com/HellSquirrel/dragon-curve"}
项目源码位于src/lib.rs中,我们要做的就是定义一个函数,从导入的crate中调用dragon_curve。将下面的代码插入文件末尾:
// src/lib.rs
#[wasm_bindgen]
pub fn dragon_curve(size: u32, len: f64, x0: f64, y0: f64) -> Vec<f64>
{
dragon_curve::dragon_curve(size, len, x0, y0)
}
是时候编译结果了。注意这些标志看起来更人性化。Wasm-pack有用于绑定JavaScript的内置Webpack支持,并且需要的话甚至可以生成HTML,但是我们将采用最小方法并设置-- target Web。只需将一个Wasm模块和一个JS包装器编译为一个原生ES模块。 这一步可能需要一些时间,具体取决于你的机器和网络连接:
docker run --rm -v $(pwd):$(pwd) -w $(pwd)/rust-example -e "USER=$(whoami)" zloymult/wasm-build-kit wasm-pack build --release --target web
你可以在项目的pkg文件夹中找到结果。是时候在项目根目录中创建HTML文件了。这里的代码是我们所有示例中最简单的:我们只是原生地将dragon_curve函数作为JavaScript导入来使用。在幕后,Wasm二进制文件负责那些繁重工作,而且我们不再需要像前面的示例中那样手动处理内存。 另一件事是异步init函数,它让我们能等待Wasm模块完成初始化。
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<canvas id="canvas" width="1920" height="1080"></canvas>
<script type="module">
import init, { dragon_curve } from "/pkg/rust_example.js";
(async function run() {
await init();
const size = 2000;
const len = 10;
const x0 = 500;
const y0 = 500;
const coords = dragon_curve(size, len, x0, y0);
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
ctx.beginPath();
ctx.moveTo(x0, y0);
[...Array(size)].forEach((_, i) => {
ctx.lineTo(coords[2 * i], coords[2 * i + 1]);
});
ctx.stroke();
})();
</script>
</body>
</html>
现在提供HTML并享受结果!
docker run --rm -v $(pwd):$(pwd) -w $(pwd) -p 8000:8000 zloymult/wasm-build-kit \
python -m http.server
显然,从开发人员的经验层面来看Rust和wasm-pack明显胜出。当然,我们只是简单介绍了一些基础知识:emscripten或wasm-pack可以做的事情还很多,例如直接操作DOM。
请查阅“DOM hello world”“使用Rust的单页应用程序”和Emscripten文档。
WebAssembly不仅带来了可移植性、源独立性和代码重用。它还承诺当浏览器运行Wasm代码时会有显著的性能优势。要了解在WebAssembly中重写Web应用程序逻辑的优点(和缺点),我们必须了解客户端的底层操作,以及它与执行JavaScript有何不同。
在过去的几十年中,即便将JavaScript转换为有效的机器代码并非易事,浏览器还是非常擅长运行JS。所有的火箭科学都发生在浏览器引擎内部,这是Web上最聪明的人才进行编译技术竞赛的地方。
我们可能无法涵盖所有引擎的内部工作原理,所以这里只谈一下V8,这是Chromium和Node JS的JS运行时,目前它在浏览器市场和JavaScript的后端环境中均占主导地位。
JS和Wasm都能由V8编译并执行,但是方法略有不同。两者的管道很像:获取源码,对其解析、编译和执行。用户必须等待所有步骤完成才能在设备上看到结果。
对于JavaScript,主要的权衡是在编译时间与执行时间之间:我们可以非常快速地生成未优化的机器代码,但是这将花费更长的时间运行;或者我们可以花更多的时间编译并确保由此产生的机器指令是最高效的。
V8尝试解决这一问题的方法如下:
V8的工作方式(JS)
首先,V8解析JavaScript并将生成的抽象语法树提供给名为Ignition的解释器,后者将其转换为基于一个寄存器型虚拟机的内部表示。在处理WebAssembly时这一步可以跳过,因为Wasm源已经是一组虚拟指令了。
在将JS解释为字节码时,Ignition会收集其他一些信息(反馈),帮助决定是否进一步优化。标为优化的函数被认为是“热”的。
生成的字节码最终出现在名为TurboFan的引擎的另一个组件中。它的工作是将内部表示转换为目标架构的优化机器代码。
为了获得最佳性能,TurboFan必须根据Ignition的反馈来推测。例如,它可以“猜测”函数的参数类型。如果随着新代码的不断出现,这些猜测失效了,那么引擎将放弃所有优化并从头开始。这种机制使代码的执行时间无法预测。
JS执行时间
Wasm让浏览器引擎的工作更加轻松:由于.wasm格式,代码已经采用了内部表示的形式,可以轻松进行多线程解析。另外,当我们在开发人员的机器上编译WebAssembly文件时,一些优化已经包含在其中了。这意味着V8可以立即编译和执行代码,而无需像对JavaScript那样反复优化和反优化。
V8的工作方式(Wasm)
Liftoff基准编译器在V8中提供了“快速启动”功能。TurboFan及其出色的优化功能仍在发挥作用,只是这一次它不必猜测任何内容,因为源代码已经具备所有必要的类型信息。“热”函数的概念不再适用,这使我们的执行时间具有确定性:我们提前知道了执行程序需要多长时间。
Wasm执行时间
当然,你也可以在浏览器外部运行WebAssembly。有许多项目可让你在任何客户端上使用Wasm运行任何代码:Wasm3、Wasmtime、WAMR、Wassmer等。如你所见,WebAssembly的雄心是最终超越浏览器,进入各种系统和设备。
WebAssembly的创建是为了补充现有的Web生态系统:绝不是要替代JavaScript。使用现代浏览器时JS已经够快了,并且对于大多数常见的Web任务(例如DOM操作),WebAssembly不会给我们带来任何性能上的优势。
WebAssembly的承诺之一是消除Web应用程序与其他各类软件之间的界限:可以轻松地将用不同语言开发的成熟代码库引入浏览器。许多项目已经移植到Wasm中,包括游戏、图像编解码器,机器学习库,甚至是语言运行时。
Figma是现代设计师必不可少的工具,它从一开始就在生产环境中使用WebAssembly。
在当下,没有JavaScript根本无法使用纯Wasm:无论你自己编写代码还是依靠工具生成代码,你都需要那些“胶水”代码。
如果你希望用Wasm消除性能瓶颈,建议你三思,因为无需完全重写就可以解决这些瓶颈。你绝不应该依赖对比单个任务的WebAssembly和JS的基准测试结果,因为在实际应用程序中,Wasm和JS是会一直互联的。
查看WebAssembly提案,以了解Web上二进制文件的前景。
尽管WebAssembly仍处于MVP阶段,但现在是开始尝试它的最佳时机:有了我们在本文中演示的工具,你就可以开始运行它了。
如果你想更深入地研究WebAssembly,请查阅我们撰写本文时为自己编制的阅读清单。我们还创建了一个存储库,其中包含本文中的所有代码示例。
授权声明:本文最初发布于EVIL MARTIANS博客,原文标题:Hands-on WebAssembly: Try the Basics,作者Polina Gurtovaya & Andy Barnov。本文经原博客授权由InfoQ中文站翻译并分享。
领取专属 10元无门槛券
私享最新 技术干货