要是你已经用 Emscripten 这类工具把其他语言的代码编译成了模块,或者自己手动加载运行过相关代码,那下一步就是深入学习 WebAssembly JavaScript API 的其他功能怎么用了。这篇文章就来教你这些必备知识。

注意:要是你对本文提到的基础概念不熟悉,需要更多解释,先去读《WebAssembly 核心概念》,之后再回来。

几个示例

咱们来看几个例子,搞明白 WebAssembly JavaScript API 该怎么用,以及怎么在网页里用它加载 Wasm 模块。

注意:示例代码可以在我们的 webassembly-examples GitHub 仓库里找到。

准备示例

首先得有个 Wasm 模块才行!把我们的 simple.wasm 文件下载下来,存到你本地电脑的一个新文件夹里。

接下来,在放 Wasm 文件的同一个文件夹里,新建一个简单的 HTML 文件,命名为 index.html(要是你手边没有现成的模板,也可以用我们提供的简易模板)。

为了让大家搞清楚这里面的逻辑,咱们来看下这个 Wasm 模块的文本格式(之前在《WebAssembly 格式转换指南》里也见过这种格式):

(module
  (func $i (import "my_namespace" "imported_func") (param i32))
  (func (export "exported_func")
    i32.const 42
    call $i))

看第二行,能发现这个导入有两层命名空间 —— 内部函数 $i 是从 my_namespace.imported_func 这里导入的。所以我们在 JavaScript 里写要导入到 Wasm 模块的对象时,也得体现出这种两层命名空间的结构。在 HTML 文件里加一个 <script></script> 标签,然后把下面这段代码放进去:

const importObject = {
  my_namespace: { imported_func: (arg) => console.log(arg) },
};

流式加载 WebAssembly 模块

Firefox 58 新增了一项功能 —— 能直接从底层源文件编译并实例化 WebAssembly 模块。实现这个功能要用到 WebAssembly.compileStreaming() 和 WebAssembly.instantiateStreaming() 这两个方法。它们比非流式的方法更方便,因为能直接把字节码转换成 Module/Instance 实例,不用额外把 Response 转成 ArrayBuffer 了。

这个例子(可以看 GitHub 上的 instantiate-streaming.html 演示代码,也能直接看在线效果)展示了怎么用 instantiateStreaming() 做这么几件事:获取 Wasm 模块、导入一个 JavaScript 函数进去、编译并实例化模块,最后调用它导出的函数 —— 所有步骤一步到位。

在脚本里刚才那段代码的下面,加这么一段:

WebAssembly.instantiateStreaming(fetch("simple.wasm"), importObject).then(
  (obj) => obj.instance.exports.exported_func(),
);

这么做的最终效果是:我们调用 Wasm 导出的函数 exported_func,这个函数又会调用我们导入的 JavaScript 函数 imported_func,最后把 Wasm 实例里定义的值(也就是 42)打印到控制台。现在把示例代码保存好,用支持 WebAssembly 的浏览器打开,就能看到效果啦!

注意:这个例子其实有点绕,做的事也很简单,但它能说明一件事 —— 网页应用里是可以让 WebAssembly 代码和 JavaScript 一起工作的。就像我们之前说的,WebAssembly 不是要取代 JavaScript,而是两者配合,各自发挥优势。

非流式加载 Wasm 模块

要是你没办法用或者不想用上面说的流式方法,也可以用非流式的 WebAssembly.compile() / WebAssembly.instantiate() 方法。

这两个方法不能直接读取字节码,所以得多一步操作:先把响应(response)转成 ArrayBuffer,之后再编译 / 实例化 Wasm 模块。

对应的代码长这样:

fetch("simple.wasm")
  .then((response) => response.arrayBuffer())
  .then((bytes) => WebAssembly.instantiate(bytes, importObject))
  .then((results) => {
    results.instance.exports.exported_func();
  });

在开发者工具里查看 Wasm

Firefox 54 及以上版本中,开发者工具的 “调试器” 面板有个功能:能显示网页里所有 Wasm 代码的文本格式。想看的话,打开 “调试器” 面板,点击 “wasm://” 这个条目就行。

