08 API:在 WebAssembly MVP 标准下你能做到哪些事? 你好,我是于航。

在目前与 Wasm 相关的一系列标准中,我们可以将这些标准主要分为两个部分:“Wasm 核心标准(Core Interfaces)”以及“嵌入接口标准(Embedding interfaces)”。

其中,“Wasm 核心标准”主要定义了与 “Wasm 字节码”、“Wasm 模块结构”、“WAT 可读文本格式”以及模块验证与指令执行细节等相关的内容。关于这部分标准中的内容,我在前面几节课中,已经有选择性地为你挑选了部分重点进行解读。

而另一个标准“嵌入接口标准”,则定义了有关 Wasm 在 Web 平台上,在与浏览器进行交互时所需要使用的相关 Web 接口以及 JavaScript 接口。在本节课里,我们将讨论有关于这些 API 接口的内容。相信在学完本节课后你便会知道,在当前的 MVP 标准下,我们能够使用 Wasm 在 Web 平台上做些什么?哪些又是 Wasm 暂时无法做到的?

Wasm 浏览器加载流程

那在开始真正讲解这些 API 之前,我们先来看一看,一个 Wasm 二进制模块需要经过怎样的流程,才能够最终在 Web 浏览器中被使用。你可以参考一下我画的这张图,这些流程可以被粗略地划分为以下四个阶段。

首先是 “Fetch” 阶段。作为一个客户端 Web 应用,在这个阶段中,我们需要将被使用到的 Wasm 二进制模块,从网络上的某个位置通过 HTTP 请求的方式,加载到浏览器中。

这个 Wasm 二进制模块的加载过程,同我们日常开发的 Web 应用在浏览器中加载 JavaScript 脚本文件等静态资源的过程,没有任何区别。对于 Wasm 模块,你也可以选择将它放置到 CDN 中,或者经由 Service Worker 缓存,以加速资源的下载和后续使用过程。

接下来是 “Compile” 阶段。在这个阶段中,浏览器会将从远程位置获取到的 Wasm 模块二进制代码,编译为可执行的平台相关代码和数据结构。这些代码可以通过 “postMessage()” 方法,在各个 Worker 线程中进行分发,以让 Worker 线程来使用这些模块,进而防止主线程被阻塞。此时,浏览器引擎只是将 Wasm 的字节码编译为平台相关的代码,而这些代码还并没有开始执行。

紧接着便是最为关键的 “Instantiate” 阶段。在这个阶段中,浏览器引擎开始执行在上一步中生成的代码。在前面的几节课中我们曾介绍过,Wasm 模块可以通过定义 “Import Section” 来使用外界宿主环境中的一些资源。

在这一阶段中,浏览器引擎在执行 Wasm 模块对应的代码时,会将那些 Wasm 模块规定需要从外界宿主环境中导入的资源,导入到正在实例化中的模块,以完成最后的实例化过程。这一阶段完成后,我们便可以得到一个动态的、保存有状态信息的 Wasm 模块实例对象。

最后一步便是 “Call”。顾名思义,在这一步中,我们便可以直接通过上一阶段生成的动态 Wasm 模块对象,来调用从 Wasm 模块内导出的方法。

接下来,我们将围绕上述流程中的第二步 “Compile 编译” 与第三步 “Instantiate 实例化”,来分别介绍与这两个阶段相关的一些 JavaScript API 与 Web API。

Wasm JavaScript API

模块对象

映入眼帘的第一个问题就是,我们如何在 JavaScript 环境中表示刚刚说过的 “Compile 编译” 与 “Instantiate 实例化” 这两个阶段的“产物”?为此,Wasm 在 JavaScript API 标准中为我们提供了如下两个对象与之分别对应:

  • WebAssembly.Module
  • WebAssembly.Instance

不仅如此,上面这两个 JavaScript 对象本身也可以被作为类型构造函数使用,以用来直接构造对应类型的对象。也就是说,我们可以通过 “new” 的方式并传入相关参数,来构造这些类型的某个具体对象。比如,可以按照以下方式来生成一个 WebAssembly.Module 对象: // “…” 为有效的 Wasm 字节码数据; bufferSource = new Int8Array([…]); let module = new WebAssembly.Module(bufferSource);

