Intégrer des extraits de code JavaScript en C++ avec Emscripten

Découvrez comment intégrer du code JavaScript dans votre bibliothèque WebAssembly pour communiquer avec le monde extérieur.

Lorsque vous travaillez sur l'intégration de WebAssembly au Web, vous avez besoin d'un moyen de faire appel à des API externes telles que des API Web et des bibliothèques tierces. Vous aurez ensuite besoin d'un moyen de stocker les valeurs et les instances d'objets renvoyées par ces API, puis d'un moyen de transmettre ces valeurs stockées à d'autres API ultérieurement. Pour les API asynchrones, vous devrez peut-être également attendre les promesses dans votre code C/C++ synchrone avec Asyncify et lire le résultat une fois l'opération terminée.

Emscripten fournit plusieurs outils pour de telles interactions:

  • emscripten::val pour stocker et exploiter des valeurs JavaScript en C++.
  • EM_JS pour intégrer des extraits de code JavaScript et les lier en tant que fonctions C/C++.
  • EM_ASYNC_JS est semblable à EM_JS, mais facilite l'intégration d'extraits JavaScript asynchrones.
  • EM_ASM pour intégrer de courts extraits de code et les exécuter de manière intégrée, sans déclarer de fonction.
  • --js-library pour les scénarios avancés dans lesquels vous souhaitez déclarer de nombreuses fonctions JavaScript en une seule bibliothèque.

Dans ce post, vous allez apprendre à les utiliser tous pour des tâches similaires.

Classe emscripten::val

La classe emcripten::val est fournie par Embind. Il peut appeler des API globales, lier des valeurs JavaScript à des instances C++ et convertir des valeurs entre les types C++ et JavaScript.

Voici comment l'utiliser avec .await() d'Asyncify pour récupérer et analyser du code 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>();

Ce code fonctionne bien, mais il effectue de nombreuses étapes intermédiaires. Chaque opération sur val doit effectuer les étapes suivantes:

  1. Convertissez les valeurs C++ transmises en tant qu'arguments dans un format intermédiaire.
  2. Accédez à JavaScript, lisez les arguments et convertissez-les en valeurs JavaScript.
  3. Exécuter la fonction
  4. Convertissez le résultat JavaScript au format intermédiaire.
  5. Renvoyez le résultat converti en C++, et C++ le lit enfin.

Chaque await() doit également mettre en pause le côté C++ en déroulant toute la pile d'appel du module WebAssembly, en revenant à JavaScript, en attendant et en restaurant la pile WebAssembly une fois l'opération terminée.

Ce code n'a pas besoin de code C++. Le code C++ ne sert qu'à piloter une série d'opérations JavaScript. Et si vous pouviez déplacer fetch_json vers JavaScript tout en réduisant la surcharge des étapes intermédiaires ?

Macro EM_JS

Le EM_JS macro vous permet de déplacer fetch_json vers JavaScript. EM_JS dans Emscripten vous permet de déclarer une fonction C/C++ implémentée par un extrait JavaScript.

Comme WebAssembly lui-même, il présente une limitation qui consiste à ne prendre en charge que les arguments numériques et les valeurs de retour. Pour transmettre d'autres valeurs, vous devez les convertir manuellement via les API correspondantes. Voici quelques exemples :

La transmission des numéros ne nécessite aucune conversion:

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

int x = add_one(41);

Lorsque vous transmettez des chaînes vers et depuis JavaScript, vous devez utiliser les fonctions de conversion et d'allocation correspondantes de 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);
});

Enfin, pour les types de valeurs plus complexes et arbitraires, vous pouvez utiliser l'API JavaScript pour la classe val mentionnée précédemment. À l'aide de cet outil, vous pouvez convertir les valeurs JavaScript et les classes C++ en poignées intermédiaires, puis revenir en arrière:

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

En tenant compte de ces API, l'exemple fetch_json pourrait être réécrit pour effectuer la plupart des tâches sans quitter 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>();

Il existe encore quelques conversions explicites au niveau des points d'entrée et de sortie de la fonction, mais le reste est désormais du code JavaScript standard. Contrairement aux équivalents val, il peut désormais être optimisé par le moteur JavaScript et ne nécessite de mettre en pause le côté C++ qu'une seule fois pour toutes les opérations asynchrones.

Macro EM_ASYNC_JS

Le seul élément qui ne semble pas attrayant est le wrapper Asyncify.handleAsync. Son seul but est d'autoriser l'exécution des fonctions JavaScript async avec Asyncify. En fait, ce cas d'utilisation est si courant qu'il existe désormais une macro EM_ASYNC_JS spécialisée qui les combine.

Voici comment l'utiliser pour générer la version finale de l'exemple 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 est la méthode recommandée pour déclarer des extraits JavaScript. Elle est efficace, car elle lie directement les extraits déclarés, comme toute autre importation de fonction JavaScript. Elle offre également une bonne ergonomie, car elle vous permet de déclarer explicitement tous les types et noms de paramètres.

Toutefois, dans certains cas, vous souhaitez insérer un extrait rapide pour un appel console.log, une instruction debugger; ou une instruction similaire, et vous ne souhaitez pas déclarer une fonction complètement distincte. Dans ces rares cas, EM_ASM macros family (EM_ASM, EM_ASM_INT et EM_ASM_DOUBLE) peut être un choix plus simple. Ces macros sont semblables à la macro EM_JS, mais elles exécutent le code de façon intégrée là où elles sont insérées, au lieu de définir une fonction.

Étant donné qu'ils ne déclarent pas de prototype de fonction, ils ont besoin d'une méthode différente pour spécifier le type renvoyé et accéder aux arguments.

Vous devez utiliser le nom de macro approprié pour choisir le type renvoyé. Les blocs EM_ASM sont censés agir comme des fonctions void, les blocs EM_ASM_INT peuvent renvoyer une valeur entière et les blocs EM_ASM_DOUBLE renvoient des nombres à virgule flottante correspondants.

Tous les arguments transmis seront disponibles sous les noms $0, $1, etc. dans le corps JavaScript. Comme pour EM_JS ou WebAssembly en général, les arguments ne sont limités qu'aux valeurs numériques (entiers, nombres à virgule flottante, pointeurs et poignées).

Voici un exemple d'utilisation d'une macro EM_ASM pour enregistrer une valeur JavaScript arbitraire dans la 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

Enfin, Emscripten permet de déclarer le code JavaScript dans un fichier distinct dans un format de bibliothèque personnalisé:

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

Vous devez ensuite déclarer manuellement les prototypes correspondants côté C++:

extern "C" void log_value(EM_VAL val_handle);

Une fois déclarée des deux côtés, la bibliothèque JavaScript peut être associée au code principal via --js-library option, en connectant les prototypes aux implémentations JavaScript correspondantes.

Cependant, ce format de module n'est pas standard et nécessite des annotations de dépendance minutieuses. Par conséquent, il est principalement réservé aux scénarios avancés.

Conclusion

Dans ce post, nous avons examiné différentes manières d'intégrer du code JavaScript en C++ lorsque vous travaillez avec WebAssembly.

L'inclusion de ces extraits de code vous permet d'exprimer de longues séquences d'opérations de manière plus claire et plus efficace, et d'exploiter des bibliothèques tierces, de nouvelles API JavaScript et même des fonctionnalités de syntaxe JavaScript qui ne peuvent pas encore être exprimées via C++ ou Embind.