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 avec le Web, vous avez besoin d'un moyen d'appeler des API externes telles que des API Web et des bibliothèques tierces. Vous devez ensuite trouver un moyen de stocker les valeurs et les instances d'objets renvoyées par ces API, ainsi qu'un moyen de transmettre ces valeurs stockées à d'autres API ultérieurement. Pour les API asynchrones, vous devrez peut-être également attendre des 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 ces interactions :

  • emscripten::val pour stocker et exploiter des valeurs JavaScript en C++.
  • EM_JS pour intégrer des extraits JavaScript et les lier en tant que fonctions C/C++.
  • EM_ASYNC_JS, qui 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 façon 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 en tant que bibliothèque unique.

Dans cet article, 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 extraire 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 suivre les étapes suivantes :

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

Chaque await() doit également mettre en pause le côté C++ en déroulant l'ensemble de la pile d'appels 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 du code C++. Le code C++ sert uniquement de pilote pour 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

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 ne prend en charge que les arguments et les valeurs de retour numériques. Pour transmettre d'autres valeurs, vous devez les convertir manuellement via les API correspondantes. Voici quelques exemples :

Vous n'avez pas besoin de convertir les nombres que vous transmettez :

// 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 depuis et vers 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. À 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 très élégant est le wrapper Asyncify.handleAsync. Son seul but est d'autoriser l'exécution de 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. Il est efficace, car il lie directement les extraits déclarés, comme toute autre importation de fonction JavaScript. Il offre également une bonne ergonomie en vous permettant 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 quelque chose de similaire, et vous ne voulez pas déclarer une fonction distincte. Dans de rares cas, un EM_ASM macros family (EM_ASM, EM_ASM_INT et EM_ASM_DOUBLE) peut être un choix plus simple. Ces macros sont similaires à la macro EM_JS, mais elles exécutent du code intégré à l'endroit où elles sont insérées, au lieu de définir une fonction.

Comme elles ne déclarent pas de prototype de fonction, elles ont besoin d'un autre moyen de spécifier le type de retour et d'accéder aux arguments.

Vous devez utiliser le nom de macro approprié pour choisir le type renvoyé. Les blocs EM_ASM doivent se comporter 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 en conséquence.

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.

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

Conclusion

Dans cet article, nous avons examiné différentes façons d'intégrer du code JavaScript dans du code C++ lorsque vous travaillez avec WebAssembly.

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