使用 Emscripten 在 C++ 中嵌入 JavaScript 代码片段

了解如何在您的 WebAssembly 库中嵌入 JavaScript 代码,以便与外部世界通信。

在进行 WebAssembly 与 Web 集成时,您需要一种方法来调用外部 API,例如 Web API 和第三方库。然后,您需要一种方法来存储这些 API 返回的值和对象实例,以及一种方法稍后将这些存储的值传递给其他 API。对于异步 API,您可能还需要使用 Asyncify 在同步 C/C++ 代码中等待 Promise,并在操作完成后读取结果。

Emscripten 提供了几种用于此类交互的工具

  • emscripten::val 用于在 C++ 中存储和操作 JavaScript 值。
  • EM_JS 用于嵌入 JavaScript 代码片段并将它们绑定为 C/C++ 函数。
  • EM_ASYNC_JSEM_JS 类似,但可以更轻松地嵌入异步 JavaScript 代码片段。
  • EM_ASM 用于嵌入简短的代码片段并以内联方式执行它们,而无需声明函数。
  • --js-library 用于高级场景,在这些场景中,您希望将大量 JavaScript 函数一起声明为单个库。

在这篇文章中,您将学习如何将所有这些工具用于类似的任务。

emscripten::val 类

emcripten::val 类由 Embind 提供。它可以调用全局 API、将 JavaScript 值绑定到 C++ 实例,以及在 C++ 和 JavaScript 类型之间转换值。

以下是如何将其与 Asyncify 的 .await() 一起使用来获取和解析一些 JSON

#include <emscripten/val.h>

using namespace emscripten;

val fetch_json(const char *url) {
  // Get and cache a binding to the global `fetch` API in each thread.
  thread_local const val fetch = val::global("fetch");
  // Invoke fetch and await the returned `Promise<Response>`.
  val response = fetch(url).await();
  // Ask to read the response body as JSON and await the returned `Promise<any>`.
  val json = response.call<val>("json").await();
  // Return the JSON object.
  return json;
}

// Example URL.
val example_json = fetch_json("https://httpbin.org/json");

// Now we can extract fields, e.g.
std::string author = json["slideshow"]["author"].as<std::string>();

此代码运行良好,但它执行了许多中间步骤。对 val 的每个操作都需要执行以下步骤

  1. 将作为参数传递的 C++ 值转换为某种中间格式。
  2. 转到 JavaScript,读取参数并将其转换为 JavaScript 值。
  3. 执行函数
  4. 将结果从 JavaScript 转换为中间格式。
  5. 将转换后的结果返回到 C++,而 C++ 最终将其读回。

每个 await() 还必须暂停 C++ 端,方法是展开 WebAssembly 模块的整个调用堆栈,返回到 JavaScript,等待,并在操作完成时恢复 WebAssembly 堆栈。

此类代码不需要来自 C++ 的任何内容。C++ 代码仅充当一系列 JavaScript 操作的驱动程序。如果您可以将 fetch_json 移动到 JavaScript 并同时减少中间步骤的开销,会怎么样?

EM_JS 宏

EM_JS 宏 让您可以将 fetch_json 移动到 JavaScript。EM_JS 在 Emscripten 中允许您声明一个由 JavaScript 代码片段实现的 C/C++ 函数。

与 WebAssembly 本身一样,它也有限制,仅支持数字参数和返回值。为了传递任何其他值,您需要通过相应的 API 手动转换它们。以下是一些示例。

传递数字不需要任何转换

// Passing numbers, doesn't need any conversion.
EM_JS(int, add_one, (int x), {
  return x + 1;
});

int x = add_one(41);

在 JavaScript 之间传递字符串时,您需要使用 preamble.js 中的相应转换和分配函数

EM_JS(void, log_string, (const char *msg), {
  console.log(UTF8ToString(msg));
});

EM_JS(const char *, get_input, (), {
  let str = document.getElementById('myinput').value;
  // Returns heap-allocated string.
  // C/C++ code is responsible for calling `free` once unused.
  return allocate(intArrayFromString(str), 'i8', ALLOC_NORMAL);
});

最后,对于更复杂、任意的值类型,您可以使用 JavaScript API 来处理前面提到的 val 类。使用它,您可以将 JavaScript 值和 C++ 类转换为中间句柄,然后再转换回来

EM_JS(void, log_value, (EM_VAL val_handle), {
  let value = Emval.toValue(val_handle);
  console.log(value);
});

EM_JS(EM_VAL, find_myinput, (), {
  let input = document.getElementById('myinput');
  return Emval.toHandle(input);
});

val obj = val::object();
obj.set("x", 1);
obj.set("y", 2);
log_value(obj.as_handle()); // logs { x: 1, y: 2 }