这里的 WebAssembly.Module 构造函数接受一个包含有效 Wasm 二进制字节码的 ArrayBuffer 或者 TypedArray 对象。

WebAssembly.Instance 构造函数的用法与 WebAssembly.Module 类似,只不过是构造函数的参数有所区别。更详细的 API 使用信息,你可以点击这里进行参考。

导入对象

我们曾在之前的课程中介绍过 Wasm 二进制模块内部 “Import Section” 的作用。通过这个 Section,模块便可以在实例化时接收并使用来自宿主环境中的数据。

Web 浏览器作为 Wasm 模块运行时的一个宿主环境,通过 JavaScript 的形式提供了可以被导入到 Wasm 模块中使用的数据类型,这些数据类型包括函数(Function)、全局数据(Global)、线性内存对象(Memory)以及 Table 对象(Table)。其中除“函数”类型外,其他数据类型分别对应着以下由 JavaScript 对象表示的包装类型:

  • WebAssembly.Global
  • WebAssembly.Memory
  • WebAssembly.Table

而对于函数类型,我们可以直接使用 JavaScript 语言中的“函数”来作为代替。

同理,我们也可以通过“直接构造”的方式来创建上述这些 JavaScript 对象。以 “WebAssembly.Memory” 为例,我们可以通过如下方式,来创建一个 WebAssembly.Memory 对象: let memory = new WebAssembly.Memory({ initial:10, maximum:100, });

这里我们通过为构造函数传递参数的方式,指定了所生成 WebAssembly.Memory 对象的一些属性。比如该对象所表示的 Wasm 线性内存其初始大小为 10 页,其最大可分配大小为 100 页。

需要注意的是,Wasm 线性内存的大小必须是 “Wasm 页” 大小的整数倍,而一个 “Wasm 页” 的大小在 MVP 标准中被定义为了 “64KiB”(注意和 64 KB 的区别。KiB 为 1024 字节,而 KB 为 1000 字节)。

关于另外的 WebAssembly.Global 与 WebAssembly.Table 这两个类型所对应构造函数的具体使用方式,你可以点击这里进行参考。

错误对象

除了上述我们介绍的几个比较重要的 JavaScript WebAssembly 对象之外,还有另外几个与 “Error” 有关的表示某种错误的 “错误对象”。这些错误对象用以表示在整个 Wasm 加载、编译、实例化及函数执行流程中,在其各个阶段中所发生的错误。这些错误对象分别是:

  • WebAssembly.CompileError 表示在 Wasm 模块编译阶段(Compile)发生的错误,比如模块的字节码编码格式错误、魔数不匹配
  • WebAssembly.LinkError 表示在 Wasm 模块实例化阶段(Instantiate)发生的错误,比如导入到 Wasm 模块实例 Import Section 的内容不正确
  • WebAssembly.RuntimeError 表示在 Wasm 模块运行时阶段(Call)发生的错误,比如常见的“除零异常”

上面这些错误对象也都有对应的构造函数,可以用来构造对应的错误对象。(同样,如果有需要,你可以点击这里进入 MDN 网站参考一下)

模块实例化方法

最后一个需要重点介绍的 JavaScript API 主要用来实例化一个 Wasm 模块对象。该方法的原型如下所示:

  • WebAssembly.instantiate(bufferSource, importObject)

这个方法接受一个包含有效 Wasm 模块二进制字节码的 ArrayBuffer 或 TypedArray 对象,然后返回一个将被解析为 WebAssembly.Module 的 Promise 对象。就像我上面讲的那样,这里返回的 WebAssembly.Module 对象,代表着一个被编译完成的 Wasm 静态模块对象。

整个方法接受两个参数。除第一个参数对应的 ArrayBuffer 或 TypedArray 类型外,第二个参数为一个 JavaScript 对象,在其中包含有需要被导入到 Wasm 模块实例中的数据,这些数据将通过 Wasm 模块的 “Import Section” 被导入到模块实例中使用。

方法在调用完成后会返回一个将被解析为 ResultObject 的 Promise 对象。ResultObject 对象包含有两个字段 ,分别是 “module” 以及 “instance”。

其中 module 表示一个被编译好的 WebAssembly.Module 静态对象;instance 表示一个已经完成实例化的 WebAssembly.Instance 动态对象。所有从 Wasm 模块中导出的方法,都被“挂载”在这个 ResultObject 对象上。

