Como incorporar snippets de JavaScript em C++ com Emscripten

Aprenda a incorporar o código JavaScript à biblioteca WebAssembly para se comunicar com o mundo exterior.

Ao trabalhar na integração do WebAssembly com a Web, você precisa de uma maneira de chamar APIs externas, como APIs da Web e bibliotecas de terceiros. Em seguida, você precisa de uma maneira de armazenar os valores e as instâncias de objeto que essas APIs retornam e uma maneira de transmitir esses valores armazenados para outras APIs posteriormente. Para APIs assíncronas, talvez seja necessário aguardar promessas no código C/C++ síncrono com o comando Asyncify e ler o resultado quando a operação for concluída.

A Emscripten oferece várias ferramentas para essas interações:

  • emscripten::val para armazenar e operar valores JavaScript em C++.
  • EM_JS para incorporar snippets JavaScript e vinculá-los como funções C/C++.
  • EM_ASYNC_JS, que é semelhante a EM_JS, mas facilita a incorporação de snippets JavaScript assíncronos.
  • EM_ASM para incorporar snippets curtos e executá-los in-line, sem declarar uma função.
  • --js-library para cenários avançados em que você quer declarar muitas funções JavaScript juntas como uma única biblioteca.

Nesta postagem, você vai aprender a usar todos eles para tarefas semelhantes.

classe emscripten::val

A classe emcripten::val é fornecida pela Embind. Ele pode invocar APIs globais, vincular valores JavaScript a instâncias C++ e converter valores entre tipos C++ e JavaScript.

Confira como usá-lo com o .await() do Asyncify para buscar e analisar um 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>();

Esse código funciona bem, mas executa muitas etapas intermediárias. Cada operação no val precisa realizar as seguintes etapas:

  1. Converter valores C++ transmitidos como argumentos em algum formato intermediário.
  2. Acesse JavaScript, leia e converta argumentos em valores JavaScript.
  3. Executar a função
  4. Converta o resultado do JavaScript para o formato intermediário.
  5. O resultado convertido é retornado em C++, que é lido por C++.

Cada await() também precisa pausar o lado C++ liberando toda a pilha de chamadas do módulo WebAssembly, retornando ao JavaScript, aguardando e restaurando a pilha WebAssembly quando a operação for concluída.

Esse código não precisa de nada do C++, que atua apenas como um driver para uma série de operações JavaScript. E se você pudesse mover fetch_json para JavaScript e reduzir a sobrecarga de etapas intermediárias ao mesmo tempo?

Macro EM_JS

O EM_JS macro permite mover o fetch_json para o JavaScript. EM_JS no Emscripten permite declarar uma função C/C++ implementada por um snippet JavaScript.

Como o próprio WebAssembly, ele tem a limitação de oferecer suporte apenas a argumentos numéricos e valores de retorno. Para transmitir outros valores, é necessário convertê-los manualmente usando as APIs correspondentes. Veja alguns exemplos.

Transmitir números não precisa de nenhuma conversão:

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

int x = add_one(41);

Ao transmitir strings de e para JavaScript, você precisa usar as funções de conversão e alocação correspondentes 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 fim, para tipos de valor mais complexos e arbitrários, use a API JavaScript para a classe val mencionada anteriormente. Com ele, você pode converter valores JavaScript e classes C++ em identificadores intermediários e vice-versa:

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

Com essas APIs em mente, o exemplo fetch_json pode ser reescrito para fazer a maior parte do trabalho sem sair do 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>();

Ainda temos algumas conversões explícitas nos pontos de entrada e saída da função, mas o restante agora é código JavaScript normal. Ao contrário do equivalente val, agora ele pode ser otimizado pelo mecanismo JavaScript e requer apenas a pausa do lado C++ uma vez para todas as operações assíncronas

Macro EM_ASYNC_JS

O único bit que não parece interessante é o wrapper Asyncify.handleAsync. O único propósito dele é permitir a execução de funções JavaScript async com o Asyncify. Na verdade, esse caso de uso é tão comum que agora existe uma macro EM_ASYNC_JS especializada que os combina.

Confira como ele pode ser usado para produzir a versão final do exemplo 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 é a maneira recomendada de declarar snippets JavaScript. É eficiente porque vincula os snippets declarados diretamente como qualquer outra importação de função JavaScript. Ela também oferece uma boa ergonomia ao permitir que você declare explicitamente todos os tipos e nomes de parâmetros.

Em alguns casos, você quer inserir um snippet rápido para a chamada console.log, uma instrução debugger; ou algo semelhante e não quer declarar uma função totalmente separada. Nesses casos raros, um EM_ASM macros family (EM_ASM, EM_ASM_INT e EM_ASM_DOUBLE) pode ser uma opção mais simples. Essas macros são semelhantes à macro EM_JS, mas executam o código in-line no local em que são inseridas, em vez de definir uma função.

Como eles não declaram um protótipo de função, precisam de uma maneira diferente de especificar o tipo de retorno e acessar os argumentos.

É necessário usar o nome correto da macro para escolher o tipo de retorno. Espera-se que os blocos EM_ASM atuem como funções void, os blocos EM_ASM_INT podem retornar um valor inteiro, e os blocos EM_ASM_DOUBLE retornam números de ponto flutuante correspondentes.

Os argumentos transmitidos vão estar disponíveis nos nomes $0, $1 e assim por diante no corpo do JavaScript. Como acontece com o EM_JS ou o WebAssembly em geral, os argumentos são limitados apenas a valores numéricos: números inteiros, números de ponto flutuante, ponteiros e identificadores.

Confira um exemplo de como usar uma macro EM_ASM para registrar um valor arbitrário de JS no 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

Por fim, o Emscripten aceita declarar o código JavaScript em um arquivo separado em um formato de biblioteca personalizado:

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

Em seguida, você precisa declarar os protótipos correspondentes manualmente no lado do C++:

extern "C" void log_value(EM_VAL val_handle);

Depois de declarada em ambos os lados, a biblioteca JavaScript pode ser vinculada ao código principal por meio de --js-library option, conectando protótipos com as implementações de JavaScript correspondentes.

No entanto, o formato desse módulo não é padrão e requer anotações de dependência cuidadosas. Por isso, ela é reservada principalmente para cenários avançados.

Conclusão

Nesta publicação, analisamos várias maneiras de integrar o código JavaScript ao C++ ao trabalhar com o WebAssembly.

A inclusão desses snippets permite expressar longas sequências de operações de forma mais limpa e eficiente e aproveitar bibliotecas de terceiros, novas APIs JavaScript e até mesmo recursos de sintaxe JavaScript que ainda não são expressos por C++ ou Embind.