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 d'appeler des API externes telles que des API Web et des bibliothèques tierces. Vous avez ensuite besoin d'un moyen de stocker les valeurs et les instances d'objet renvoyées par ces API, ainsi que de transmettre ultérieurement ces valeurs stockées à d'autres API. Pour les API asynchrones, vous devrez peut-être également attendre les promesses de votre code C/C++ synchrone avec Asyncify et lire le résultat une fois l'opération terminée.

Emscripten fournit plusieurs outils pour ces interactions:

  • emscripten::val pour stocker et utiliser 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 de code JavaScript asynchrones.
  • EM_ASM pour intégrer des extraits courts 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 ensemble comme une seule bibliothèque.

Dans cet article, vous apprendrez à 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. Convertir les valeurs C++ transmises en tant qu'arguments dans un format intermédiaire
  2. Accédez à JavaScript, lisez et convertissez des arguments en valeurs JavaScript.
  3. Exécuter la fonction
  4. Convertissez le résultat du format JavaScript au format intermédiaire.
  5. Renvoyez le résultat converti en C++. C++ le relit ensuite.

Chaque await() doit également suspendre 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 besoin d'aucun code C++. Le code C++ n'agit que comme pilote pour une série d'opérations JavaScript. Et si vous pouviez migrer fetch_json vers JavaScript tout en réduisant la surcharge des étapes intermédiaires ?

Macro EM_JS

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

Comme WebAssembly lui-même, il n'accepte 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.

Les numéros de transfert ne nécessitent aucune conversion:

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

int x = add_one(41);

Pour transmettre 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 des types de valeurs plus complexes et arbitraires, vous pouvez utiliser l'API JavaScript pour la classe val mentionnée précédemment. Grâce à lui, vous pouvez convertir les valeurs JavaScript et les classes C++ en handle et retour intermédiaires:

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 gardant ces API à l'esprit, 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 aux points d'entrée et de sortie de la fonction, mais le reste correspond à du code JavaScript standard. Contrairement à son équivalent 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 n'a pas l'air joli est le wrapper Asyncify.handleAsync. Son seul objectif 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 produire 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. Il est efficace, car il lie les extraits déclarés directement comme n'importe quelle autre importation de fonction JavaScript. Il offre également une bonne ergonomie, car il vous permet de déclarer explicitement tous les types et noms de paramètres.

Toutefois, dans certains cas, vous pouvez insérer un extrait rapide pour un appel console.log, une instruction debugger; ou une instruction similaire, sans avoir à 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.

Comme ils ne déclarent pas de prototype de fonction, ils ont besoin d'une autre façon de spécifier le type renvoyé et d'accéder aux arguments.

Pour choisir le type renvoyé, vous devez utiliser le nom de macro approprié. Les blocs EM_ASM doivent 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 avec 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 consigner une valeur JS 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, afin d'associer les prototypes aux implémentations JavaScript correspondantes.

Cependant, ce format de module n'est pas standard et nécessite des annotations de dépendance prudentes. Il est donc principalement réservé aux scénarios avancés.

Conclusion

Dans cet article, nous avons abordé différentes manières d'intégrer du code JavaScript en C++ avec WebAssembly.

Inclure de tels extraits 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.