基于这个方法实现的 Wasm 模块初始化流程如下图所示。你可以看到,整个流程是完全串行的。

需要注意的是,WebAssembly.instantiate 方法还有另外的一个重载形式,也就是其第一个参数类型从含有 Wasm 模块字节码数据的 bufferSource,转变为已经编译好的静态 WebAssembly.Module 对象。这种重载形式通常用于 WebAssembly.Module 对象已经被提前编译好的情况。

模块编译方法

上面讲到的 WebAssembly.instantiate 方法,主要用于从 Wasm 字节码中一次性进行 Wasm 模块的编译和实例化过程,而这通常是我们经常使用的一种形式。当然你也以将编译和实例化两个步骤分开来进行。比如单独对于编译阶段,你可以使用下面这个 JavaScript API:

  • WebAssembly.compile(bufferSource)

该方法接收一个含有有效 Wasm 字节码数据的 bufferSource,也就是 ArrayBuffer 或者 TypedArray 对象。返回的 Promise 对象在 Resolve 后,会返回一个编译好的静态 WebAssembly.Module 对象。

Wasm Web API

Wasm 的 JavaScript API 标准,主要定义了一些与 Wasm 相关的类型和操作,这些类型和操作与具体的平台无关。为了能够在最大程度上利用 Web 平台的一些特性,来加速 Wasm 模块对象的编译和实例化过程,Wasm 标准又通过添加 Wasm Web API 的形式,为 Web 平台上的 Wasm 相关操作提供了新的、高性能的编译和实例化接口。

模块流式实例化方法

不同于 JavaScript API 中的 WebAssembly.instantiate 方法,Web API 中定义的“流式接口”可以让我们提前开始对 Wasm 模块进行编译和实例化过程,你也可以称此方式为“流式编译”。比如下面这个 API 便对应着 Wasm 模块的“流式实例化”接口:

  • WebAssembly.instantiateStreaming(source, importObject)

为了能够支持“流式编译”,该方法的第一个参数,将不再需要已经从远程加载好的完整 Wasm 模块二进制数据(bufferSource)。取而代之的,是一个尚未 Resolve 的 Response 对象。

Response 对象(window.fetch 调用后的返回结果)是 Fetch API 的重要组成部分,这个对象代表了某个远程 HTTP 请求的响应数据。而该方法中第二个参数所使用的 Response 对象,则必须代表着对某个位于远程位置上的 Wasm 模块文件的请求响应数据。

通过这种方式,Web 浏览器可以在从远程位置开始加载 Wasm 模块文件数据的同时,也一并启动对 Wasm 模块的编译和初始化工作。相较于上一个 JavaScript API 需要在完全获取 Wasm 模块文件二进制数据后,才能够开始进行编译和实例化流程的方式,流式编译无疑在某种程度上提升了 Web 端运行 Wasm 应用的整体效率。

基于流式编译进行的 Wasm 模块初始化流程如下图所示。可以看到,与之前 API 有所不同的是,Wasm 模块的编译和初始化可以提前开始,而不用再等待模块的远程加载完全结束。因此应用的整体初始化时间也会有所减少。

模块流式编译方法

那么既然存在着模块的“流式实例化方法”,便也存在着“流式编译方法”。如下所示:

  • WebAssembly.compileStreaming(source)

该 API 的使用方式与 WebAssembly.instantiateStreaming 类似,第一个参数为 Fetch API 中的 Response 对象。API 调用后返回的 Promise 对象在 Resolve 之后,会返回一个编译好的静态 WebAssembly.Module 对象。

同 Wasm 模块的“流式实例化方法”一样,“流式编译方法”也可以在浏览器加载 Wasm 二进制模块文件的同时,提前开始对模块对象的编译过程。

Wasm 运行时(Runtime)

这里提到的“运行时”呢,主要存在于我们开头流程图中的 “Call” 阶段。在这个阶段中,我们可以调用从 Wasm 模块对象中导出的函数。每一个经过实例化的 Wasm 模块对象,都会在运行时维护自己唯一的“调用栈”。

