如何使用 WebAssembly 和 JS构建高性能应用程序

本文最初发布于 Medium 网站,经原作者授权由 InfoQ 中文站翻译并分享。

自计算机发明以来,原生应用程序的性能有了巨大的提升。相比之下,由于 JavaScript 最初并不是为提高速度而构建的,因此 Web 应用程序的运行速度曾经相当缓慢。但是,由于浏览器之间的激烈竞争以及 JavaScript 引擎(例如 V8)的迅速发展,JavaScript 在机器上的运行速度也变得非常快了。但是它仍然无法在速度上击败原生应用程序。这主要是由于 JavaScript 代码必须经过多个流程才能生成机器代码。

JS 引擎花费的平均时间

随着 WebAssembly 的引入,现代 Web 为我们所知的一切都有望迎来变革。这项技术快如闪电。在这篇文章文章中,我们就来看一下什么是 WebAssembly,以及如何将它与 JavaScript 集成以构建高性能应用程序。

什么是 WebAssembly?

在深入了解 WebAssembly 之前,我们先来看一下什么是 Assembly。

汇编(Assembly)是一种底层编程语言,与 CPU 架构的机器级指令有着非常紧密的联系。换句话说,它离机器可理解的代码(称为机器代码)只差一个转换过程。这种转换过程称为汇编

顾名思义,WebAssembly可以理解为 Web 的汇编。它是一种类似于汇编语言的底层语言,有着紧凑的二进制格式,使你能够以接近原生的速度运行 Web 应用程序。它还为 C、C 和 Rust 等语言提供了编译目标,从而使客户端应用程序能够以接近原生的性能运行在 Web 上。

此外,WebAssembly 被设计为与 JavaScript 并存,而不是替代后者。使用 WebAssembly JavaScript API,你可以在两种语言之间来回交换代码,而不会出现任何问题。这样,你就可以获得同时具备 WebAssembly 的功能和性能,以及 JavaScript 的多功能和适应性的应用程序。这打开了一个 Web 应用程序的全新世界,我们可以在 Web 上运行很多原本不准备用于 Web 的代码和功能。

它带来了什么变化

Lin Clark 预测,2017 年推出的 WebAssembly 可能会让 Web 开发产业迎来全新的拐点。上一个拐点来自现代浏览器中引入的 JIT 编译,其使 JavaScript 的速度提高了近 10 倍。

JavaScript 性能

如果对比 WebAssembly 与 JavaScript 的编译过程,你会注意到前者剥离了几个步骤,剩下的都被缩减了。下图是两种语言编译过程的直观对比。

WebAssembly 与传统 Web 应用程序编译过程的近似对比

仔细对比两者,你会注意到 WebAssembly 中的重优化部分已被完全剥离。这主要是因为:编译器无需对 WebAssembly 代码做任何假设,因为数据类型之类的东西在代码中是显式展现的。

但 JavaScript 不是这样,因为 JIT 应该为运行代码做出假设,如果假设失败,则应重优化代码。

如何获取 WebAssembly 代码

WebAssembly 是一项伟大的技术,但是你该如何使用它的力量呢?

你有几种方法可用。

  • 从头开始编写 WebAssembly 代码——除非你非常了解它的基础知识,否则完全不建议这样做。
  • 从 C 编译为 WebAssembly
  • 从 C 编译为 WebAssembly
  • 从 Rust 编译为 WebAssembly
  • 使用 AssemblyScript 将 Typescript 的一个严格变体编译为 WebAssembly。对于不熟悉 C/C 或 Rust 的 Web 开发人员来说,这是一个不错的选项。
  • Wasm 还支持更多语言选项,后文会提到。

此外,还有 Emscripten 和 WebAssembly Studio 之类的工具可以帮助你完成上述过程。

JavaScript 的 WebAssembly API

为了充分利用 WebAssembly 的功能,我们必须将其与 JavaScript 代码集成在一起。这可以在 JavaScript WebAssembly API 的帮助下完成。

模块编译和实例化

WebAssembly 代码位于.wasm 文件中。该文件应编译为针对底层机器的机器码。你可以使用 WebAssembly.compile 方法来编译 WebAssembly 模块。收到已编译的模块后,可以使用 WebAssembly.instantiate 方法实例化已编译的模块。另外,你也可以将获取.wasm 文件获得的数组缓存传递到 WebAssembly.instantiate 方法中。这也可以,因为实例化方法有两个重载。