val myinput = val::take_ownership(find_input());
// Now you can store the `find_myinput` DOM element for as long as you like, and access it later like:
std::string value = input["value"].as<std::string>();

考虑到这些 API,可以重写 fetch_json 示例,以便在不离开 JavaScript 的情况下完成大部分工作

EM_JS(EM_VAL, fetch_json, (const char *url), {
  return Asyncify.handleAsync(async () => {
    url = UTF8ToString(url);
    // Invoke fetch and await the returned `Promise<Response>`.
    let response = await fetch(url);
    // Ask to read the response body as JSON and await the returned `Promise<any>`.
    let json = await response.json();
    // Convert JSON into a handle and return it.
    return Emval.toHandle(json);
  });
});

// Example URL.
val example_json = val::take_ownership(fetch_json("https://httpbin.org/json"));

// Now we can extract fields, e.g.
std::string author = json["slideshow"]["author"].as<std::string>();

在函数的入口点和出口点,我们仍然有一些显式转换,但其余部分现在是常规 JavaScript 代码。与 val 等效项不同,它现在可以由 JavaScript 引擎优化,并且所有异步操作只需要暂停 C++ 端一次。

EM_ASYNC_JS 宏

唯一看起来不太漂亮的部分是 Asyncify.handleAsync 包装器,它的唯一目的是允许使用 Asyncify 执行 async JavaScript 函数。实际上,这种用例非常常见,因此现在有一个专门的 EM_ASYNC_JS 宏将它们组合在一起。

以下是如何使用它来生成 fetch 示例的最终版本

EM_ASYNC_JS(EM_VAL, fetch_json, (const char *url), {
  url = UTF8ToString(url);
  // Invoke fetch and await the returned `Promise<Response>`.
  let response = await fetch(url);
  // Ask to read the response body as JSON and await the returned `Promise<any>`.
  let json = await response.json();
  // Convert JSON into a handle and return it.
  return Emval.toHandle(json);
});

// Example URL.
val example_json = val::take_ownership(fetch_json("https://httpbin.org/json"));

// Now we can extract fields, e.g.
std::string author = json["slideshow"]["author"].as<std::string>();

EM_ASM

EM_JS 是声明 JavaScript 代码片段的推荐方法。它很有效率,因为它像任何其他 JavaScript 函数导入一样直接绑定声明的代码片段。它还通过允许您显式声明所有参数类型和名称来提供良好的人体工程学。

但是,在某些情况下,您想要插入一个快速代码片段以调用 console.logdebugger; 语句或类似内容,并且不想费心声明一个完整的单独函数。在这些极少数情况下,EM_ASM 宏系列EM_ASMEM_ASM_INTEM_ASM_DOUBLE)可能是一个更简单的选择。这些宏类似于 EM_JS 宏,但它们以内联方式执行代码,而不是定义函数。

由于它们不声明函数原型,因此它们需要一种不同的方式来指定返回类型和访问参数。

您需要使用正确的宏名称来选择返回类型。EM_ASM 块预计充当 void 函数,EM_ASM_INT 块可以返回整数值,而 EM_ASM_DOUBLE 块相应地返回浮点数。

任何传递的参数都将在 JavaScript 主体中以名称 $0$1 等提供。与 EM_JS 或一般 WebAssembly 一样,参数仅限于数值 - 整数、浮点数、指针和句柄。

以下是如何使用 EM_ASM 宏将任意 JS 值记录到控制台的示例

val obj = val::object();
obj.set("x", 1);
obj.set("y", 2);
// executes inline immediately
EM_ASM({
  // convert handle passed under $0 into a JavaScript value
  let obj = Emval.fromHandle($0);
  console.log(obj); // logs { x: 1, y: 2 }
}, obj.as_handle());

--js-library

最后,Emscripten 支持在自定义 库格式 的单独文件中声明 JavaScript 代码

mergeInto(LibraryManager.library, {
  log_value: function (val_handle) {
    let value = Emval.toValue(val_handle);
    console.log(value);
  }
});

然后,您需要在 C++ 端手动声明相应的原型

extern "C" void log_value(EM_VAL val_handle);

在两侧都声明后,JavaScript 库可以通过 --js-library option 与主代码链接在一起,将原型与相应的 JavaScript 实现连接起来。

但是,此模块格式是非标准的,并且需要仔细的依赖项注释。因此,它主要保留用于高级场景。

结论

在这篇文章中,我们研究了在使用 WebAssembly 时将 JavaScript 代码集成到 C++ 中的各种方法。

包含此类代码片段允许您以更简洁高效的方式表达长序列操作,并利用第三方库、新的 JavaScript API,甚至是通过 C++ 或 Embind 尚无法表达的 JavaScript 语法功能。