Incorporare snippet JavaScript in C++ con Emscripten

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

Ingvar Stepanyan
Ingvar Stepanyan

Quando lavori all'integrazione di WebAssembly con il web, devi trovare un modo per richiamare le API esterne, come le API web e le librerie di terze parti. Hai quindi bisogno di un modo per archiviare i valori e le istanze di oggetti restituiti da queste API e un modo per passare i valori archiviati ad altre API in un secondo momento. 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 offre diversi strumenti per queste interazioni:

  • emscripten::val per l'archiviazione e il funzionamento dei valori JavaScript in C++.
  • EM_JS per incorporare snippet JavaScript e associarli come funzioni C/C++.
  • EM_ASYNC_JS è simile a EM_JS, ma semplifica l'incorporamento degli snippet JavaScript asincroni.
  • EM_ASM per incorporare brevi snippet ed eseguirli in linea, senza dichiarare una funzione.
  • --js-library per scenari avanzati in cui si vuole dichiarare insieme molte funzioni JavaScript come un'unica libreria.

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

emscripten::val classe

Il corso emcripten::val è fornito 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 alcuni file 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. Converti 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 in C++ e C++ infine lo leggerà.

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

Questo codice non richiede nulla di C++. Il codice C++ agisce 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

L'elemento 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, presenta un limite nel supportare solo argomenti numerici e valori restituiti. Per passare eventuali altri valori, devi convertirli manualmente tramite le API corrispondenti. Di seguito sono riportati alcuni esempi.

Il passaggio dei 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 da e verso 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 valore più complessi e arbitrari, puoi utilizzare l'API JavaScript per la classe val menzionata in precedenza. Utilizzandolo, 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 a mente 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 uscita della funzione, ma il resto è ora codice JavaScript normale. A differenza dell'equivalente val, ora può essere ottimizzato dal motore JavaScript e richiede di mettere in pausa il lato C++ solo una volta per tutte le operazioni asincrone.

Macro EM_ASYNC_JS

L'unico pezzo rimasto che non sembra bello è il wrapper Asyncify.handleAsync, il suo unico scopo è consentire l'esecuzione delle funzioni JavaScript async con Asyncify. In effetti, questo caso d'uso è talmente comune che ora esiste una macro EM_ASYNC_JS specializzata che le combina.

Ecco come potresti 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é associa direttamente gli snippet dichiarati come qualsiasi altra importazione di funzioni JavaScript. Fornisce inoltre una buona ergonomia perché consente di dichiarare esplicitamente tutti i tipi e i nomi di parametri.

In alcuni casi, tuttavia, potresti voler inserire un breve snippet per la chiamata console.log, un'istruzione debugger; o qualcosa di simile e non voler dichiarare una funzione completamente separata. In questi rari casi, 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 nel punto in cui vengono inserite, invece di definire una funzione.

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

Per scegliere il tipo restituito, devi utilizzare il nome della macro corretto. I blocchi EM_ASM dovrebbero agire come le funzioni void, i blocchi EM_ASM_INT possono restituire un valore intero e i blocchi EM_ASM_DOUBLE restituiscono rispettivamente numeri in virgola mobile.

Tutti gli 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.

Di seguito è riportato un esempio di come puoi 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 libreria personalizzato:

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

Quindi devi dichiarare manualmente i prototipi corrispondenti sul 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 prototipi con implementazioni JavaScript corrispondenti.

Tuttavia, questo formato di modulo non è standard e richiede attente annotazioni relative alle dipendenze. Di conseguenza, sono per lo più riservate a scenari avanzati.

Conclusione

In questo post abbiamo visto 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ù chiaro ed efficiente e di sfruttare librerie di terze parti, nuove API JavaScript e persino funzionalità di sintassi JavaScript che non sono ancora esprimibili tramite C++ o Embind.