Incorporación de fragmentos de JavaScript en C++ con Emscripten

Aprende a incorporar código JavaScript en tu biblioteca de WebAssembly para comunicarte con el mundo exterior.

Cuando trabajas en la integración de WebAssembly con la Web, necesitas una forma de llamar a APIs externas, como APIs web y bibliotecas de terceros. Luego, necesitas una manera de almacenar los valores y las instancias de objetos que muestran esas APIs, y una manera de pasar esos valores almacenados a otras APIs más adelante. En el caso de las APIs asíncronas, es posible que también debas esperar promesas en tu código C/C++ síncrono con Asyncify y leer el resultado una vez que finalice la operación.

Emscripten proporciona varias herramientas para estas interacciones:

  • emscripten::val para almacenar y operar en valores de JavaScript en C++.
  • EM_JS para incorporar fragmentos de JavaScript y vincularlos como funciones C/C++
  • EM_ASYNC_JS es similar a EM_JS, pero facilita la incorporación de fragmentos asíncronos de JavaScript.
  • EM_ASM para incorporar fragmentos cortos y ejecutarlos intercalados, sin declarar una función
  • --js-library para situaciones avanzadas en las que deseas declarar muchas funciones de JavaScript juntas como una sola biblioteca.

En esta publicación, aprenderás a usarlas para tareas similares.

clase emscripten::val

Embind proporciona la clase emcripten::val. Puede invocar APIs globales, vincular valores de JavaScript a instancias de C++ y convertir valores entre tipos de C++ y JavaScript.

A continuación, te mostramos cómo usarlo con .await() de Asyncify para recuperar y analizar algunos 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>();

Este código funciona bien, pero realiza muchos pasos intermedios. Cada operación en val debe realizar los siguientes pasos:

  1. Convierte los valores de C++ pasados como argumentos a algún formato intermedio.
  2. Ve a JavaScript, lee los argumentos y conviértelos en valores de JavaScript.
  3. Ejecuta la función
  4. Convertir el resultado de JavaScript a un formato intermedio
  5. Muestra el resultado convertido a C++, y C++ finalmente lo lee.

Cada await() también debe pausar el lado de C++. Para ello, se desenrolla toda la pila de llamadas del módulo de WebAssembly, se vuelve a JavaScript, se espera y se restablece la pila de WebAssembly cuando se completa la operación.

Este código no necesita nada de C++. El código C++ funciona solo como controlador de una serie de operaciones de JavaScript. ¿Qué pasaría si pudieras mover fetch_json a JavaScript y reducir la sobrecarga de los pasos intermedios al mismo tiempo?

Macro EM_JS

El objeto EM_JS macro te permite mover fetch_json a JavaScript. EM_JS en Emscripten te permite declarar una función de C/C++ implementada por un fragmento de JavaScript.

Al igual que WebAssembly, tiene la limitación de admitir solo argumentos numéricos y valores de retorno. Para pasar cualquier otro valor, debes convertirlo manualmente a través de las APIs correspondientes. Aquí tienes algunos ejemplos.

No se necesita ninguna conversión para pasar los números:

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

int x = add_one(41);

Cuando pasas cadenas desde y hacia JavaScript, debes utilizar las funciones de conversión y asignación correspondientes 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);
});

Por último, para tipos de valores más complejos y arbitrarios, puedes usar la API de JavaScript para la clase val mencionada anteriormente. Con él, puedes convertir valores de JavaScript y clases C++ en controladores intermedios y 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>();

Con esas APIs en mente, el ejemplo de fetch_json podría reescribirse para realizar la mayor parte del trabajo sin salir de 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>();

Aún tenemos un par de conversiones explícitas en los puntos de entrada y salida de la función, pero el resto ahora es código JavaScript normal. A diferencia del equivalente de val, ahora el motor de JavaScript puede optimizarlo y solo requiere pausar el lado de C++ una vez para todas las operaciones asíncronas.

Macro EM_ASYNC_JS

Lo único que queda que no se ve bien es el wrapper Asyncify.handleAsync. Su único propósito es permitir la ejecución de funciones async de JavaScript con Asyncify. De hecho, este caso de uso es tan común que ahora hay una macro EM_ASYNC_JS especializada que los combina.

A continuación, se muestra cómo puedes usarlo para producir la versión final del ejemplo de 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 es la forma recomendada de declarar fragmentos de JavaScript. Es eficiente porque vincula los fragmentos declarados directamente como cualquier otra importación de función de JavaScript. También proporciona una buena ergonomía, ya que te permite declarar explícitamente todos los tipos y nombres de parámetros.

Sin embargo, en algunos casos, es recomendable que insertes un fragmento rápido para una llamada console.log, una sentencia debugger; o algo similar y no quieras declarar una función completamente independiente. En esos casos poco comunes, un EM_ASM macros family (EM_ASM, EM_ASM_INT y EM_ASM_DOUBLE) podría ser una opción más simple. Esas macros son similares a la macro EM_JS, pero ejecutan el código intercalado donde se insertan, en lugar de definir una función.

Dado que no declara un prototipo de función, necesita una forma diferente de especificar el tipo de datos que se devuelve y acceder a los argumentos.

Debes usar el nombre de macro correcto para elegir el tipo de datos que se muestra. Se espera que los bloques EM_ASM actúen como funciones void, los bloques EM_ASM_INT pueden mostrar un valor entero y los bloques EM_ASM_DOUBLE mostrarán números de punto flotante, en consecuencia.

Todos los argumentos que se pasen estarán disponibles con los nombres $0, $1, y así sucesivamente, en el cuerpo de JavaScript. Al igual que con EM_JS o WebAssembly en general, los argumentos se limitan solo a valores numéricos: números enteros, números de punto flotante, punteros y controladores.

Este es un ejemplo de cómo podrías usar una macro EM_ASM para registrar un valor de JS arbitrario en la consola:

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

Por último, Emscripten admite la declaración de código JavaScript en un archivo separado, con su propio formato de biblioteca personalizado:

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

Luego, debes declarar los prototipos correspondientes de forma manual en el lado de C++:

extern "C" void log_value(EM_VAL val_handle);

Una vez que se declara en ambos lados, la biblioteca de JavaScript se puede vincular con el código principal a través de --js-library option, conectando los prototipos con las implementaciones de JavaScript correspondientes.

Sin embargo, este formato de módulo no es estándar y requiere anotaciones cuidadosas de dependencias. Por lo tanto, se reserva principalmente para situaciones avanzadas.

Conclusión

En esta publicación, analizamos varias formas de integrar el código de JavaScript en C++ cuando se trabaja con WebAssembly.

La inclusión de estos fragmentos te permite expresar largas secuencias de operaciones de una manera más simple y eficiente, y aprovechar bibliotecas de terceros, nuevas API de JavaScript e incluso funciones de sintaxis de JavaScript que aún no se pueden expresar mediante C++ o Embind.