所有模块导出函数的实际调用过程,都会影响着栈容器中存放的数据,这些数据代表着每条 Wasm 指令的执行结果。当然,这些结果也同样可以被作为导出函数的返回值。

调用栈一般是“不透明”的。也就是说,我们无法通过任何 API 或者方法直接接触到栈容器中存放的数据。因此,这也是 Wasm 保证执行安全的众多因素之一。

除了调用栈,每一个实例化的 Wasm 模块对象都有着自己的(在 MVP 下只能有一个)线性内存段。在这个内存段中,以二进制形式存放着 Wasm 模块可以使用的所有数据资源。

这些资源可以是来自于对 Wasm 模块导出方法调用后的结果,即通过 Wasm 模块内的相关指令对线性内存中的数据进行读写操作;也可以是在进行模块实例化时,我们将预先填充好的二进制数据资源以 WebAssembly.Memory 导入对象的形式,提前导入到模块实例中进行使用。

浏览器在为 Wasm 模块对象分配线性内存时,会将这部分内存与 JavaScript 现有的内存区域进行隔离,并单独管理,你可以参考我下面给你画的这张图。在以往的 JavaScript Memory 中,我们可以存放 JavaScript 中的一些数据类型,这些数据同时也可以被相应的 JavaScript / Web API 直接访问。而当数据不再使用时,它们便会被 JavaScript 引擎的 GC 进行垃圾回收。

相反,图中绿色部分的 WebAssembly Memory 则有所不同。这部分内存可以被 Wasm 模块内部诸如 “i32.load” 与 “i32.store” 等指令直接使用,而外部浏览器宿主中的 JavaScript / Web API 则无法直接进行访问。不仅如此,分配在这部分内存区域中的数据,受限于 MVP 中尚无 GC 相关的标准,因此需要 Wasm 模块自行进行清理和回收。

Wasm 的内存访问安全性是众多人关心的一个话题。事实上你并不用担心太多,因为当浏览器在执行 “i32.load” 与 “i32.store” 这些内存访问指令时,会首先检查指令所引用的内存地址偏移,是否超出了 Wasm 模块实例所拥有的内存地址范围。若引用地址不在上图中绿色范围以内,则会终止指令的执行,并抛出相应的异常。这个检查过程我们一般称之为 “Bound Check”。

那么,接下来我们再把目光移到 WebAssembly Memory 身上,来看一看它是如何与“浏览器”这个 Web 宿主环境中的 JavaScript API 进行交互的。

Wasm 内存模型

根据之前课程所讲的内容,我们知道,每一个 Wasm 模块实例都有着自己对应的线性内存段。准确来讲,也就是由 “Memory Section” 和 “Data Section” 共同“描述”的一个线性内存区域。在这个区域中,以二进制形式存放着模块所使用到的各种数据资源。

事实上,每一个 Wasm 实例所能够合法访问的线性内存范围,仅限于我们上面讲到的这一部分内存段。对于宿主环境中的任何变量数据,如果 Wasm 模块实例想要使用,一般可以通过以下两种常见的方式:

  • 对于简单(字符 \ 数字值等)数据类型,可以选择将其视为全局数据,通过 “Import Section” 导入到模块中使用;
  • 对于复杂数据,需要将其以“字节”的形式,拷贝到模块实例的线性内存段中来使用。

在 Web 浏览器这个宿主环境中,一个内存实例通常可以由 JavaScript 中的 ArrayBuffer 类型来进行表示。ArrayBuffer 中存放的是原始二进制数据,因此在需要读写这段数据时,我们必须指定一个“操作视图(View)”。你可以把“操作视图”理解为,在对这些二进制数据进行读写操作时,数据的“解读方式”。

举个例子,假设我们想要将字符串 “Hello, world!” ,按照逐个字符的方式写入到线性内存段中,那么在进行写操作时,我们如何知道一个字符所应该占用的数据大小呢?

根据实际需要,一个字符可能会占用 1 个字节到多个字节不等的大小。而这个“占用大小”便是我们之前提到的数据“解读方式”。在 JavaScript 中,我们可以使用 TypedArray 以某个具体类型作为视图,来操作 ArrayBuffer 中的数据。

你可以通过下面这张图,来理解一下我们刚刚说的 Wasm 模块线性内存与 Web 浏览器宿主环境,或者说与 JavaScript 之间的互操作关系。

