WebAssembly JavaScript 内置函数,其实就是 JavaScript 操作对应的 Wasm 实现。有了它,我们在 Wasm 模块里使用 JavaScript 特性时,就不用额外导入 JavaScript 胶水代码了 —— 要知道,原本这些胶水代码是用来衔接 JavaScript 和 WebAssembly 的值类型,以及处理两者调用约定差异的。

本文会先讲清楚这些内置函数的工作原理、目前有哪些可用的内置函数,最后再给一个实际的使用例子。

导入 JavaScript 函数存在的问题

对于很多 JavaScript 特性来说,常规的导入方式是没问题的。但如果要为StringArrayBufferMap这类基础类型导入胶水代码,就会带来很大的性能损耗。这种情况下,WebAssembly 以及大多数面向 WebAssembly 的开发语言,更需要的是紧凑的内联操作序列,而不是像常规导入函数那样通过间接函数调用来实现。

具体来说,从 JavaScript 向 WebAssembly 模块导入函数会导致性能问题,主要原因有三个:

  1. 现有 API 需要做转换来处理this值的差异 ——WebAssembly 函数导入调用时,this值是undefined的。
  2. 某些基础类型会用到===<这类 JavaScript 运算符,而这些运算符是没法导入的。
  3. 大多数 JavaScript 函数对输入值的类型限制非常宽松;但我们更希望能借助 WebAssembly 的类型系统,尽可能去掉这些类型检查和类型转换操作。

考虑到这些问题,与其通过导入实现、依赖间接函数调用,不如直接创建内置定义 —— 把String这类现有 JavaScript 功能适配成 WebAssembly 可用的形式,这样既简单,性能也更好。

目前可用的 WebAssembly JavaScript 内置函数

下面会详细介绍目前可用的内置函数。未来应该还会支持更多内置函数。

字符串操作

目前可用的字符串相关内置函数如下:

  • "wasm:js-string" "cast"如果传入的值不是字符串,就会抛出错误。大致等价于下面这段代码:

    // 如果obj的类型不是字符串,就抛出WebAssembly运行时错误
    if (typeof obj !== "string") throw new WebAssembly.RuntimeError();
    
  • "wasm:js-string" "compare"比较两个字符串的值并确定它们的顺序。如果第一个字符串小于第二个,返回-1;如果第一个大于第二个,返回1;如果两个字符串严格相等,返回0

  • "wasm:js-string" "concat"功能和String.prototype.concat()一样。

  • "wasm:js-string" "charCodeAt"功能和String.prototype.charCodeAt()一样。

  • "wasm:js-string" "codePointAt"功能和String.prototype.codePointAt()一样。

  • "wasm:js-string" "equals"检查两个字符串是否严格相等:相等返回1,不相等返回0注意:在所有字符串内置函数里,只有 “equals” 遇到null输入时不会抛出错误 —— 所以 Wasm 模块调用它之前,不用先检查值是不是null。其他函数都没有合理的方式处理null输入,遇到了就会抛出错误。

  • "wasm:js-string" "fromCharCode"功能和String.fromCharCode()一样。

  • "wasm:js-string" "fromCharCodeArray"用 Wasm 里的i16类型数组创建字符串。

  • "wasm:js-string" "fromCodePoint"功能和String.fromCodePoint()一样。

  • "wasm:js-string" "intoCharCodeArray"把字符串的字符编码写入 Wasm 里的i16类型数组。

  • "wasm:js-string" "length"功能和String.prototype.length一样。

  • "wasm:js-string" "substring"功能和String.prototype.substring()一样。

  • "wasm:js-string" "test"如果传入的值不是字符串,返回0;是字符串则返回1。大致等价于下面这段代码:

    // 检查obj的类型是不是字符串
    typeof obj === "string";
    

内置函数怎么用?

内置函数的用法和从 JavaScript 导入的函数差不多,但有个关键区别:内置函数是用标准的 Wasm 函数来实现 JavaScript 操作,而且这些函数都定义在一个预留的命名空间(wasm:)里。正因为这样,浏览器才能预判它们的行为,生成性能最优的代码。下面就总结一下具体用法。

JavaScript API

要启用内置函数,需要在编译阶段做配置:调用编译或实例化模块的方法时,传入compileOptions.builtins属性作为参数。这个属性的值是一个字符串数组,用来指定你要启用的内置函数集合。示例如下:

// 编译Wasm模块,同时启用字符串相关内置函数
WebAssembly.compile(bytes, { builtins: ["js-string"] });

支持compileOptions对象的函数有这些:

  • WebAssembly.compile()
  • WebAssembly.compileStreaming()
  • WebAssembly.instantiate()
  • WebAssembly.instantiateStreaming()
  • WebAssembly.validate()
  • WebAssembly.Module()构造函数

WebAssembly 模块相关配置

配置好compileOptions之后,在 WebAssembly 模块里,就可以从wasm:命名空间导入对应的内置函数了(下面例子里导入的是concat()函数,你也可以参考对应的内置函数定义):

// 从"wasm:js-string"命名空间导入concat函数,命名为$concat
// 参数是两个externref类型,返回值是(ref extern)类型
(func $concat (import "wasm:js-string" "concat")
    (param externref externref) (result (ref extern)))

检测内置函数是否支持

使用内置函数时,类型检查会比不使用时更严格 —— 内置函数的导入有一些特定规则。

所以,要写内置函数的特性检测代码,可以这样做:定义一个模块,如果环境支持该特性,这个模块就是无效的;如果不支持,模块就是有效的。然后,当验证失败时返回true,就表示支持该特性。下面是一个基础的检测模块示例:

(module
  // 导入"wasm:js-string"命名空间下的"cast"函数
  (function (import "wasm:js-string" "cast")))
  • 不支持内置函数时:这个模块是有效的。因为此时可以导入任意签名的函数(这里的函数没有参数也没有返回值)。
  • 支持内置函数时:这个模块是无效的。因为此时 “wasm:js-string” 下的 “cast” 函数是特殊处理的,必须有特定签名 —— 参数是externref类型,返回值是不可为null(ref extern)类型。

接下来就可以用validate()方法验证这个模块了,但要注意:结果需要用!运算符取反 —— 记住,模块无效才说明支持内置函数。代码如下:

// 配置编译选项:启用字符串相关内置函数
const compileOptions = {
  builtins: ["js-string"],
};

// 加载Wasm模块文件,验证是否支持内置函数
fetch("module.wasm")
  .then((response) => response.arrayBuffer()) // 把响应转为ArrayBuffer
  .then((bytes) => WebAssembly.validate(bytes, compileOptions)) // 验证模块
  .then((result) => console.log(`是否支持内置函数:${!result}`)); // 取反后输出结果

其实上面这个检测模块的代码很短,不用下载module.wasm,直接验证字面量字节数组就行。下面是一个完整的特性检测函数示例:

// 检测是否支持字符串相关内置函数
function JsStringBuiltinsSupported() {
  // 检测模块对应的字节数组(就是前面wat代码编译后的结果)
  let bytes = new Uint8Array([
    0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 2, 23, 1, 14, 119, 97, 115,
    109, 58, 106, 115, 45, 115, 116, 114, 105, 110, 103, 4, 99, 97, 115, 116, 0,
    0,
  ]);
  // 验证字节数组,结果取反(无效→支持)
  return !WebAssembly.validate(bytes, { builtins: ["js-string"] });
}

注意:很多时候,检测内置函数不一定非要用上面这种方式。另一个思路是:除了内置函数,同时提供常规的导入函数作为备选 —— 支持内置函数的浏览器会自动忽略这些备选的导入函数。

内置函数使用示例

下面我们来走一遍完整的基础示例,看看内置函数具体怎么用。这个示例会做两件事:在 Wasm 模块里定义一个函数,用来拼接两个字符串并把结果打印到控制台,然后导出这个函数;最后在 JavaScript 里调用这个导出的函数。

我们这个示例会用WebAssembly.instantiate()函数在网页上处理模块的编译和实例化。你也可以在我们的webassembly-examples代码仓库里找到这个示例和其他相关例子,具体路径是js-builtin-examples

跟着下面的步骤,就能搭建好这个示例。另外,你也可以直接看在线运行效果 —— 打开浏览器的 JavaScript 控制台,就能看到示例的输出。

JavaScript 代码

示例的 JavaScript 代码如下。如果想本地测试,把这段代码加到 HTML 页面里就行(比如写在<script>标签里,或者放到外部.js文件里再通过<script src="">引入):

// 定义导入对象:给Wasm模块导入JavaScript里的函数/变量
const importObject = {
  // 常规导入:命名空间为"m"
  m: {
    log: console.log, // 把console.log导入,命名为log
  },
};

