JavaScript-Snippets mit Emscripten in C++ einbetten

Hier erfahren Sie, wie Sie JavaScript-Code in Ihre WebAssembly-Bibliothek einbetten, um mit der Außenwelt zu kommunizieren.

Wenn Sie an der WebAssembly-Integration mit dem Web arbeiten, benötigen Sie eine Möglichkeit, externe APIs wie Web-APIs und Bibliotheken von Drittanbietern aufzurufen. Dann brauchen Sie eine Möglichkeit, die Werte und Objektinstanzen zu speichern, die diese APIs zurückgeben, sowie eine Möglichkeit, diese gespeicherten Werte später an andere APIs zu übergeben. Bei asynchronen APIs müssen Sie möglicherweise auch Promise in Ihrem synchronen C/C++-Code mit Asyncify erwarten und das Ergebnis lesen, sobald der Vorgang abgeschlossen ist.

Emscripten bietet mehrere Tools für solche Interaktionen:

  • emscripten::val zum Speichern und Verarbeiten von JavaScript-Werten in C++.
  • EM_JS, um JavaScript-Snippets einzubetten und als C/C++-Funktionen zu binden.
  • EM_ASYNC_JS, das EM_JS ähnelt, erleichtert aber das Einbetten asynchroner JavaScript-Snippets.
  • EM_ASM, um kurze Snippets einzubetten und inline auszuführen, ohne eine Funktion zu deklarieren.
  • --js-library für erweiterte Szenarien, in denen Sie viele JavaScript-Funktionen zusammen als eine einzige Bibliothek deklarieren möchten.

In diesem Beitrag erfahren Sie, wie Sie alle diese Tools für ähnliche Aufgaben verwenden können.

emscripten::val-Klasse

Die Klasse emcripten::val wird von Embind bereitgestellt. Sie kann globale APIs aufrufen, JavaScript-Werte an C++-Instanzen binden und Werte zwischen C++- und JavaScript-Typen konvertieren.

So können Sie es mit dem .await() von Asyncify verwenden, um JSON-Daten abzurufen und zu parsen:

#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>();

Dieser Code funktioniert gut, führt jedoch viele Zwischenschritte aus. Für jeden Vorgang für val müssen die folgenden Schritte ausgeführt werden:

  1. Konvertieren Sie als Argumente übergebene C++-Werte in ein Zwischenformat.
  2. Rufen Sie JavaScript auf, lesen Sie Argumente aus und konvertieren Sie diese in JavaScript-Werte.
  3. Funktion ausführen
  4. Konvertieren Sie das Ergebnis aus JavaScript in das Zwischenformat.
  5. Geben Sie das konvertierte Ergebnis in C++ zurück und C++ liest es schließlich aus.

Jedes await() muss außerdem die C++-Seite anhalten, indem es den gesamten Aufrufstack des WebAssembly-Moduls entlädt, zu JavaScript zurückkehrt, wartet und den WebAssembly-Stack nach Abschluss des Vorgangs wiederherstellt.

Solcher Code benötigt nichts von C++. C++-Code dient lediglich als Treiber für eine Reihe von JavaScript-Vorgängen. Was wäre, wenn du fetch_json zu JavaScript verschieben und gleichzeitig den Aufwand für Zwischenschritte reduzieren könntest?

EM_JS-Makro

Mit dem EM_JS macro kannst du fetch_json in JavaScript verschieben. Mit EM_JS in Emscripten können Sie eine C-/C++-Funktion deklarieren, die durch ein JavaScript-Snippet implementiert wird.

Wie WebAssembly selbst hat es die Einschränkung, nur numerische Argumente und Rückgabewerte zu unterstützen. Um andere Werte zu übergeben, müssen Sie sie manuell über die entsprechenden APIs konvertieren. Hier einige Beispiele:

Das Weitergeben von Nummern erfordert keine Umwandlung:

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

int x = add_one(41);

Bei der Übergabe von Strings an und aus JavaScript müssen Sie die entsprechenden Conversion- und Zuordnungsfunktionen von preamble.js verwenden:

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);
});

Für komplexere, beliebige Werttypen können Sie schließlich die JavaScript API für die zuvor erwähnte val-Klasse verwenden. Damit können Sie JavaScript-Werte und C++-Klassen in Zwischen-Handles und zurück konvertieren:

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>();