letexports;
fetch("sample.wasm").then(response=>response.arrayBuffer();
  ).then(bytes=>WebAssembly.instantiate(bytes);
  ).then(results=>exports = results.instance.exports;});

上述方法的缺点之一是这些方法不能直接访问字节码,因此在编译 / 实例化 wasm 模块之前,需要采取额外的步骤将响应转换为 ArrayBuffer。相比之下,我们可以使用WebAssembly.compileStreaming/WebAssembly.instantiateStreaming 方法来实现上述功能,其优点是可以直接访问字节码,而无需将响应转换为 ArrayBuffer。

letexports;
WebAssembly.instantiateStreaming(fetch("sample.wasm")).
then(obj => {exports= obj.instance.exports;})

应注意,WebAssembly.instantiate 和WebAssembly.instantiateStreaming 会返回实例以及已编译的模块,这些实例可用于快速启动模块的实例。

letexports;letcompiledModule;
WebAssembly.instantiateStreaming(fetch("sample.wasm"))
  .then(obj=>{
       exports = obj.instance.exports;//access compiled modulecompiledModule = obj.module;
})

导入对象

实例化 WebAssembly 模块实例时,可以选择传递一个导入对象,该对象将包含要导入到新创建的模块实例中的值。它们可以是 4 种类型。

  • 全局变量值
  • 函数
  • memory
  • table

导入对象可以视为提供给模块实例以帮助其完成任务的工具。如果未提供导入对象,则编译器将分配默认值。

全局变量

WebAssembly 允许你创建可从 JavaScript 和 WebAssembly 模块访问的全局变量实例。你可以导入 / 导出这些变量,并在一个或多个 WebAssembly 模块实例中使用它们。

你可以使用 WebAssembly.Global() 构造器创建一个全局实例。

constglobal=newWebAssembly.Global({value:"i64",  
       mutable:true},20);

全局构造器接收两个参数。

  • 一个对象,包含描述全局变量的数据类型和可变性的属性。允许的数据类型为 i32、i64、f32 或 f64
  • 实际变量的初始值。此值应为参数 1 中提到的类型。例如,如果你声明类型为 i32,则变量应为 32 位整数。同样,如果你声明类型为 f64,则变量应为 64 位浮点数。
constglobal=newWebAssembly.Global({value:"i64", 
  mutable:true},20);letimportObject = {  
  js: {global}
};
WebAssembly.instantiateStreaming(fetch("global.wasm"), importObject)

全局实例应传递到 importObject 上,以便在 WebAssembly 模块实例中访问它。

Memory

在实例化时,WebAssembly 模块将需要分配一个 memory 对象。该 memory 对象应与 importObject 一起传递。如果没能这样做,则 JIT 编译器将使用默认值自动创建一个 memory 对象并将其附加到实例。

附加到模块实例的 memory 对象只是一个 ArrayBuffer。只需使用索引值,即可轻松访问 memory。此外,由于它是简单的 ArrayBuffer,因此可以简单地在 JavaScript 和 WebAssembly 之间传递和共享值。

Table

WebAssembly Table 是一个可调整大小的数组,位于 WebAssembly 的 memory 之外。该 Table 的值都是函数引用。尽管这听起来很像 WebAssembly memory,但它们是不同的,主要区别在于 Memory 数组是原始字节,而 Table 数组是引用。

引入 Table 主要是为了提高安全性。

你可以使用 set()、grow() 和 get() 方法来操作 Table。

演示

在这个演示中,我将使用 WebAssembly Studio 应用程序将一个 C 文件编译为.wasm。你可以在这里查看演示。

我创建了一个函数来计算wasm 文件中一个数字的幂。我将必要的值传递给函数,并在JavaScript 中接收输出。

同样,我在wasm 中进行了一些字符串操作。需要注意wasm 没有字符串类型,因此它用的是ASCII 值。返回到JavaScript 的值将指向存储输出的memory 位置。由于memory 对象是ArrayBuffer,因此我要进行迭代,直到收到字符串中的所有字符为止。

JavaScript 文件

letexports;letbuffer;(async() => {letresponse =awaitfetch("../out/main.wasm");letresults =awaitWebAssembly.instantiate(awaitresponse.arrayBuffer());//or// let results = await WebAssembly.instantiateStreaming(fetch("../out/main.wasm"));letinstance = results.instance;  
  exports = instance.exports;  
  buffer =newUint8Array(exports.memory.buffer);
  findPower(5,3);   
  
  printHelloWorld(); 

})();constfindPower= (base = 0, power = 0) =>{console.log(exports.power(base,power));
}constprintHelloWorld =()=>{letpointer = exports.helloWorld();letstr ="";for(leti = pointer;buffer[i];i  ){   
    str  =String.fromCharCode(buffer[i]);
  }console.log(str);
}