// 定义编译选项
const compileOptions = {
  builtins: ["js-string"], // 启用JavaScript字符串相关内置函数
  importedStringConstants: "string_constants", // 启用导入的全局字符串常量
};

// 加载、编译、实例化Wasm模块,并调用导出的函数
fetch("log-concat.wasm") // 加载Wasm模块文件
  .then((response) => response.arrayBuffer()) // 把响应转为ArrayBuffer(Wasm模块的二进制格式)
  .then((bytes) => WebAssembly.instantiate(bytes, importObject, compileOptions)) // 编译并实例化模块
  .then((result) => result.instance.exports.main()); // 调用Wasm模块导出的main函数

这段 JavaScript 代码的作用拆解:

  1. 定义importObject对象:指定要导入到 Wasm 模块的内容 —— 这里是把console.log()函数导入,放在 “m” 命名空间下,命名为 “log”。
  2. 定义compileOptions对象,包含两个配置:
    • builtins: ["js-string"]:启用字符串相关的内置函数。
    • importedStringConstants: "string_constants":启用全局字符串常量的导入功能。
  3. 加载 Wasm 模块文件(log-concat.wasm),把响应转为 ArrayBuffer(Wasm 模块的二进制格式),然后用WebAssembly.instantiate()编译并实例化模块。
  4. 调用 Wasm 模块导出的main()函数。

Wasm 模块代码

我们这个 WebAssembly 模块的文本格式(WAT)代码如下:

(module
  // 导入全局字符串常量:来自"string_constants"命名空间,分别命名为$h和$w
  (global $h (import "string_constants" "hello ") externref)
  (global $w (import "string_constants" "world!") externref)
  
  // 从"wasm:js-string"命名空间导入concat内置函数,命名为$concat
  // 参数:两个externref类型;返回值:(ref extern)类型
  (func $concat (import "wasm:js-string" "concat")
    (param externref externref) (result (ref extern)))
  
  // 从"m"命名空间导入log函数(就是JavaScript里的console.log),命名为$log
  // 参数:一个externref类型
  (func $log (import "m" "log") (param externref))
  
  // 定义导出函数main:调用$concat拼接$h和$w,再把结果传给$log打印
  (func (export "main")
    (call $log (call $concat (global.get $h) (global.get $w))))
)

这段 WAT 代码的作用拆解:

  1. 导入两个全局字符串常量:来自 “string_constants” 命名空间,分别是 “hello ” 和 “world!”,在模块里命名为$h$w(对应 JavaScript 里compileOptions.importedStringConstants的配置)。
  2. wasm:命名空间导入concat内置函数,在模块里命名为$concat,并指定它的参数(两个externref类型)和返回值((ref extern)类型)。
  3. 从 “m” 命名空间导入log函数(就是 JavaScript 里importObject.m.log对应的console.log),在模块里命名为$log,指定参数是一个externref类型。这里特意同时包含了常规导入和内置函数,方便大家对比两种方式的差异。
  4. 定义一个导出函数main:先调用$concat,把$h$w两个全局字符串常量传进去做拼接,再把拼接结果传给$log打印出来。

本地运行示例的步骤

  1. 把上面的 WAT 代码保存成文本文件,命名为log-concat.wat,和你的 HTML/JavaScript 文件放在同一个目录。
  2. wasm-as工具把log-concat.wat编译成 Wasm 模块文件(log-concat.wasm)。wasm-as是 Binaryen 库的一部分(具体编译步骤可以看 Binaryen 的官方文档)。编译时需要启用 “引用类型”(reference types)和 “垃圾回收”(GC)功能,命令如下:
    # 启用引用类型和GC,编译WAT文件为Wasm模块
    wasm-as --enable-reference-types --enable-gc log-concat.wat
    
    也可以用-all选项替代上面两个启用参数,命令更简洁:
    # 用-all选项启用所有必要特性,编译WAT文件
    wasm-as -all log-concat.wat
    
  3. 用本地 HTTP 服务器打开你的示例 HTML 页面(直接打开本地 HTML 文件可能会有跨域问题)。

运行结果

最终你会看到一个空白的网页,但打开浏览器的 JavaScript 控制台,会看到里面打印了 “hello world!”—— 这个结果是 Wasm 导出函数生成的:日志打印用的是从 JavaScript 导入的console.log,而两个字符串的拼接用的是 WebAssembly 的concat内置函数。

Logo

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

更多推荐