使用 WebAssembly JavaScript API

如果您已经使用 Emscripten 等工具从其他语言编译了一个模块,或者自行加载并运行了代码,那么下一步就是学习如何使用 WebAssembly JavaScript API 的其他功能。本文将教您所需了解的知识。

注意:如果您不熟悉本文中提到的基本概念,需要更多解释,请先阅读WebAssembly 概念,然后再回来阅读本文。

一些示例

让我们通过一些示例来解释如何使用 WebAssembly JavaScript API,以及如何在网页中加载 Wasm 模块。

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

准备示例

  1. 首先我们需要一个 Wasm 模块!获取我们的 simple.wasm 文件并将其副本保存在本地机器上的一个新目录中。

  2. 接下来,在与 Wasm 文件相同的目录中创建一个名为 index.html 的简单 HTML 文件(如果没有方便可用的,可以使用我们的 简单模板)。

  3. 现在,为了帮助我们理解这里发生了什么,让我们看一下 Wasm 模块的文本表示(我们在将 WebAssembly 格式转换为 Wasm 中也曾遇到过)

    wat
    (module
      (func $i (import "my_namespace" "imported_func") (param i32))
      (func (export "exported_func")
        i32.const 42
        call $i))
    
  4. 在第二行,您会看到导入具有两级命名空间——内部函数 $i 是从 my_namespace.imported_func 导入的。在编写要导入到 Wasm 模块中的对象时,我们需要在 JavaScript 中反映这种两级命名空间。在您的 HTML 文件中创建一个 <script></script> 元素,并向其中添加以下代码

    js
    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 函数导入其中,编译并实例化它,以及访问其导出的函数——所有这些都在一步完成。

将以下内容添加到您的脚本中,在第一个代码块下方

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

最终结果是,我们调用了导出的 WebAssembly 函数 exported_func,该函数又调用了导入的 JavaScript 函数 imported_func,该函数将 WebAssembly 实例中提供的值 (42) 记录到控制台。如果您现在保存示例代码并在支持 WebAssembly 的浏览器中加载它,您将看到它的实际效果!

注意:这是一个复杂的、冗长的示例,几乎没有实现任何功能,但它确实说明了可能性——在您的 Web 应用程序中将 WebAssembly 代码与 JavaScript 一起使用。正如我们所说的,WebAssembly 旨在不替代 JavaScript;两者可以协同工作,相互借鉴优势。

不使用流式传输加载我们的 Wasm 模块

如果您不能或不想使用上面描述的流式方法,则可以使用非流式方法 WebAssembly.compile() / WebAssembly.instantiate()

这些方法不直接访问字节码,因此在编译/实例化 Wasm 模块之前,需要额外一步将响应转换为 ArrayBuffer

等效的代码如下所示

js
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://”条目。

Developer tools debugger panel highlighting a module.

除了以文本形式查看 WebAssembly,开发人员还可以使用文本格式调试 WebAssembly(设置断点、检查调用堆栈、单步执行等)。

内存

在 WebAssembly 的低级内存模型中,内存表示为一段连续的无类型字节范围,称为线性内存,模块内部的加载和存储指令会对其进行读写。在这个内存模型中,任何加载或存储都可以访问整个线性内存中的任何字节,这对于忠实地表示 C/C++ 概念(如指针)是必需的。

然而,与原生 C/C++ 程序不同,原生 C/C++ 程序的可用内存范围跨越整个进程,WebAssembly 实例可访问的内存被限制在一个特定的(可能非常小)范围,该范围由 WebAssembly Memory 对象包含。这允许单个 Web 应用程序使用多个独立的库(每个库在内部使用 WebAssembly),以拥有彼此完全隔离的独立内存。此外,更新的实现还可以创建共享内存,可以通过 postMessage() 在 Window 和 Worker 上下文之间传输,并在多个地方使用。

在 JavaScript 中,一个 Memory 实例可以被看作是一个可调整大小的 ArrayBuffer(或者在共享内存的情况下,是一个 SharedArrayBuffer),并且与 ArrayBuffer 一样,单个 Web 应用程序可以创建许多独立的 Memory 对象。您可以使用 WebAssembly.Memory() 构造函数创建一个 Memory 实例,该构造函数接受初始大小和(可选地)最大大小以及一个 shared 属性作为参数,该属性指示它是否是共享内存。