C 文件

#defineWASM_EXPORT __attribute__((visibility("default")))#includeWASM_EXPORTdoublepower(doublenumber,doublepower_value){returnpow(number,power_value);
}WASM_EXPORTchar*helloWorld(){return"hello world";
}

用例

WebAssembly 的诞生带来了很多全新的机遇。

  • 能够在 Web 环境中使用以 C/C 等语言编写的现有库 / 代码。

例如,如果你无法找到实现某些功能的 JavaScript 库,则必须从头开始编写并实现自己的库。但如果你可以找到实现相同功能,但使用不同语言编写的库,则可以使用 WebAssembly 的能力在 Web 应用程序中运行它。这是一项重大突破,因为从开发人员的角度来看,它将节省大量时间。

Squoosh 应用使用 WebAssembly 来实现其 QR 和图像检测功能。这样一来,即使在较旧的浏览器上,它们也能以接近原生的速度支持这些功能。此外,eBay 将其原生应用程序的 C 库编译为 WebAssembly,从而在 Web 应用程序中实现了条形码扫描功能。

  • 无需修改代码,就能在 Web 上运行用 C、C 、Rust 等语言编写的完全原生应用程序,性能表现也接近原生水平。

诸如 AutoCAD、QT 甚至谷歌地球之类的应用程序都可以在几乎不修改代码库的情况下以接近原生的性能运行在 Web 上,多亏了 Web Assembly 的强大力量。

  • 即使你找到了用 JavaScript 编写的类似库,也可以使用以 C、C 或 Rust 等语言编写的库,因为 WebAssembly 代码速度飞快,并能提供更好的质量。

谷歌团队将来自 C 和 C 等不同语言的编码器编译到了他们的 Squoosh 应用中,并替换掉了常规的编解码器(如 JPEG -> MozJPEG)。这些替代品缩小了文件体积,而不会牺牲图像的视觉质量。

支持的语言

WebAssembly 支持不仅限于 C、C 和 Rust。许多开发人员也在努力纳入对其他语言的支持。以下是当前支持的语言列表。

  • C/C
  • Rust
  • AssemblyScript(类似 TypeScript 的语法)
  • C#
  • F#
  • Go
  • Kotlin
  • Swift
  • D
  • Pascal
  • Zig

批评

WebAssembly 允许在已编译的二进制文件中执行,因此带来了许多安全性质疑。甚至无需什么 trace 也可以利用这些漏洞,而且它们确实很难发现。尽管 WebAssembly 也有自己的安全特性,但我个人认为这方面仍需进一步改进。因为引入了那么多新功能,防病毒工具和 URL 过滤之类的传统防护技术根本无法提供全面保护。这意味着常规浏览器在未来会更加脆弱。

关于这些问题的更多信息可以参考以下文章。

  • https://www.virusbulletin.com/virusbulletin/2018/10/dark-side-webassembly/
  • https://securityboulevard.com/2020/01/research-more-worries-with-wasm-3/
  • https://webassembly.org/docs/security/

结论

有人猜测 WebAssembly 将取代 JavaScript,但我必须说这是完全错误的。创建 WebAssembly 是为了与 JavaScript 共存,而不是替代它。此外,调试 JavaScript 比 WebAssembly 容易一些,并且在 Wasm 中没有 JavaScript 的那种自由气氛。

大家都对它寄予厚望,可以肯定地说 WebAssembly 能够为一系列应用铺平道路,惊艳世人。

“没人能肯定地说这些性能改进可以催生怎样的应用。但以史为鉴,我们可以对此满怀期待” ——Lin Clark

原文链接:

https://blog.bitsrc.io/a-complete-introduction-to-webassembly-and-its-javascript-api-3474a9845206

延伸阅读:

20个大前端必知领域之「WebAssembly」-InfoQ

22个必知编程语言之「WebAssembly」-InfoQ

Next.js 9.5 正式发布:支持 Webpack 5-InfoQ

关注我并转发此篇文章,私信我“领取资料”,即可免费获得InfoQ价值4999元迷你书,点击文末「了解更多」,即可移步InfoQ官网,获取最新资讯~