除了能看 WebAssembly 的文本格式,开发者还能用这个文本格式调试 Wasm—— 比如打断点、查看调用栈、单步执行等等。

内存(Memory)

在 WebAssembly 的底层内存模型里,内存是用一块连续的无类型字节区域来表示的,叫做 “线性内存”(Linear Memory),模块里的加载(load)和存储(store)指令会对这块内存进行读写。在这个内存模型里,任何加载或存储操作都能访问线性内存里的任意字节,这一点很重要,因为要准确实现 C/C++ 里的指针这类概念,就需要这种特性。

不过,原生 C/C++ 程序的可用内存范围覆盖整个进程,而某个 WebAssembly 实例能访问的内存,却被限制在一个特定的 WebAssembly Memory 对象里 —— 这块内存可能还很小。这样设计有个好处:一个网页应用里如果用了多个独立的库(每个库内部都用了 WebAssembly),它们可以有各自独立的内存,互相完全隔离。另外,新版本的实现还支持创建 “共享内存”(shared memories),可以用 postMessage() 在窗口(Window)和工作线程(Worker)上下文之间传递,还能在多个地方使用。

在 JavaScript 里,Memory 实例可以理解成一个可调整大小的 ArrayBuffer(如果是共享内存,就是 SharedArrayBuffer)。而且和 ArrayBuffer 一样,一个网页应用能创建多个独立的 Memory 对象。创建方法是用 WebAssembly.Memory() 构造函数,参数要包含初始大小,还可以选填最大大小和一个 shared 属性(用来指定是不是共享内存)。

咱们先看个简单例子,快速上手。

新建一个简单的 HTML 页面(复制我们的简易模板就行),命名为 memory.html。在页面里加一个 <script></script> 标签。

然后在脚本最上面加这么一行,创建一个 Memory 实例:

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

这里 initial 和 maximum 的单位是 “WebAssembly 页”—— 每一页固定是 64KB。也就是说,上面这个 Memory 实例初始大小是 640KB,最大能到 6.4MB。

WebAssembly 内存通过一个 buffer getter/setter 暴露它的字节数据,这个 buffer 会返回一个 ArrayBuffer。比如想直接往线性内存的第一个 “字”(word)里写 42,可以这么做:

const data = new DataView(memory.buffer);
data.setUint32(0, 42, true);

注意这里用了 true,强制用小端序(little-endian)读写 —— 因为 WebAssembly 内存始终是小端序的。之后要读取这个值,就用:

data.getUint32(0, true);

现在在你的演示代码里试试这个功能吧 —— 把刚才加的代码保存好,用浏览器打开页面,然后在 JavaScript 控制台里输入上面这两行代码,看看效果。

扩展内存

调用 Memory.prototype.grow() 方法就能扩展 Memory 实例的大小,参数同样是 WebAssembly 页的数量:

memory.grow(1);

要是创建 Memory 实例的时候指定了最大大小,那试图扩展到超过这个最大值时,会抛出 RangeError 异常。引擎会利用这个预设的上限提前预留内存,这样调整大小的时候效率会更高。

注意:因为 ArrayBuffer 的 byteLength 是不可变的,所以成功调用 Memory.prototype.grow() 之后,buffer getter 会返回一个新的 ArrayBuffer 对象(字节长度是新的),而之前的所有 ArrayBuffer 对象都会变成 “分离状态”(detached)—— 也就是和它们之前指向的底层内存断开连接了。

和函数一样,线性内存既可以在模块内部定义,也可以从外部导入。同样,模块也可以选择导出它的内存。这意味着 JavaScript 有两种方式获取 WebAssembly 实例的内存:一种是创建一个新的 WebAssembly.Memory,作为导入项传进去;另一种是通过 Instance.prototype.exports 获取导出的 Memory。

更复杂的内存示例

咱们来看个更复杂的内存示例,把上面说的内容弄得更清楚 —— 这个 Wasm 模块会导入我们之前定义的 Memory 实例,往里面存一个整数数组,然后计算数组的和。这个模块的文件是 memory.wasm。

