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 mit Asyncify in Ihrem synchronen C/C++-Code auf Versprechen warten und das Ergebnis lesen, sobald der Vorgang abgeschlossen ist.

Emscripten bietet mehrere Tools für solche Interaktionen:

  • emscripten::val zum Speichern und Bearbeiten von JavaScript-Werten in C++.
  • EM_JS zum Einbetten von JavaScript-Snippets und zum Binden 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 erweiterte Szenarien, in denen Sie viele JavaScript-Funktionen gemeinsam als einzelne Bibliothek deklarieren möchten.

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

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 kannst du es mit Asyncifys .await() 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 aber viele Zwischenschritte aus. Für jeden Vorgang auf val müssen die folgenden Schritte ausgeführt werden:

  1. Wandelt als Argumente übergebene C++-Werte in ein Zwischenformat um.
  2. Rufen Sie JavaScript auf, lesen Sie die Argumente und konvertieren Sie sie in JavaScript-Werte.
  3. Funktion ausführen
  4. Wandeln Sie das Ergebnis aus JavaScript in ein Zwischenformat um.
  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 entfernt, zu JavaScript zurückgekehrt wird, gewartet und der WebAssembly-Stapel wiederhergestellt wird, wenn der Vorgang abgeschlossen ist.

Für diesen Code ist kein C++-Code erforderlich. C++-Code dient nur als Treiber für eine Reihe von JavaScript-Vorgängen. Was wäre, wenn Sie fetch_json in JavaScript umwandeln und gleichzeitig den Overhead der Zwischenschritte reduzieren könnten?

EM_JS-Makro

Mit 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 unterstützt sie nur numerische Argumente und Rückgabewerte. Wenn Sie andere Werte übergeben möchten, müssen Sie sie manuell über die entsprechenden APIs konvertieren. Hier einige Beispiele:

Für das Übergeben von Zahlen ist keine Konvertierung erforderlich:

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

int x = add_one(41);

Wenn du Strings an JavaScript und von JavaScript übergeben möchtest, musst du die entsprechenden Konvertierungs- und Zuweisungsfunktionen 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, beliebige Werttypen können Sie die JavaScript API für die oben 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>();

Mit diesen APIs im Hinterkopf könnte das fetch_json-Beispiel so umgeschrieben werden, dass die meisten Aufgaben ohne Verlassen von JavaScript erledigt 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 Konvertierungen am Anfang und Ende der Funktion, aber der Rest ist jetzt regulärer JavaScript-Code. Im Gegensatz zum val-Äquivalent kann es jetzt von der JavaScript-Engine optimiert werden und die C++-Seite muss nur einmal für alle asynchronen Vorgänge angehalten werden.

EM_ASYNC_JS-Makro

Das einzige Element, das nicht schön aussieht, ist der Asyncify.handleAsync-Wrapper. Sein einziger Zweck besteht darin, die Ausführung von async-JavaScript-Funktionen mit Asyncify zu ermöglichen. Dieser Anwendungsfall ist so häufig, dass es jetzt ein spezielles EM_ASYNC_JS-Makro gibt, das beide Funktionen 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

EM_JS wird empfohlen, um JavaScript-Snippets zu deklarieren. Es ist effizient, da die deklarierten Snippets wie alle anderen JavaScript-Funktionsimporte direkt gebunden werden. Außerdem bietet es eine gute Ergonomie, da alle Parametertypen und -namen explizit deklariert werden können.

In einigen Fällen möchten Sie jedoch ein kurzes Snippet für einen console.log-Aufruf, eine debugger;-Anweisung oder etwas Ähnliches einfügen und sich nicht mit der Deklarierung einer ganzen separaten Funktion befassen. 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 sollten wie void-Funktionen funktionieren, EM_ASM_INT-Blöcke können einen Ganzzahlwert zurückgeben und EM_ASM_DOUBLE-Blöcke geben entsprechend Gleitkommazahlen zurück.

Alle übergebenen Argumente sind im JavaScript-Body unter den Namen $0, $1 usw. verfügbar. Wie bei EM_JS oder WebAssembly im Allgemeinen sind die Argumente auf numerische Werte beschränkt, also auf Ganzzahlen, Gleitkommazahlen, Zeiger und Ziehpunkte.

Hier ist 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

Außerdem 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);
  }
});

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, um Prototypen mit entsprechenden JavaScript-Implementierungen zu verbinden.

Dieses Modulformat ist jedoch nicht standardmäßig und erfordert sorgfältige Abhängigkeitsanmerkungen. Sie ist daher hauptsächlich für fortgeschrittene Szenarien vorgesehen.

Fazit

In diesem Beitrag haben wir uns verschiedene Möglichkeiten angesehen, wie Sie JavaScript-Code in C++ einbinden können, wenn Sie mit WebAssembly arbeiten.

Mithilfe solcher Snippets können Sie lange Abfolgen von Vorgängen übersichtlicher und effizienter ausdrücken und Drittanbieterbibliotheken, neue JavaScript APIs und sogar JavaScript-Syntaxfunktionen nutzen, die noch nicht über C++ oder Embind ausgedrückt werden können.