WebAssembly 与 AssemblyScript 快速入门

WebAssembly 概述

简单来说,WebAssembly 是一种除了 HTML、JS 和 CSS 之外,可在浏览器环境下运行的一种二进制程序文件,文件格式为 wasm。
wasm 文件是一种低级的类汇编语言,运行效率接近原生性能,并可以和 JavaScript 进行交互。

AssemblyScript 概述

虽然 wasm 文件有各种优点,但是并不能使用 JavaScript 编写。通常情况下是使用诸如 C++或 Rust 等语言(语言支持情况)编写并生成 wasm 文件。
这对于前端开发人员来说还需要学习另外一门语言,存在上手难度。好在有 AssemblyScript。
AssemblyScript 是 TypeScript 的子集,包含了最基本的 JS 标准库。可使用有限的 TS 语法编写并生成 wasm 文件,对前端开发人员非常友好。

WebAssembly 关键概念

先看一个例子:

AssemblyScript

1
2
3
4
5
6
declare function log(arg: i32): void;

export function add(a: i32, b: i32): i32 {
log(a + b + 1000);
return a + b;
}

JavaScript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
(async () => {
const buffer = await fetch('../build/optimized.wasm').then(r => r.arrayBuffer());
const memory = new WebAssembly.Memory({ initial: 1 }); // 1
new Uint32Array(memory.buffer)[0] = 123;
const importObj = {
wasm: {
log: args => console.log('log from wasm', args),
},
env: {
memory,
},
};
const { instance, module } = await WebAssembly.instantiate(buffer, importObj); // 2
const exportObj = instance.exports;
const result1 = exportObj.add(1, 2);
console.log('result1', result1);

const table = new WebAssembly.Table({ initial: 1, element: 'anyfunc' }); // 3
table.set(0, exportObj.add);
const result2 = table.get(0)(2, 3);
console.log('result2', result2);
})();

运行结果

1
2
3
4
log from wasm 1003
result1 3
log from wasm 1005
result2 5

模块

模块表示一个已经被浏览器编译过的 WebAssembly 二进制代码,可以被浏览器缓存和通过 postMessage 在 worker 中共享。一般一个 wasm 文件会被编译成一个模块。

注释 2 中的module就是一个 wasm 模块,表示 wasm 编译后的结果,可以被缓存。

实例

调用WebAssembly.Instance传入 wasm 文件,会创建一个 WebAssembly 实例,类似 JS 中的 new 操作。一个实例包含了模块运行时的所有状态,包括导入导出值、内存和表格等。

注释 2 中的instance就是一个 wasm 实例,其中主要包含了模块导出的对象,可以直接被 JS 调用。

内存

内存是将 WebAssembly 运行的内存表示为 JS 中的 ArrayBuffer,可通过 TypedArray 对其进行读写。支持导入到 wasm 和从 wasm 导出。

注释 1 在 JS 创建了一段内存,将第一个元素设置为 123,并将其导入到 wasm 中使用。

表格

表格实际是引用值地址组成的数组,每一项表示在 wasm 中的引用类型,目前 WebAssembly 只支持函数类型。

注释 3 创建了一个表格,并将第 0 项设置为 wasm 中 add 函数的引用,可以在其后直接调用。

AssemblyScript 开发环境的搭建

  1. 新建一个项目,并用yarn init初始化
  2. 安装必要的依赖

    1
    2
    yarn add assemblyscript --dev
    yarn add @assemblyscript/loader
  3. 初始化项目目录结构和配置

    1
    yarn asinit .

    确认初始化,并编译 wasm 后,目录结构如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    PROJECT_ROOT
    ├── asconfig.json
    ├── assembly
    | ├── index.ts
    | └── tsconfig.json
    ├── build
    | ├── optimized.wasm
    | ├── optimized.wasm.map
    | ├── optimized.wat
    | ├── untouched.wasm
    | ├── untouched.wasm.map
    | └── untouched.wat
    ├── index.js
    ├── package.json
    ├── tests
    | └── index.js
    └── yarn.lock
  4. 编辑assembly/index.ts中的 ts 文件 (AssemblyScript)

  5. 运行 yarn asbuild会在 build 目录中生成 wasm 文件
  6. 可以通过修改并运行node index.js测试生成的 wasm,也可以在其他 node 项目中引用此项目直接使用 wasm

    1
    2
    const myModule = require('path/to/mymodule');
    myModule.add(1, 2);

AssemblyScript HelloWorld

使用 AssemblyScript 实现一个计算加法的程序,并在 wasm 内输出 log 到控制台
首先编写 wasm.ts 文件

注:AssemblyScript 是 TypeScript 的子集,并有一些书写上的限制,如没有 number 类型,需要用更具体的类型代替,如 i32, u32, f32, f64 等。

使用declare声明从 JS 中导入的函数
使用export语法导出函数到 JS

1
2
3
4
5
6
declare function log(arg: string): void;

