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 sull'integrazione di WebAssembly con il web, devi avere un modo per richiamare API esterne come le API web e le librerie di terze parti. Devi quindi trovare un modo per archiviare i valori e le istanze dell'oggetto restituiti dalle API e un modo per passare i valori archiviati ad altre API in un secondo momento. Per le API asincrone, potresti anche dover 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 la memorizzazione e l'uso di valori JavaScript in C++.
  • EM_JS per incorporare gli 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 snippet brevi ed eseguirli in linea, senza dichiarare una funzione.
  • --js-library per scenari avanzati in cui vuoi dichiarare molte funzioni JavaScript insieme come un'unica libreria.

In questo post scoprirai come utilizzarli per attività simili.

classe emscripten::val

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

Ecco come utilizzarlo con .await() di Asyncify per recuperare e analizzare alcuni 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 comportare 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 nel formato intermedio.
  5. Restituire il risultato convertito in C++ e C++ finalmente 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 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 allo stesso tempo l'overhead dei passaggi intermedi?

Macro EM_JS

Il parametro 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 lo stesso WebAssembly, ha un limite che consente di supportare solo argomenti numerici e restituire valori. Per trasmettere altri valori, devi convertirli manualmente tramite le API corrispondenti. Ecco 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 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 valori più complessi e arbitrari, puoi utilizzare l'API JavaScript per la classe val menzionata in precedenza. che ti consente di 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 di uscita della funzione, ma il resto ora è il normale codice JavaScript. A differenza dell'equivalente val, ora può essere ottimizzata dal motore JavaScript e richiede di mettere in pausa il lato C++ una sola volta per tutte le operazioni asincrone.

Macro EM_ASYNC_JS

L'unico bit a sinistra che non ha un aspetto accattivante è 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 gli snippet dichiarati direttamente come qualsiasi altra importazione di funzioni JavaScript. Inoltre, offre una buona ergonomia perché ti consente di dichiarare esplicitamente tutti i tipi e i nomi di parametri.

In alcuni casi, tuttavia, potresti voler inserire uno snippet rapido per la chiamata console.log, un'istruzione debugger; o qualcosa di simile e non vuoi preoccuparti di 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, anziché 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.

Devi usare il nome della macro corretto per scegliere il tipo restituito. Si prevede che i blocchi EM_ASM agiscano come le funzioni void, i blocchi EM_ASM_INT possono restituire un valore intero, mentre i blocchi EM_ASM_DOUBLE restituiscono corrispondenti numeri in virgola mobile.

Qualsiasi argomento passato sarà disponibile nei nomi $0, $1 e così via nel corpo di JavaScript. Come con EM_JS o WebAssembly in generale, gli argomenti sono limitati solo a valori numerici, numeri interi, numeri in virgola mobile, puntatori e punti di manipolazione.

Ecco 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 di 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 la --js-library option, collegando i prototipi alle implementazioni JavaScript corrispondenti.

Tuttavia, questo formato di modulo non è standard e richiede un'attenta annotazioni delle dipendenze. Di conseguenza, è per lo più riservata a scenari avanzati.

Conclusione

In questo post abbiamo visto vari 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 attingere a librerie di terze parti, nuove API JavaScript e persino funzionalità di sintassi JavaScript che non sono ancora esprimebili tramite C++ o Embind.