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 im Web arbeiten, benötigen Sie eine Möglichkeit, externe APIs wie Web-APIs und Bibliotheken von Drittanbietern aufzurufen. Sie benötigen dann eine Möglichkeit, die Werte und Objektinstanzen zu speichern, die von diesen APIs zurückgegeben werden, und eine Möglichkeit, diese gespeicherten Werte später an andere APIs zu übergeben. Bei asynchronen APIs müssen Sie möglicherweise auch auf Promise-Objekte im synchronen C/C++ Code mit Asyncify warten und das Ergebnis lesen, sobald der Vorgang abgeschlossen ist.

Emscripten bietet verschiedene Tools für solche Interaktionen:

  • emscripten::val zum Speichern und Verarbeiten von JavaScript-Werten in C++
  • EM_JS zum Einbetten von JavaScript-Snippets und zur Bindung als C/C++-Funktionen.
  • EM_ASYNC_JS, das EM_JS ähnelt, aber das Einbetten asynchroner JavaScript-Snippets vereinfacht.
  • EM_ASM zum Einbetten kurzer Snippets und zum direkten Ausführen dieser kurzen Snippets, ohne eine Funktion zu deklarieren.
  • --js-library für fortgeschrittene Szenarien, in denen Sie viele JavaScript-Funktionen zusammen als eine Bibliothek deklarieren möchten.

In diesem Beitrag erfahren Sie, wie Sie alle für ähnliche Aufgaben nutzen.

Klasse emscripten::val

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 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. Jeder Vorgang auf val muss die folgenden Schritte ausführen:

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

Jede await() muss außerdem die C++-Seite anhalten, indem der gesamte Aufrufstack des WebAssembly-Moduls entspult, zu JavaScript zurückgekehrt wird, gewartet und der WebAssembly-Stapel wiederhergestellt wird, wenn der Vorgang abgeschlossen ist.

Dieser Code benötigt nichts aus C++. C++-Code fungiert nur als Treiber für eine Reihe von JavaScript-Vorgängen. Was wäre, wenn Sie fetch_json auf JavaScript umstellen und gleichzeitig den Aufwand für Zwischenschritte reduzieren könnten?

EM_JS-Makro

Mit dem EM_JS macro können Sie 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, dass nur numerische Argumente und Rückgabewerte unterstützt werden. Wenn Sie andere Werte übergeben möchten, müssen Sie sie manuell über entsprechende APIs konvertieren. Hier einige Beispiele:

Für das Bestehen von Nummern ist keine Umwandlung erforderlich:

// 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 von JavaScript müssen Sie die entsprechenden Conversion- und Zuordnungsfunktionen aus 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 und beliebige Werttypen können Sie 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 wieder 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 Beispiel fetch_json so umgeschrieben werden, dass es größtenteils ohne JavaScript funktioniert:

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

Wir haben immer noch einige explizite Konvertierungen an den Ein- und Ausgangspunkten der Funktion, aber der Rest ist jetzt regulärer JavaScript-Code. Im Gegensatz zu val-Äquivalenten kann es jetzt von der JavaScript-Engine optimiert werden und erfordert für alle asynchronen Vorgänge nur eine Pause von der C++-Seite.

EM_ASYNC_JS-Makro

Das einzige etwas, das noch unschön aussieht, ist der Asyncify.handleAsync-Wrapper. Er dient nur dazu, die Ausführung von async-JavaScript-Funktionen mit Asyncify zu ermöglichen. Dieser Anwendungsfall ist so weit verbreitet, dass es jetzt ein spezialisiertes EM_ASYNC_JS-Makro gibt, das die beiden Werte kombiniert.

So könnten Sie damit die endgültige Version des fetch-Beispiels 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

Zum Deklarieren von JavaScript-Snippets wird EM_JS empfohlen. Diese Methode ist effizient, da sie die deklarierten Snippets wie alle anderen JavaScript-Funktionsimporte direkt bindet. Außerdem bietet es eine gute Ergonomie, da Sie die Möglichkeit haben, alle Parametertypen und -namen explizit zu deklarieren.

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

Da sie keinen Funktionsprototyp deklarieren, müssen sie den Rückgabetyp und den Zugriff auf Argumente anders angeben.

Sie müssen den richtigen Makronamen verwenden, um den Rückgabetyp auszuwählen. EM_ASM-Blöcke sollen wie void-Funktionen funktionieren, EM_ASM_INT-Blöcke können eine Ganzzahl und EM_ASM_DOUBLE-Blöcke geben entsprechend Gleitkommazahlen zurück.

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 auf numerische Werte beschränkt, also auf Ganzzahlen, Gleitkommazahlen, Zeiger und Ziehpunkte.

Hier ein Beispiel dafür, 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

Schließlich unterstützt Emscripten die Deklaration von JavaScript-Code in einer separaten Datei in einem benutzerdefinierten Bibliotheksformat:

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

Dann müssen Sie die entsprechenden Prototypen in C++ manuell in C++ deklarieren:

extern "C" void log_value(EM_VAL val_handle);

Nach der Deklaration auf beiden Seiten kann die JavaScript-Bibliothek über das --js-library option mit dem Hauptcode verknüpft werden. So werden Prototypen mit entsprechenden JavaScript-Implementierungen verbunden.

Dieses Modulformat ist jedoch kein Standard und erfordert sorgfältige Abhängigkeitsanmerkungen. Sie ist daher hauptsächlich für fortgeschrittene Szenarien vorgesehen.

Fazit

In diesem Post haben wir uns verschiedene Möglichkeiten zur Integration von JavaScript-Code in C++ bei der Arbeit mit WebAssembly angesehen.

Durch das Einbeziehen solcher Snippets können Sie lange Sequenzen von Operationen auf sauberere und effizientere Weise ausdrücken und von Drittanbieter-Bibliotheken, neuen JavaScript-APIs und sogar JavaScript-Syntaxfunktionen, die noch nicht über C++ oder Embind ausgedrückt werden können, genutzt werden.