Incorporare snippet JavaScript in C++ con Emscripten

Scopri come incorporare il codice JavaScript nella libreria WebAssembly per comunicare con il mondo esterno.

Ingvar Stepanyan
Ingvar Stepanyan

Quando lavori all'integrazione di WebAssembly con il web, hai bisogno di un modo per chiamare API esterne come API web e librerie di terze parti. Devi quindi avere un modo per archiviare i valori e le istanze di oggetti restituiti da queste API e un modo per trasmettere in un secondo momento i valori archiviati ad altre API. Per le API asincrone, potrebbe anche essere necessario attendere le promesse nel codice C/C++ sincrono con Asyncify e leggere il risultato al termine dell'operazione.

Emscripten fornisce diversi strumenti per queste interazioni:

  • emscripten::val per memorizzare e operare sui valori JavaScript in C++.
  • EM_JS per incorporare snippet JavaScript e associarli come funzioni C/C++.
  • EM_ASYNC_JS simile a EM_JS, ma che semplifica l'inserimento di snippet JavaScript asincroni.
  • EM_ASM per incorporare snippet brevi ed eseguirli in linea, senza dichiarare una funzione.
  • --js-library per scenari avanzati in cui vuoi dichiarare molte funzioni JavaScript insieme come una singola libreria.

In questo post scoprirai come utilizzarli tutti per attività simili.

Classe emscripten::val

La classe emcripten::val è fornita da Embind. Può richiamare API globali, associare valori JavaScript a istanze C++ e convertire valori tra tipi C++ e JavaScript.

Ecco come utilizzarlo con .await() di Asyncify per recuperare e analizzare del 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>();

Questo codice funziona bene, ma esegue molti passaggi intermedi. Ogni operazione su val deve eseguire i seguenti passaggi:

  1. Converte i valori C++ passati come argomenti in un formato intermedio.
  2. Vai a JavaScript, leggi e converti gli argomenti in valori JavaScript.
  3. Esegui la funzione
  4. Converti il risultato da JavaScript in formato intermedio.
  5. Restituire il risultato convertito a C++, che lo legge di nuovo.

Ogni await() deve anche mettere in pausa il lato C++ annullando l'intero stack di chiamate del modulo WebAssembly, tornando a JavaScript, aspettando e ripristinando lo stack WebAssembly al termine dell'operazione.

Questo codice non ha bisogno di nulla da C++. Il codice C++ funge solo da driver per una serie di operazioni JavaScript. E se potessi spostare fetch_json in JavaScript e ridurre contemporaneamente l'overhead dei passaggi intermedi?

Macro EM_JS

EM_JS macro ti consente di spostare fetch_json in JavaScript. EM_JS in Emscripten ti consente di dichiarare una funzione C/C++ implementata da uno snippet JavaScript.

Come WebAssembly stesso, ha la limitazione di supportare solo argomenti e valori restituiti numerici. Per trasmettere altri valori, devi convertirli manualmente tramite le API corrispondenti. Di seguito sono riportati alcuni esempi.

La trasmissione di numeri non richiede alcuna conversione:

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

int x = add_one(41);

Quando passi stringhe a e da JavaScript, devi utilizzare le funzioni di conversione e allocazione corrispondenti di 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);
});

Infine, per tipi di valori più complessi e arbitrari, puoi utilizzare l'API JavaScript per la classe val menzionata in precedenza. Con questo metodo, puoi convertire i valori JavaScript e le classi C++ in handle intermedi e viceversa:

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

Tenendo presente queste API, l'esempio fetch_json potrebbe essere riscritto per svolgere la maggior parte del lavoro senza uscire da 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>();

Abbiamo ancora un paio di conversioni esplicite nei punti di ingresso e di uscita della funzione, ma il resto è ora codice JavaScript normale. A differenza dell'equivalente val, ora può essere ottimizzato dal motore JavaScript e richiede solo di mettere in pausa il lato C++ una volta per tutte le operazioni asincrone.

Macro EM_ASYNC_JS

L'unico elemento che non sembra bello è il wrapper Asyncify.handleAsync, il cui unico scopo è consentire l'esecuzione di funzioni JavaScript async con Asyncify. In effetti, questo caso d'uso è così comune che ora esiste una macro EM_ASYNC_JS specializzata che li combina.

Ecco come puoi utilizzarlo per produrre la versione finale dell'esempio 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 è il metodo consigliato per dichiarare gli snippet JavaScript. È efficiente perché lega gli snippet dichiarati direttamente come qualsiasi altra importazione di funzione JavaScript. Offre inoltre una buona ergonomia consentendoti di dichiarare esplicitamente tutti i tipi e i nomi dei parametri.

In alcuni casi, però, vuoi inserire uno snippet rapido per la chiamata console.log, un'istruzione debugger; o qualcosa di simile e non vuoi preoccuparti di dichiarare un'intera funzione separata. In questi rari casi, un EM_ASM macros family (EM_ASM, EM_ASM_INT e EM_ASM_DOUBLE) potrebbe essere una scelta più semplice. Queste macro sono simili alla macro EM_JS, ma eseguono il codice in linea dove vengono inserite, anziché definire una funzione.

Poiché non dichiarano un prototipo di funzione, hanno bisogno di un modo diverso per specificare il tipo di ritorno e accedere agli argomenti.

Devi utilizzare il nome della macro corretto per scegliere il tipo di ritorno. I blocchi EM_ASM dovrebbero comportarsi come funzioni void, i blocchi EM_ASM_INT possono restituire un valore intero e i blocchi EM_ASM_DOUBLE restituiscono numeri in virgola mobile di conseguenza.

Gli eventuali argomenti passati saranno disponibili con i nomi $0, $1 e così via nel corpo di JavaScript. Come per EM_JS o WebAssembly in generale, gli argomenti sono limitati solo a valori numerici: numeri interi, numeri in virgola mobile, puntatori e handle.

Ecco un esempio di come utilizzare una macro EM_ASM per registrare un valore JS arbitrario nella console:

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

Infine, Emscripten supporta la dichiarazione del codice JavaScript in un file separato in un formato della libreria personalizzato:

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

Poi devi dichiarare manualmente i prototipi corrispondenti lato C++:

extern "C" void log_value(EM_VAL val_handle);

Una volta dichiarata su entrambi i lati, la libreria JavaScript può essere collegata al codice principale tramite --js-library option, collegando i prototipi alle implementazioni JavaScript corrispondenti.

Tuttavia, questo formato del modulo non è standard e richiede annotazioni delle dipendenze attente. Di conseguenza, è principalmente riservato a scenari avanzati.

Conclusione

In questo post abbiamo esaminato diversi modi per integrare il codice JavaScript in C++ quando si lavora con WebAssembly.

L'inclusione di questi snippet ti consente di esprimere lunghe sequenze di operazioni in modo più pulito ed efficiente e di utilizzare librerie di terze parti, nuove API JavaScript e persino funzionalità di sintassi JavaScript che non sono ancora esprimibili tramite C++ o Embind.