前言:
看了上篇文章对 webAssembly 的介绍,本文开始进行代码实践,看看webAssembly是如何工作的。
一、快速体验
首先,想快速体验webAssembly,我们可以打开任意一个支持它的浏览器,在控制台里输入以下代码并运行:
WebAssembly.compile(new Uint8Array(`
00 61 73 6d 01 00 00 00 01 0c 02 60 02 7f 7f 01
7f 60 01 7f 01 7f 03 03 02 00 01 07 10 02 03 61
64 64 00 00 06 73 71 75 61 72 65 00 01 0a 13 02
08 00 20 00 20 01 6a 0f 0b 08 00 20 00 20 00 6c
0f 0b`.trim().split(/[\s\r\n]+/g).map(str => parseInt(str, 16))
)).then(module => {
const instance = new WebAssembly.Instance(module)
const { add, square } = instance.exports
console.log('4 + 8 =', add(4, 8))
console.log('5^2 =', square(5))
console.log('(4 + 8)^2 =', square(add(4 + 8)))
})
如果浏览器正常支持的话,会输出以下结果,否则会报错。
代码中需要解析的这一大串数字,就是webAssembly的二进制源码,源码一共有 66 个数,每个数都是 8 位无符号十六进制整数,一共占 66 Byte。
上方代码目的是把字符串转成 ArrayBuffer。先将字符串分割成普通数组,然后将普通数组转成 8 位无符号整数的数组,由于数组里的数字是十六进制的,所以用了 parseInt(str, 16)进行转化。
webAssembly 提供了 JS API,其中 webAssembly.compile 可以用来编译 wasm 的二进制源码,返回一个 Promise。
当返回的 Promise fulfilled 了,resolve 方法的第一个参数就是 webAssembly 的模块对象,即webAssembly.Module 的实例。
我们可以用webAssembly.Instance将模块对象转成 webAssembly 实例(第二个参数可以用来导入变量)。
接着通过 instance.exports拿到 wasm 代码输出的接口,剩下的代码就和和普通 JavaScript一样了。
注意,webAssembly 是有明确的数据类型的,本例子里用的都是 32 位整型数(二进制里那些 7f 表示i32指令,意思就是32位整数),所以用 webAssembly 编译出来的时候要注意数据类型。
如果你乱传数据,程序也不会报错,因为在执行时会被动态转换,它支持传递模糊类型的数据引用。但是你如果给函数传了个字符串或者超大的数,具体会被转成什么就说不清了,通常是转成 0。
二、把 C/C++ 编译成 WebAssembly
在实践工作中,我们当然不会直接手写上述例子中的二进制代码,而是写好C/C++等高级语言的代码,再将该代码编译成二进制。
我们常用的一个编译工具是Emscripten,它基于 LLVM ,可以将 C/C++ 编译成 asm.js,使用 WASM 标志也可以直接生成 WebAssembly 二进制文件(后缀是 .wasm)。
有了这个工具后,我们可以先写一个简单的C语言文件math.c,如:
// math.c
int add (int x, int y) {
return x + y;
}
int square (int x) {
return x * x;
}
然后执行 emcc math.c -Os -s WASM=1 -s SIDE_MODULE=1 -o math.wasm 就可以生成 wasm 文件了。
因为该C语言文件里没有main()函数,所以它无法自动运行,不过可以当作库文件,自己写html和JS来调用。当然,如果写了main()函数,可以调用emcc math.c -s WASM=1 -o math.html,将该文件编译成html+js+wasm文件,形成一个可执行的demo。
三、如何运行webAssembly文件
运行wasm文件的步骤如下:
(1)加载文件
(2)转成ArrayBuffer
(3)编译+实例化
(4)提取生成的模块
根据以上步骤我们可以写一个loadWebAssembly 方法来加载wasm文件,代码如下:
/**
* @param {String} path wasm 文件路径
* @param {Object} imports 传递到 wasm 代码中的变量
*/
function loadWebAssembly (path, imports = {}) {
return fetch(path)
.then(response => response.arrayBuffer())
.then(buffer => WebAssembly.compile(buffer))
.then(module => {
imports.env = imports.env || {}
// 开辟内存空间
imports.env.memoryBase = imports.env.memoryBase || 0
if (!imports.env.memory) {
imports.env.memory = new WebAssembly.Memory({ initial: 256 })
}
// 创建变量映射表
imports.env.tableBase = imports.env.tableBase || 0
if (!imports.env.table) {
// 在 MVP 版本中 element 只能是 "anyfunc"
imports.env.table = new WebAssembly.Table({ initial: 0, element: 'anyfunc' })
}
// 创建 WebAssembly 实例
return new WebAssembly.Instance(module, imports)
})
}
代码并不难,第一个参数为文件的路径,使用fetch函数来加载wasm文件。第二个参数为传递给 wasm 的变量,在初始化 webAssembly 实例的时候,可以把一些接口传递给 wasm 代码,然后进行相关转化和生成实例。这样,我们就可以运用编写好C语言等代码,通过工具转化为wasm文件,运用到我们的JS中。
loadWebAssembly('path/to/math.wasm')
.then(instance => {
const { add, square } = instance.exports
// ...
})
四、把asm.js编译成webAssembly
除了将C/C++等语言编译成wasm之外,Emscripten还能将这些高级语言编译成asm.js。asm.js 是 JavaScript的子集,是一种语法,用了很多底层语法来标注数据类型,目的是提高JavaScript的运行效率,其本身就是作为 C/C++ 编译的目标设计的,可以理解为一种中间语言IR (Intermediate Representation)。
asm.js 出生于 webAssembly 之前, webAssembly 借鉴了这个思路,做的更彻底一些,直接跳过 JavaScript,设计了一套新的平台指令。
根据上面math.c文件,我们编写一个asm.js版的文件。
// math.js
function () {
"use asm";
function add (x, y) {
x = x | 0;
y = y | 0;
return x + y | 0;
}
function square (x) {
x = x | 0;
return x * x | 0;
}
return {
add: add,
square: square
};
}
该函数在开头声明了use asm,表示这个函数会被视为asm.js的模块,里面添加的方法可以通过return暴露出来供外部使用。
不过,目前只有 asm.js 才能转成 wasm,普通 JavaScript是不行的。因为 JavaScript是弱类型语言,用法也比较灵活,本身就很难编译成强类型的指令。
五、在webAssembly中调用Web API
我们通过JS可以调用wasm暴露出来的方法。同时wasm也能调用web中提供的api。只需要我们在加载wasm文件时,将所需要用到的api封装好传递过去即可。wasm文件是二进制形式不便于我们阅读,但webAssembly提供了一种可读的文本描述模式wast供我们理解阅读。
const imports = {
Math,
objects: {
count: 2333
},
methods: {
output (message) {
console.log(`${message}`)
}
}
}
loadWebAssembly('path/to/source.wasm', imports)
.then(instance => {
// ...
})
我们给wasm文件传递了三个对象,Math是web自带的api,objects是一个JS对象,methods里则是写好的JS方法。在loadWebAssembly 函数接收到这些参数后,通过new WebAssembly.Instance(module, imports)的第二个参数接收JS参数并实例化WebAssembly。
为了方便阅读和编程,我们可以在编译时通过编译选项将wasm文件转换为wast文件:
(module
(import "objects" "count" (global $count f32))
(import "methods" "output" (func $output (param f32)))
(import "Math" "sin" (func $sin (param f32) (result f32)))
(export "test" (func $test))
(func $test (param $x f32)
(call $output (f32.const 42))
(call $output (get_global $count))
(call $output (get_local $x))
(call $output
(call $sin
(get_local $x)
)
)
)
)
这段代码首先从 objects 中导入 count 属性,并且在代码里声明为全局的 $count 变量,格式是 32 位浮点数。然后从 methods 中导入 output 方法,声明为一个接受 32 位浮点数作为参数的函数 $output。最后从 Math 中导入 sin 方法,声明为一个接受 32 位浮点数作为参数的函数 $sin,返回值也是 32 位浮点数。这样一来就把JS传递的对象转成了自身模块中可以使用变量。
转化变量后,定义并且导出了一个 test 函数,接受一个 32 位浮点数作为参数。在 wast 的语法里 call 指令用来调用函数,get_global 用来获取全局变量的值,get_local 用来获取局部变量的值,只能在函数定义中使用。这样来看,test 函数 里执行了四条命令,首先调用 $output 输出了一个常量 42;然后调用 $output 输出全局变量 $count ,这个值是通过 import 获取来的;接着又输出了函数的参数 $x;最后输出了函数参数 $x 调用 Web API $sin 计算后的结果。
之后我们通过west2wasm source.wast -o source.wasm命令生成wasm文件,再通过load加载调用,就能实现wasm使用web API的功能。
loadWebAssembly('path/to/source.wasm', imports)
.then(instance => {
const { test } = instance.exports
test(2333)
})
总结:
以上便是一些简单使用webAssembly的小例子。个人感觉webAssembly主要是为了解决JS动态类型特性的编译问题和一些计算瓶颈而诞生的,在实际开发中,我们可以充分发挥C/C++语言的优势,封装好一些代码库供JS调用,相信只要两者充分结合起来,就能使浏览器的性能进一步提高。