了解 JS API

这里我们假设你已经拥有一个 .wasm 模块,无论是 从 C/C++ 程序编译而来 还是 直接从 s-exprs 组装而来.

加载和运行

虽然有 未来计划 允许 WebAssembly 模块像 ES6 模块一样加载(使用 <script type='module'>),但目前 WebAssembly 必须由 JavaScript 加载和编译。对于基本加载,有三个步骤

  • .wasm 字节获取到一个类型化数组或 ArrayBuffer
  • 将字节编译成一个 WebAssembly.Module
  • 使用导入实例化 WebAssembly.Module 以获取可调用导出

让我们更详细地讨论这些步骤。

对于第一步,有许多方法可以获取类型化数组或 ArrayBuffer 的字节:通过网络,使用 XHR 或 fetch,从 IndexedDB 获取的 File,甚至直接在 JavaScript 中合成。

下一步是使用异步函数 WebAssembly.compile 编译字节,该函数返回一个 Promise,该 Promise 解析为一个 WebAssembly.Module。一个 Module 对象是无状态的,支持 结构化克隆,这意味着编译后的代码可以存储在 IndexedDB 中,并且可以通过 postMessage 在窗口和工作线程之间共享。

最后一步是通过构造一个新的 WebAssembly.Instance实例化 Module,将 ModuleModule 请求的任何导入传递给它。 Instance 对象就像 函数闭包 一样,将代码与环境配对,并且不可结构化克隆。

我们可以将最后两个步骤合并成一个 instantiate 操作,该操作既接受字节又接受导入,并异步返回一个 Instance

function instantiate(bytes, imports) {
  return WebAssembly.compile(bytes).then(m => new WebAssembly.Instance(m, imports));
}

为了实际演示,我们首先需要介绍 JS API 的另一个部分

函数导入和导出

与 ES6 模块一样,WebAssembly 模块可以导入和导出函数(以及我们稍后将看到的其他类型的对象)。我们可以在这个模块中看到一个简单的例子,它从模块 imports 导入函数 i 并导出函数 e

;; simple.wasm
(module
  (func $i (import "imports" "i") (param i32))
  (func (export "e")
    i32.const 42
    call $i))

(这里,我们不是用 C/C++ 编写模块并编译为 WebAssembly,而是直接用 文本格式 编写模块,该格式可以直接 组装 成二进制文件 simple.wasm。)

查看这个模块,我们可以看到一些东西。首先,WebAssembly 导入有一个两级命名空间;在本例中,内部名称为 $i 的导入是从 imports.i 导入的。类似地,我们必须在传递给 instantiate 的导入对象中反映这个两级命名空间

var importObject = { imports: { i: arg => console.log(arg) } };

将本节和上一节中的所有内容放在一起,我们可以使用简单的 Promise 链来获取、编译和实例化我们的模块

fetch('simple.wasm').then(response => response.arrayBuffer())
.then(bytes => instantiate(bytes, importObject))
.then(instance => instance.exports.e());

最后一行调用我们导出的 WebAssembly 函数,该函数反过来调用我们导入的 JS 函数,最终执行 console.log(42)

内存

线性内存 是 WebAssembly 的另一个重要构建块,通常用于表示编译后的 C/C++ 应用程序的整个堆。从 JavaScript 的角度来看,线性内存(以下简称“内存”)可以被认为是一个可调整大小的 ArrayBuffer,经过精心优化,可以低开销地对加载和存储进行沙箱隔离。

可以通过提供初始大小和可选的最大大小从 JavaScript 创建内存

var memory = new WebAssembly.Memory({initial:10, maximum:100});

需要注意的第一件事是 initialmaximum 的单位是WebAssembly 页面,固定为 64KiB。因此,上面的 memory 的初始大小为 10 页,即 640KiB,最大大小为 6.4MiB。

由于 JavaScript 中大多数字节范围操作都在 ArrayBuffer 和类型化数组上,而不是定义一组完全不兼容的操作,WebAssembly.Memory 通过简单地提供一个返回 ArrayBufferbuffer getter 来公开其字节。例如,要将 42 直接写入线性内存的第一个字

new Uint32Array(memory.buffer)[0] = 42;

创建后,可以通过调用 Memory.prototype.grow 来扩展内存,其中参数再次以 WebAssembly 页为单位指定

memory.grow(1);

如果在创建时提供了 maximum,则尝试超过此 maximum 增长将抛出 RangeError 异常。引擎利用此提供的上限提前预留内存,这可以使调整大小更有效。

由于 ArrayBufferbyteLength 是不可变的,因此在成功执行 Memory.grow 操作后,buffer getter 将返回一个新的 ArrayBuffer 对象(具有新的 byteLength),并且任何以前的 ArrayBuffer 对象将变为“分离”(零长度,许多操作会抛出异常)。

与函数一样,线性内存可以在模块内定义或导入。类似地,模块也可以选择导出其内存。这意味着 JavaScript 可以通过创建 new WebAssembly.Memory 并将其作为导入传递,或者通过接收 Memory 导出来访问 WebAssembly 实例的内存。

例如,让我们使用一个 WebAssembly 模块来对整数数组求和(用“…”替换函数体)

(module
  (memory (export "mem") 1)
  (func (export "accumulate") (param $ptr i32) (param $length i32) ))

由于此模块导出其内存,因此对于称为 instance 的此模块的 Instance,我们可以使用其导出的 mem getter 在实例的线性内存中直接创建和填充输入数组,如下所示

var i32 = new Uint32Array(instance.exports.mem);
for (var i = 0; i < 10; i++)
  i32[i] = i;
var sum = instance.exports.accumulate(0, 10);

内存导入的工作方式与函数导入相同,只是 Memory 对象作为值而不是 JS 函数传递。内存导入有两个用途

  • 它们允许 JavaScript 在模块编译之前或与其同时获取和创建内存的初始内容。
  • 它们允许单个 Memory 对象被多个实例导入,这是在 WebAssembly 中实现 动态链接 的关键构建块。