使用 WebAssembly JavaScript API

如果您已经使用 Emscripten 等工具从其他语言编译了一个模块,或自己加载并运行了代码,下一步就是学习更多关于使用 WebAssembly JavaScript API 的其他功能。本文将教你你需要了解的内容。

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

一些简单的示例

让我们浏览一些示例,这些示例说明了如何使用 WebAssembly JavaScript API,以及如何使用它在网页中加载 Wasm 模块。

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

准备示例

  1. 首先我们需要一个 Wasm 模块!获取我们的simple.wasm 文件并在本地计算机上的新目录中保存副本。
  2. 接下来,让我们在与 Wasm 文件相同的目录中创建一个名为 index.html 的简单 HTML 文件(如果您没有很容易获得的 HTML 文件,可以使用我们的简单模板)。
  3. 现在,为了帮助我们理解这里发生了什么,让我们看看 Wasm 模块的文本表示(我们也在将 WebAssembly 格式转换为 Wasm中遇到过)。
    wasm
    (module
      (func $i (import "my_namespace" "imported_func") (param i32))
      (func (export "exported_func")
        i32.const 42
        call $i))
    
  4. 在第二行,您将看到导入具有两级命名空间——内部函数 $imy_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)中提供的 value 值记录到控制台。如果您现在保存示例代码并在支持 WebAssembly 的浏览器中加载它,您将看到它的运行效果!

注意:这是一个复杂且冗长的示例,它实现的功能很少,但它确实说明了可能性——在 Web 应用程序中将 WebAssembly 代码与 JavaScript 一起使用。正如我们在其他地方所说的,WebAssembly 的目标不是取代 JavaScript;两者可以协同工作,利用彼此的优势。

在不进行流式传输的情况下加载我们的 Wasm 模块

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

这些方法不会直接访问字节码,因此需要额外的步骤才能将响应转换为ArrayBuffer,然后才能编译/实例化 Wasm 模块。

等效代码如下所示

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 内存对象包含的一个特定(可能非常小)范围。这允许单个 Web 应用程序使用多个独立的库(每个库在内部使用 WebAssembly)来拥有彼此完全隔离的单独内存。此外,较新的实现还可以创建共享内存,这些内存可以使用postMessage()在 Window 和 Worker 上下文之间传输,并在多个位置使用。

在 JavaScript 中,内存实例可以被认为是可调整大小的ArrayBuffer(或SharedArrayBuffer,在共享内存的情况下),并且与 ArrayBuffers 一样,单个 Web 应用程序可以创建许多独立的内存对象。您可以使用WebAssembly.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) 上创建 DataView 视图,而不是在 Memory 本身上。

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

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

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

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

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

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

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

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

表格示例

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

  1. 在新的目录中创建 table.wasm 的本地副本。

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

  2. 在同一目录中创建我们 HTML 模板 的新副本,并将其命名为 table.html
  3. 与之前一样,获取、编译并实例化您的 Wasm 模块 - 将以下内容添加到 HTML 主体底部的 <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:一个布尔值,用于定义该值是否可变。
  • 一个包含变量实际值的 value。只要其类型与指定的数据类型匹配,它就可以是任何值。

那么我们如何使用它呢?在以下示例中,我们将全局变量定义为可变的 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 在 JavaScript 上下文中包含 WebAssembly 模块并使用其函数的基本知识,以及如何在 JavaScript 中使用 WebAssembly 内存和表格。我们还简要介绍了多重性的概念。

另请参阅