让我们从一个快速示例开始探索。

  1. 创建另一个新的简单 HTML 页面(复制我们的简单模板),并将其命名为 memory.html。向页面添加一个 <script></script> 元素。

  2. 现在将以下行添加到脚本顶部,以创建内存实例

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

    initialmaximum 的单位是 WebAssembly 页面——它们的大小固定为 64KB。这意味着上述内存实例的初始大小为 640KB,最大大小为 6.4MB。

    WebAssembly 内存通过提供一个返回 ArrayBuffer 的缓冲区 getter/setter 来公开其字节。例如,要将 42 直接写入线性内存的第一个字,您可以这样做

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

    请注意使用 true,这强制使用小端序读写,因为 WebAssembly 内存始终是小端序。然后可以使用以下方法返回相同的值

    js
    data.getUint32(0, true);
    
  3. 现在在您的演示中尝试一下——保存您目前添加的内容,在浏览器中加载它,然后尝试在 JavaScript 控制台中输入上面两行。

增长内存

内存实例可以通过调用 Memory.prototype.grow() 来增长,其中参数再次以 WebAssembly 页面的单位指定。

js
memory.grow(1);

如果在创建内存实例时提供了最大值,则尝试超出此最大值进行增长将抛出 RangeError 异常。引擎会利用这个提供的上限预留内存,这可以使调整大小更高效。

注意:由于 ArrayBuffer 的 byteLength 是不可变的,所以在成功执行 Memory.prototype.grow() 操作后,缓冲区 getter 将返回一个新的 ArrayBuffer 对象(具有新的 byteLength),并且任何先前的 ArrayBuffer 对象都将“分离”,或与它们之前指向的底层内存断开连接。

就像函数一样,线性内存可以在模块内部定义或导入。同样,模块也可以选择性地导出其内存。这意味着 JavaScript 可以通过创建新的 WebAssembly.Memory 并将其作为导入传入,或者通过接收内存导出(通过 Instance.prototype.exports)来访问 WebAssembly 实例的内存。

更复杂的内存示例

让我们通过一个更复杂的内存示例来阐明上述断言——一个 WebAssembly 模块导入我们之前定义的内存实例,用一个整数数组填充它,然后对它们求和。您可以在memory.wasm找到它。

  1. 在与之前相同的目录中制作 memory.wasm 的本地副本。

    注意:您可以在memory.wat查看模块的文本表示。

  2. 回到您的 memory.html 示例文件,并像以前一样获取、编译和实例化您的 Wasm 模块——将以下内容添加到脚本底部

    js
    WebAssembly.instantiateStreaming(fetch("memory.wasm"), {
      js: { mem: memory },
    }).then((results) => {
      // add code here
    });
    
  3. 由于此模块导出了其内存,给定此模块的一个实例(称为 instance),我们可以使用导出的函数 accumulate() 直接在模块实例的线性内存 (mem) 中创建和填充输入数组。将以下内容添加到您的代码中,在指定位置

    js
    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 对象的缓冲区上(Memory.prototype.buffer),而不是在 Memory 本身创建 DataView 视图的。

内存导入的工作原理与函数导入类似,只是 Memory 对象作为值传递而不是 JavaScript 函数。内存导入有两个有用的原因

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

注意:您可以在memory.html找到我们的完整演示(也可在线查看)。

表格

WebAssembly Table 是一个可调整大小的引用类型数组,可由 JavaScript 和 WebAssembly 代码访问。虽然 Memory 提供了一个可调整大小的原始字节类型数组,但将引用存储在 Memory 中是不安全的,因为引用是引擎信任的值,出于安全、可移植性和稳定性原因,其字节不能由内容直接读取或写入。

表格具有一个元素类型,它限制了可以存储在表格中的引用类型。在 WebAssembly 的当前迭代中,WebAssembly 代码只需要一种引用类型——函数——因此只有一种有效的元素类型。在未来的迭代中,将添加更多元素类型。

函数引用对于编译像 C/C++ 这样具有函数指针的语言是必需的。在 C/C++ 的本地实现中,函数指针由进程虚拟地址空间中函数代码的原始地址表示,因此,出于上述安全原因,不能直接存储在线性内存中。相反,函数引用存储在表中,它们的索引(整数,可以存储在线性内存中)被传递。

当需要调用函数指针时,WebAssembly 调用者提供索引,然后可以安全地对照表进行边界检查,然后再索引和调用索引的函数引用。因此,表目前是一个相当底层的原语,用于安全且可移植地编译低级编程语言特性。

表格可以通过 Table.prototype.set() 进行修改,该方法更新表格中的一个值,以及 Table.prototype.grow(),该方法增加可以存储在表格中的值的数量。这允许间接可调用函数集随时间变化,这对于动态链接技术是必需的。这些修改可以通过 JavaScript 中的 Table.prototype.get() 和 Wasm 模块立即访问。