把 memory.wasm 下载到本地,存到之前的那个文件夹里。

注意:可以在 memory.wat 里看到这个模块的文本格式。

回到 memory.html 文件,像之前那样获取、编译并实例化这个 Wasm 模块 —— 在脚本的最下面加这么一段:

WebAssembly.instantiateStreaming(fetch("memory.wasm"), {
  js: { mem: memory },
}).then((results) => {
  // 在这里加代码
});

这个模块会导出它的内存,假设这个模块的实例叫 instance,我们可以用它导出的 accumulate() 函数,直接在模块实例的线性内存(mem)里创建并填充一个输入数组。在上面注释的位置,加这么一段代码:

const summands = new DataView(memory.buffer);
for (let i = 0; i < 10; i++) {
  summands.setUint32(i * 4, i, true);
}
const sum = results.instance.exports.accumulate(0, 10);
console.log(sum);

注意哦,我们是在 Memory 对象的 buffer(也就是 Memory.prototype.buffer)上创建 DataView 视图,而不是直接在 Memory 对象上创建。

内存导入和函数导入的用法差不多,区别只是传入的值是 Memory 对象,而不是 JavaScript 函数。内存导入有两个好处:

  1. 能让 JavaScript 在模块编译之前或者编译的同时,获取并初始化内存的初始内容;
  2. 能让多个模块实例导入同一个 Memory 对象 —— 这对实现 WebAssembly 的动态链接来说,是很关键的基础。

注意:完整的演示代码可以看 memory.html(也能看在线效果)。

表(Tables)

WebAssembly 表(Table)是一个可调整大小的 “引用类型数组”,JavaScript 和 WebAssembly 代码都能访问它。之前说的 Memory 是可调整大小的 “原始字节数组”,但把引用存在 Memory 里是不安全的 —— 引用是引擎信任的值,为了安全、可移植性和稳定性,不能让代码直接读写它的字节数据。

表有 “元素类型”(element type),这个类型会限制表里能存储的引用类型。在当前版本的 WebAssembly 里,代码只需要一种引用类型 —— 函数引用,所以目前只有一种有效的元素类型。未来的版本会增加更多元素类型。

函数引用对编译 C/C++ 这类有函数指针的语言来说很有必要。在 C/C++ 的原生实现里,函数指针是用函数代码在进程虚拟地址空间里的原始地址表示的 —— 出于上面说的安全原因,不能直接存在线性内存里。所以函数引用会存在表里,而传递的是它们的索引(索引是整数,能存在线性内存里)。

当需要调用函数指针的时候,WebAssembly 调用者会提供索引,引擎会先根据表的大小对这个索引做安全边界检查,确认没问题后再通过索引找到对应的函数引用并调用。所以目前来看,表是一种比较底层的结构,用来安全、可移植地编译底层编程语言的特性。

调用 Table.prototype.set() 可以修改表的值(更新表中的某个元素),调用 Table.prototype.grow() 可以扩展表的容量(增加表能存储的元素数量)。这样一来,能间接调用的函数集合就可以动态变化了 —— 这对动态链接技术来说是必需的。这些修改在 JavaScript 里通过 Table.prototype.get() 能立即获取到,WebAssembly 模块也能立即感知到。

表的示例

咱们看个简单的表示例 —— 这个 Wasm 模块会创建并导出一个表,表里有两个元素:索引 0 对应的函数返回 13,索引 1 对应的函数返回 42。这个模块的文件是 table.wasm。

把 table.wasm 下载到本地,存到一个新文件夹里。

注意:可以在 table.wat 里看到这个模块的文本格式。

在同一个文件夹里复制一份我们的 HTML 模板,命名为 table.html。

和之前一样,获取、编译并实例化这个 Wasm 模块 —— 在 HTML body 底部的 <script> 标签里加这么一段:

WebAssembly.instantiateStreaming(fetch("table.wasm")).then((results) => {
  // 在这里加代码
});

现在咱们来访问表里的数据 —— 在上面注释的位置加这么几行:

const tbl = results.instance.exports.tbl;
console.log(tbl.get(0)()); // 输出 13
console.log(tbl.get(1)()); // 输出 42

