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

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

伊格瓦史坦尼恩
Ingvar Stepanyan

執行 WebAssembly 與網路整合時,需要呼叫外部 API,例如 Web API 和第三方程式庫。然後,您需要有一種方法來儲存這些 API 傳回的值和物件執行個體,並在之後將這些儲存的值傳送至其他 API。如果是非同步 API,您可能還需要在具有 Asyncify 的同步 C/C++ 程式碼中等待承諾,並在作業完成後讀取結果。

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

最後,Emmscripten 支援在獨立的檔案中,以自訂的程式庫格式宣告 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 程式庫與主要程式碼連結,將原型與對應的 JavaScript 實作項目連結。

不過,此模組格式非標準格式,需要謹慎的依附元件註解。因此,主要用於進階情境。

結語

在這篇文章中,我們已瞭解使用 WebAssembly 時,將 JavaScript 程式碼整合至 C++ 的多種方式。

加入這類程式碼片段可讓您以更簡潔有效率的方式呈現一系列長時間的作業,並運用第三方程式庫、新的 JavaScript API,甚至是無法透過 C++ 或 Embind 表達的 JavaScript 語法功能。