表格示例

让我们看一个简单的表格示例——一个 WebAssembly 模块,它创建并导出一个包含两个元素的表格:元素 0 返回 13,元素 1 返回 42。您可以在 table.wasm 找到它。

  1. 在新目录中制作 table.wasm 的本地副本。

    注意:您可以在table.wat查看模块的文本表示。

  2. 在同一目录中创建一个我们HTML 模板的新副本,并将其命名为 table.html

  3. 和以前一样,获取、编译和实例化您的 Wasm 模块——将以下内容添加到您的 HTML body 底部的 <script> 元素中

    js
    WebAssembly.instantiateStreaming(fetch("table.wasm")).then((results) => {
      // add code here
    });
    
  4. 现在让我们访问表格中的数据——将以下行添加到您的代码中,在指定位置

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

此代码依次访问存储在表格中的每个函数引用,并实例化它们以将它们持有的值打印到控制台——请注意每个函数引用是如何通过 Table.prototype.get() 调用检索的,然后我们添加了一组额外的括号来实际调用该函数。

注意:您可以在table.html找到我们的完整演示(也可在线查看)。

全局变量

WebAssembly 能够创建全局变量实例,这些实例可以从 JavaScript 访问,并且可以在一个或多个 WebAssembly.Module 实例之间导入/导出。这非常有用,因为它允许动态链接多个模块。

要从 JavaScript 中创建 WebAssembly 全局实例,您可以使用 WebAssembly.Global() 构造函数,它看起来像这样

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

您可以看到这需要两个参数

  • 一个包含两个属性的对象,描述全局变量

    • value:其数据类型,可以是 WebAssembly 模块中接受的任何数据类型——i32i64f32f64
    • mutable:一个布尔值,定义该值是否可变。
  • 一个包含变量实际值的值。这可以是任何值,只要其类型与指定的数据类型匹配。

那么我们如何使用它呢?在下面的示例中,我们将一个全局变量定义为可变的 i32 类型,值为 0。

然后,全局变量的值被更改,首先使用 Global.value 属性更改为 42,然后使用从 global.wasm 模块导出的 incGlobal() 函数更改为 43(该函数将 1 添加到给定值,然后返回新值)。

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

function assertEq(msg, got, expected) {
  const result =
    got === expected
      ? `SUCCESS! Got: ${got}\n`
      : `FAIL!\nGot: ${got}\nExpected: ${expected}\n`;
  output.innerText += `Testing ${msg}: ${result}`;
}

assertEq("WebAssembly.Global exists", typeof WebAssembly.Global, "function");

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

WebAssembly.instantiateStreaming(fetch("global.wasm"), { js: { global } }).then(
  ({ instance }) => {
    assertEq(
      "getting initial value from wasm",
      instance.exports.getGlobal(),
      0,
    );
    global.value = 42;
    assertEq(
      "getting JS-updated value from wasm",
      instance.exports.getGlobal(),
      42,
    );
    instance.exports.incGlobal();
    assertEq("getting wasm-updated value from JS", global.value, 43);
  },
);

注意:您可以在 GitHub 上实时运行示例;另请参阅源代码

多重性

现在我们已经演示了主要 WebAssembly 构建块的用法,这是一个提及多重性概念的好地方。这为 WebAssembly 在架构效率方面提供了许多进步

  • 一个模块可以有 N 个实例,就像一个函数字面量可以产生 N 个闭包值一样。
  • 一个模块实例可以使用 0-1 个内存实例,这些实例提供实例的“地址空间”。WebAssembly 的未来版本可能允许每个模块实例有 0-N 个内存实例(参见多重内存)。
  • 一个模块实例可以使用 0-1 个表格实例——这是实例的“函数地址空间”,用于实现 C 函数指针。WebAssembly 的未来版本可能允许每个模块实例有 0-N 个表格实例。
  • 一个内存或表格实例可以被 0-N 个模块实例使用——这些实例都共享相同的地址空间,从而允许动态链接

您可以在我们的《理解文本格式》文章中看到多重性的实际应用——请参阅修改表格和动态链接部分

总结

本文带您了解了使用 WebAssembly JavaScript API 将 WebAssembly 模块包含在 JavaScript 上下文中并利用其函数的基本知识,以及如何在 JavaScript 中使用 WebAssembly 内存和表格。我们还触及了多重性概念。

另见