Unter Berücksichtigung dieser APIs könnte das fetch_json-Beispiel so umgeschrieben werden, dass die meisten Vorgänge ohne JavaScript ausgeführt werden:

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>();

Es gibt noch einige explizite Conversions an den Ein- und Ausstiegspunkten der Funktion, der Rest ist jedoch jetzt normaler JavaScript-Code. Anders als die val-Entsprechung kann sie jetzt vom JavaScript-Engine optimiert werden und erfordert nur ein einmaliges Pausieren der C++-Seite für alle asynchronen Vorgänge.

EM_ASYNC_JS-Makro

Der einzige verbleibende Teil, der nicht gut aussieht, ist der Asyncify.handleAsync-Wrapper. Er dient nur dazu, die Ausführung von async-JavaScript-Funktionen mit Asyncify zu ermöglichen. Dieser Anwendungsfall kommt so häufig vor, dass sie jetzt mit einem speziellen EM_ASYNC_JS-Makro kombiniert werden.

So könnten Sie die endgültige Version des fetch-Beispiels damit erstellen:

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 ist die empfohlene Methode zum Deklarieren von JavaScript-Snippets. Dies ist effizient, weil die deklarierten Snippets wie bei jedem anderen JavaScript-Import direkt verknüpft werden. Außerdem bietet sie eine gute Ergonomie, da alle Parametertypen und -namen explizit deklariert werden können.

In einigen Fällen möchten Sie jedoch ein schnelles Snippet für einen console.log-Aufruf, eine debugger;-Anweisung oder etwas Ähnliches einfügen und sich nicht um die Deklaration einer komplett separaten Funktion kümmern. In diesen seltenen Fällen ist ein EM_ASM macros family (EM_ASM, EM_ASM_INT und EM_ASM_DOUBLE) möglicherweise eine einfachere Wahl. Diese Makros ähneln dem EM_JS-Makro, sie führen jedoch Code direkt dort aus, wo sie eingefügt werden, anstatt eine Funktion zu definieren.

Da sie keinen Funktionsprototyp deklarieren, benötigen sie eine andere Methode, um den Rückgabetyp anzugeben und auf Argumente zuzugreifen.

Du musst den richtigen Makronamen verwenden, um den Rückgabetyp auszuwählen. EM_ASM-Blöcke funktionieren wie void-Funktionen. EM_ASM_INT-Blöcke können eine Ganzzahl und EM_ASM_DOUBLE-Blöcke entsprechende Gleitkommazahlen zurückgeben.

Alle übergebenen Argumente stehen im JavaScript-Text unter den Namen $0, $1 usw. zur Verfügung. Wie bei EM_JS oder WebAssembly im Allgemeinen sind die Argumente nur auf numerische Werte beschränkt: Ganzzahlen, Gleitkommazahlen, Zeiger und Ziehpunkte.

Das folgende Beispiel zeigt, wie Sie mit einem EM_ASM-Makro einen beliebigen JS-Wert in der Konsole protokollieren können:

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

Außerdem unterstützt Emscripten die Deklaration von JavaScript-Code in einer separaten Datei in einem eigenen Bibliotheksformat:

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

Anschließend müssen Sie die entsprechenden Prototypen manuell auf der C++-Seite deklarieren:

extern "C" void log_value(EM_VAL val_handle);

Nachdem die JavaScript-Bibliothek auf beiden Seiten deklariert wurde, kann sie über --js-library option mit dem Hauptcode verknüpft werden. So werden Prototypen mit den entsprechenden JavaScript-Implementierungen verbunden.

Dieses Modulformat ist jedoch nicht Standard und erfordert sorgfältige Abhängigkeitsannotationen. Daher eignet sich diese Methode eher für fortgeschrittene Szenarien.

Fazit

In diesem Post haben wir verschiedene Möglichkeiten kennengelernt, bei der Arbeit mit WebAssembly JavaScript-Code in C++ zu integrieren.

Mit solchen Snippets können Sie lange Sequenzen von Operationen auf sauberer und effizientere Weise ausdrücken und auf Bibliotheken von Drittanbietern, neue JavaScript-APIs und sogar JavaScript-Syntaxfunktionen zugreifen, die noch nicht über C++ oder Embind ausgedrückt werden können.