这段代码会依次访问表里存储的每个函数引用,然后调用它们,把返回值打印到控制台。注意哦,每个函数引用都是用 Table.prototype.get() 获取的,后面要多跟一对括号,才是真正调用这个函数。

注意:完整的演示代码可以看 table.html(也能看在线效果)。

全局变量(Globals)

WebAssembly 可以创建全局变量实例,JavaScript 能访问它,而且它还能在一个或多个 WebAssembly.Module 实例之间导入 / 导出。这个功能很有用,因为能实现多个模块的动态链接。

在 JavaScript 里创建 WebAssembly 全局变量实例,要用到 WebAssembly.Global() 构造函数,用法如下:

const global = new WebAssembly.Global({ value: "i32", mutable: true }, 0);

能看到它有两个参数:

  1. 一个对象,包含描述全局变量的两个属性:
    • value:数据类型,必须是 WebAssembly 模块支持的类型 ——i32、i64、f32 或 f64;
    • mutable:布尔值,指定这个值是不是可修改的;
  2. 一个值,就是全局变量的初始值。这个值的类型必须和上面指定的 value 类型一致。

那具体怎么用呢?下面这个例子里,我们定义了一个全局变量:类型是可修改的 i32,初始值是 0。

然后修改这个全局变量的值:先用 Global.value 属性把它改成 42,再用 global.wasm 模块导出的 incGlobal() 函数把它改成 43(这个函数会给传入的值加 1,然后返回新值)。

const output = document.getElementById("output");

function assertEq(msg, got, expected) {
  const result =
    got === expected
      ? `成功!实际值:${got}\n`
      : `失败!\n实际值:${got}\n预期值:${expected}\n`;
  output.innerText += `测试 "${msg}":${result}`;
}

assertEq("WebAssembly.Global 存在", typeof WebAssembly.Global, "function");

const global = new WebAssembly.Global({ value: "i32", mutable: true }, 0);

WebAssembly.instantiateStreaming(fetch("global.wasm"), { js: { global } }).then(
  ({ instance }) => {
    assertEq(
      "从 wasm 中获取初始值",
      instance.exports.getGlobal(),
      0,
    );
    global.value = 42;
    assertEq(
      "从 wasm 中获取 JS 修改后的值",
      instance.exports.getGlobal(),
      42,
    );
    instance.exports.incGlobal();
    assertEq("从 JS 中获取 wasm 修改后的值", global.value, 43);
  },
);

注意:可以在 GitHub 上看这个例子的在线效果,也能看源码。

多实例共享机制(Multiplicity)

我们已经演示了 WebAssembly 主要核心组件的用法,现在可以提一下 “多实例共享机制” 这个概念了。它能让 WebAssembly 在架构效率上有很多提升,简单说就是不同模块实例如何共享内存、表等资源:

  1. 一个模块(Module)可以有 N 个实例(Instance)—— 就像一个函数字面量能生成 N 个闭包值一样;
  2. 一个模块实例可以使用 0 到 1 个内存实例 —— 这个内存实例就是实例的 “地址空间”。未来版本的 WebAssembly 可能会支持一个模块实例使用 0 到 N 个内存实例(可以看《多内存支持》文档);
  3. 一个模块实例可以使用 0 到 1 个表实例 —— 这个表实例就是实例的 “函数地址空间”,用来实现 C 语言的函数指针。未来版本可能会支持一个模块实例使用 0 到 N 个表实例;
  4. 一个内存或表实例可以被 0 到 N 个模块实例使用 —— 这些实例会共享同一个地址空间,这样就能实现动态链接了。

想看看多实例共享机制的实际效果,可以看《理解 WebAssembly 文本格式》这篇文章 —— 里面的 “修改表与动态链接” 部分有演示。

总结

这篇文章带你过了一遍 WebAssembly JavaScript API 的基础用法:包括怎么在 JavaScript 环境里引入 WebAssembly 模块、调用它的函数,以及怎么在 JavaScript 里使用 WebAssembly 的内存和表。我们还简单提了一下多实例共享机制的概念。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