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 WebAssembly in das Web einbinden, benötigen Sie eine Möglichkeit, externe APIs wie Web-APIs und Drittanbieterbibliotheken aufzurufen. Sie benötigen dann eine Möglichkeit, die von diesen APIs zurückgegebenen Werte und Objektinstanzen zu speichern und diese gespeicherten Werte später an andere APIs weiterzugeben. 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 deren Inline-Ausführung, 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.

emscripten::val-Klasse

Die Klasse emcripten::val wird von Embind bereitgestellt. Es 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 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. C++-Werte, die als Argumente übergeben werden, in ein Zwischenformat konvertieren.
  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. Das umgewandelte Ergebnis wird an C++ zurückgegeben und dort schließlich gelesen.

Jede await() muss außerdem die C++-Seite pausieren, indem der gesamte Aufrufstapel des WebAssembly-Moduls zurückgewickelt, zu JavaScript zurückgekehrt, gewartet und der WebAssembly-Stapel wiederhergestellt wird, sobald 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 es 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 übergeben und von JavaScript empfangen 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 können Sie alle Parametertypen und ‑namen explizit deklarieren, was die Ergonomie verbessert.

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 aber Code an der Stelle aus, an der 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 auf andere Weise 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: Ganzzahlen, Gleitkommazahlen, Zeiger und Handles.

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. Daher ist sie hauptsächlich für erweiterte 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.