当我们拥有了填充好数据的 ArrayBuffer 或 TypedArray 对象时,便可以构造自己的 WebAssembly.Memory 导入对象。然后在 Wasm 模块进行实例化时,将该对象导入到模块中,来作为模块实例的线性内存段进行使用。

局限性

一切看起来好像都还不错,但我们现在再来回味一下 MVP 的全称。MVP 全称为 “Minimum Viable Product”,翻译过来是“最小可用产品”。那既然是“最小可用”,当然也就意味着它还有很多的不足。我给你总结了一下,目前可以观测到的“局限性”主要集中在以下几个方面:

  • 无法直接引用 DOM

在 MVP 标准下,我们无法直接在 Wasm 二进制模块内引用外部宿主环境中的“不透明”(即数据内部的实际结构和组成方式未知)数据类型,比如 DOM 元素。

因此目前通常的一种间接实现方式是使用 JavaScript 函数来封装相应的 DOM 操作逻辑,然后将该函数作为导入对象,导入到模块中,由模块在特定时机再进行间接调用来使用。但相对来说,这种借助 JavaScript 的间接调用方式,在某种程度上还是会产生无法弥补的性能损耗。

  • 复杂数据类型需要进行编解码

还是类似的问题,对于除“数字值”以外的“透明”数据类型(比如字符串、字符),当我们想要将它们传递到 Wasm 模块中进行使用时,需要首先对这些数据进行编码(比如 UTF-8)。然后再将编码后的结果以二进制数据的形式存放到 Wasm 的线性内存段中。模块内部指令在实际使用时,再将这些数据进行解码。

因此我们说,就目前 MVP 标准而言,Wasm 模块的线性内存段是与外部宿主环境进行直接信息交换的最重要“场所”。

总结

好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。

在本节课中,我们主要讲解了 Wasm MVP 相关标准中的 JavaScript API 与 Web API。借助这些 API,我们可以在 Web 平台上通过 JavaScript 代码来与 Wasm 模块进行一系列的交互。

我们可以用一句话来总结目前 Wasm MVP 标准在 Web 浏览器上的能力:凡是能够使用 Wasm 来实现的功能,现阶段都可以通过 JavaScript 来实现;而能够使用 JavaScript 来实现的功能,其中部分还无法直接通过 Wasm 实现(比如调用 Web API)

JavaScript API 提供了众多的包装类型,这样便能够在 JavaScript 环境中表示 Wasm 模块的不同组成部分。比如 WebAssembly.Moulde 对应的 Wasm 模块对象、WebAssembly.Memory 对应的 Wasm 线性内存对象等等。

除此之外,JavaScript API 中还提供了诸如 WebAssembly.Compile 以及 WebAssembly.instantiate 方法,以用于编译及实例化一个 Wasm 模块对象。

相对的,Web API 则提供了与 Web 平台相关的一些特殊方法。比如 WebAssembly.compileStreaming 与 WebAssembly.instantiateStreaming。借助这两个 API,我们可以更加高效地完成对 Wasm 模块对象的编译和实例化过程。

除此之外,我们还讲解了 Wasm 模块在运行时的一些特征,比如“内存模型”。以及目前在 MVP 标准下应用 Wasm 时的一些局限性等等。相信学完本次课程,你可以对 “Wasm 目前在 Web 平台上能够做些什么,哪些事情暂时还无法做到?” 这个问题,有着一个更加深刻的认识。

最后,我绘制一个 Wasm JavaScript API 脑图,可以供你参考以及回顾本节课的内容。

课后思考

最后,我们来做一个思考题吧。

如果你是曾经使用过 Wasm 的同学,那么你觉得在目前的 MVP 标准下,Wasm 还有着哪些局限性亟待解决?如果你还没有使用过 Wasm,那么你最期待 Wasm 能够支持哪些新的特性呢?

今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/WebAssembly%e5%85%a5%e9%97%a8%e8%af%be/08%20API%ef%bc%9a%e5%9c%a8%20WebAssembly%20MVP%20%e6%a0%87%e5%87%86%e4%b8%8b%e4%bd%a0%e8%83%bd%e5%81%9a%e5%88%b0%e5%93%aa%e4%ba%9b%e4%ba%8b%ef%bc%9f.md