Como incorporar snippets de JavaScript em C++ com Emscripten

Aprenda a incorporar código JavaScript na sua 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. Você precisa de uma maneira de armazenar os valores e as instâncias de objeto retornados por essas APIs e uma maneira de transmitir esses valores armazenados para outras APIs mais tarde. Para APIs assíncronas, talvez você também precise aguardar promessas no código C/C++ síncrono com o Asyncify e ler o resultado quando a operação for concluída.

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

  • emscripten::val para armazenar e operar em valores JavaScript em C++.
  • EM_JS para incorporar snippets JavaScript e vincular como funções C/C++.
  • EM_ASYNC_JS semelhante a EM_JS, mas que facilita a incorporação de snippets de JavaScript assíncronos.
  • EM_ASM para incorporar snippets curtos e executá-los inline, 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.

Neste post, você vai aprender a usar todos eles para tarefas semelhantes.

Classe emscripten::val

A classe emcripten::val é fornecida pelo 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 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 em val precisa realizar as seguintes etapas:

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

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

Esse código não precisa de nada do C++. O código C++ 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 das etapas intermediárias ao mesmo tempo?

Macro EM_JS

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

Assim como a WebAssembly, ela 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.

Não é necessário converter os números:

// 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 para e do JavaScript, é necessário 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 valores mais complexos e arbitrários, use a API JavaScript para a classe val mencionada anteriormente. Com ele, é possível converter valores do 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 só requer pausar o lado C++ uma vez para todas as operações assíncronas.

Macro EM_ASYNC_JS

O único pedaço que não parece muito bom é o wrapper Asyncify.handleAsync. O único objetivo dele é permitir a execução de funções JavaScript async com o Asyncify. Na verdade, esse caso de uso é tão comum que agora há uma macro EM_ASYNC_JS especializada que os combina.

Confira como usá-lo para produzir a versão final do exemplo 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. Ele é eficiente porque vincula os snippets declarados diretamente, como qualquer outra importação de função JavaScript. Ele também oferece boa ergonomia, permitindo que você declare explicitamente todos os tipos e nomes de parâmetros.

No entanto, 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 se preocupar em declarar uma função 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 à EM_JS, mas executam o código inline onde são inseridas, em vez de definir uma função.

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

É necessário usar o nome correto da macro para escolher o tipo de retorno. Os blocos EM_ASM devem funcionar 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.

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

Confira um exemplo de como usar uma macro EM_ASM para registrar um valor arbitrário do 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 oferece suporte à declaração de 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, é necessário declarar manualmente os protótipos correspondentes 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 pelo --js-library option, conectando protótipos às implementações JavaScript correspondentes.

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

Conclusão

Neste post, 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 uma maneira mais limpa e eficiente, além de usar bibliotecas de terceiros, novas APIs JavaScript e até mesmo recursos de sintaxe JavaScript que ainda não podem ser expressos em C++ ou Embind.