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

瞭解如何將 JavaScript 程式碼嵌入 WebAssembly 程式庫,與外界通訊。

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

宣告 JavaScript 程式碼片段時,建議採用 EM_JS。這個 API 可以像任何其他 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

最後,Escripten 支援在個別檔案中使用自訂程式庫格式宣告 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 語法功能。