使用 Emscripten 以 C++ 嵌入 JavaScript 程式碼片段

瞭解如何在 WebAssembly 程式庫中嵌入 JavaScript 程式碼,以便與外部世界通訊。

Ingvar Stepanyan
Ingvar Stepanyan

在 WebAssembly 與網頁整合時,您需要能夠呼叫外部 API (例如網頁 API 和第三方程式庫) 接著,您必須找到儲存這些 API 傳回的值和物件執行個體的方法,以及在之後將這些儲存的值傳送至其他 API 的方法。如果是非同步 API,您可能還需要在同步 C/C++ 程式碼中以 Asyncify 等待承諾,並在作業完成後讀取結果。

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 macro 可將 fetch_json 移至 JavaScript。Emscripten 中的 EM_JS 可讓你宣告由 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);
});

最後,如果是更複雜的任意值類型,您可以為先前提到的 val 類別使用 JavaScript API。您可以使用它將 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.log 呼叫、debugger; 陳述式或類似內容的簡短程式碼片段,且不想宣告完整的獨立函式。在極少數的情況下,EM_ASM macros family (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 語法功能。