export function add(a: i32, b: i32): i32 {
log(`run add: ${a}, ${b}`);
return a + b;
}

编写 index.js 文件,并在 html 文件中使用 ESModule 方式引用
使用 @assemblyscript/loader 而不使用原生的方法是因为需要用到 loader 提供的 runtime(下文会作具体说明)
注:在编译 wasm 时需要提供 --exportRuntime参数

1
<script type="module" src="./index.js"></script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import loader from 'https://cdn.jsdelivr.net/npm/@assemblyscript/loader/index.js';

const main = async () => {
const importObj = {
wasm: {
log,
},
};
const buffer = await fetch('../build/optimized.wasm').then(r => r.arrayBuffer());
const {
exports: { __getString, add },
instance,
} = await loader.instantiate(buffer, importObj);
function log(args) {
console.log('log from wasm:', __getString(args));
}
const result = add(1, 2);
console.log('result', result);
};

main();

输出结果:

1
2
log from wasm: run add: 1, 2
result 3

在 AssemblyScript 中使用的 JS 函数可以通过loader.instantiate的第二个参数,以文件名为 key 的对象中传入。

在 AssemblyScript 导出的 add 函数可以在loader.instantiate返回的exports中使用,除了自己定义的 add 函数外,loader 还导出了一些工具函数用来和 JS 进行数据转换。例子中用到了__getString方法。

AssemblyScript 的导入和导出

导入

可以在任意文件声明导入的接口类型,然后从 JS 接收具体的函数。
例:新建一个模块,声明一个 log 函数接口,函数具体实现稍后会从 JS 中导入。

imports.ts

1
export declare function log(arg: string): void;

其他文件就可以通过如下方式引入导入的函数

wasm.ts

1
import { log } from './imports';

log 函数需要在 JS 中通过导入对象的形式传入(key 为包含 declare 方法的文件名)

index.js

1
2
3
4
5
6
7
8
9
10
const importObj = {
// imports: 包含函数声明的文件名
imports: {
// log: 声明的函数名称
log: arg => {
console.log(arg);
},
},
};
const { instance } = await loader.instantiate(fetch('../build/optimized.wasm'), importObj);

导出

使用标准的 ESModule export 语法在入口文件处导出函数或常量

例:

wasm.ts

1
2
3
4
5
export function add(a: i32, b: i32): i32 {
log(`run add: ${a}, ${b}`);
return a + b;
}
export const num: i32 = 256;

AssemblyScript 与 JS 的数据转换

AssemblyScript 使用线性内存管理数据,底层只支持 number 类型 (i32, u8, f64 等),所以字符串、数组等类型不能直接导入导出或通过函数参数传入和返回。如需使用需要用到 AssemblyScript 的 loader (@assemblyscript/loader)。
通过loader.instantiate的方式载入用 AssemblyScript 编译后的 wasm 后,会同时导出一些内置的工具函数,用来实现字符串或数组等类型的转换。
附带的工具函数 API

常用方法如下:

  • function __newString(str: string): number: 创建字符串到指针值,用于向 wasm 传递字符串。
  • function __getString(ptr: number): string: 从 wasm 指针,读取字符串。
  • function __newArray( id: number, values: valuesOrCapacity?: number[] | ArrayBufferView | number ): number: 创建数组并传递到 wasm
  • function __getArray(ptr: number): number[]: 从 wasm 读取数组

字符串类型

AssemblyScript

1
2
3
export function concat(a: string, b: string): string {
return a + b;
}

JavaScript

1
2
3
4
5
6
7
8
9
10
const { concat } = myModule.exports;
const { __newString, __getString } = myModule.exports;
function doConcat(aStr, bStr) {
const aPtr = __newString(aStr);
const bPtr = __newString(bStr);
const cPtr = concat(aPtr, bPtr);
const cStr = __getString(cPtr);
return cStr;
}
console.log(doConcat('Hello ', 'world!'));

数组类型

AssemblyScript

1
2
3
4
5
6
7
8
export function sum(arr: Int32Array): i32 {
let sum = 0;
for (let i = 0, k = arr.length; i < k; ++i) {
sum += unchecked(arr[i]);
}
return sum;
}
export const Int32Array_ID = idof<Int32Array>();

JavaScript

1
2
3
4
5
6
7
8
9
const { sum, Int32Array_ID } = myModule.exports;
const { __newArray } = myModule.exports;

function doSum(values) {
const arrPtr = __newArray(Int32Array_ID, values);
return sum(arrPtr);
}

console.log(doSum([1, 2, 3]));

使用 unchecked 对提供的表达式不进行边界检查。

创建数组前需要在 AssemblyScript 中定义相关类型的 ID,并使用 ID 在 JS 中创建数组。
此外 AssemblyScript 还提供以下函数,获取类型化数组。

1
2
3
4
5
6
7
8
9
10
11
function __getInt8ArrayView(ptr: number): Int8Array;
function __getUint8ArrayView(ptr: number): Uint8Array;
function __getUint8ClampedArrayView(ptr: number): Uint8ClampedArray;
function __getInt16ArrayView(ptr: number): Int16Array;
function __getUint16ArrayView(ptr: number): Uint16Array;
function __getInt32ArrayView(ptr: number): Int32Array;
function __getUint32ArrayView(ptr: number): Uint32Array;
function __getInt64ArrayView(ptr: number): BigInt64Array;
function __getUint64ArrayView(ptr: number): BigUint64Array;
function __getFloat32ArrayView(ptr: number): Float32Array;
function __getFloat64ArrayView(ptr: number): Float64Array;

自定义类

AssemblyScript 可以导出自定义类,类中可以包含数字和字符串类型的属性和自定义方法。

AssemblyScript

1
2
3
4
5
6
7
8
9
10
export class Foo {
constructor(public str: string) {}
getString(): string {
return this.str;
}
}

export function getFoo(): Foo {
return new Foo('Hello world!');
}

JavaScript

1
2
3
4
5
6
7
8
const { Foo, getFoo } = myModule.exports;
const { __getString, __pin, __unpin } = myModule.exports;

const fooPtr = __pin(getFoo()); // pin if necessary
const foo = Foo.wrap(fooPtr);
const strPtr = foo.getString();
console.log(__getString(strPtr));
__unpin(fooPtr); // unpin if necessary

使用 __pin__unpin 防止 AssemblyScript 中的对象被回收。

可使用 as-bind 库简化数据转换操作:NPM

AssemblyScript 内存相关操作

AssemblyScript 支持和 JS 共享一段线性内存用来保存和处理数据。
注:在编译 wasm 时需要提供 --importMemory参数

例:用操作内存的方法来实现一个加法

  • load 用于读取指定字节位置的数据
  • store 用于将数据存入指定字节的位置

JavaScript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(async () => {
const buffer = await fetch('../build/optimized.wasm').then(r => r.arrayBuffer());
const memory = new WebAssembly.Memory({ initial: 1 });
const wasmMem = new Int32Array(memory.buffer);
wasmMem[0] = 2; // 第一个加数
wasmMem[1] = 3; // 第二个加数
const importObj = {
env: {
memory,
},
};
const {
instance: { exports },
module,
} = await WebAssembly.instantiate(buffer, importObj);
// exports.memory === memory // true
exports.add();
console.log('memory', wasmMem);
const result = wasmMem[2]; // 加法结果
console.log('result', result);
})();

AssemblyScript

1
2
3
4
5
6
7
8
9
// memory.grow(1); // 单位为页,1页为64KB

// i32对应的长度为4B
const UNIT_SIZE: i32 = 4;
export function add(): void {
// 将内存中第一项和第二项相加,结果保存到第三项
const result = load<i32>(0 * UNIT_SIZE) + load<i32>(1 * UNIT_SIZE);
store<i32>(2 * UNIT_SIZE, result);
}

AssemblyScript 相比于 TypeScript 存在的限制

  • 需要指明更具体的 number 类型:
AssemblyScript Type WebAssembly Type Description
i32 i32 A 32-bit signed integer
u32 i32 A 32-bit unsigned integer
i64 i64 A 64-bit signed integer
u64 i64 A 64-bit unsigned integer
f32 f32 A 32-bit float
f64 f64 A 64-bit float
i8 i32 An 8-bit signed integer
u8 i32 An 8-bit unsigned integer
i16 i32 A 16-bit signed integer
u16 i32 A 16-bit unsigned integer
bool i32 A 1-bit unsigned integer
  • 由于是严格类型,所以 ===== 是等价的

  • 所有类型必须是确定的,没有 any、undefined、联合类型

  • 对象不能在运行时检查字段可用性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    function doSomething(foo: Foo): void {
    if (foo.something) {
    // ... some code ...
    foo.something.length; // fails
    }
    }

    function doSomething(foo: Foo): void {
    let something = foo.something;
    if (something) {
    something.length; // works
    }
    }
  • 不支持 JSONRegExp (存在社区解决方案)

  • Date支持有限 (仅包含 UTC 相关的实现)
  • 不支持函数闭包
  • 不支持迭代器和扩展运算符
  • 不支持try...catchthrow异常处理
  • 不支持 Promise
  • JS 动态特性的相关功能

具体说明及其他限制详见官方文档

其他特性

  • 支持一些装饰器函数,用来更改函数的行为或优化性能 官方文档

总结

由于 WebAssembly 不支持 JS 中的文档对象、网络、Promise 等,且数据类型支持有限,所以目前 WebAssembly 更适合对算法相关逻辑进行封装,通过缓存相关技术,可以将一个大的算法拆成几个小的 wasm,再通过 JS 粘到一起,兼顾高性能与灵活性。

0%