Menyematkan cuplikan JavaScript di C++ dengan Emscripten

Pelajari cara menyematkan kode JavaScript di library WebAssembly untuk berkomunikasi dengan dunia luar.

Saat mengerjakan integrasi WebAssembly dengan web, Anda memerlukan cara untuk memanggil API eksternal seperti API web dan library pihak ketiga. Anda kemudian memerlukan cara untuk menyimpan nilai dan instance objek yang ditampilkan API tersebut, serta cara untuk meneruskan nilai yang disimpan tersebut ke API lain nanti. Untuk API asinkron, Anda mungkin juga perlu menunggu promise dalam kode C/C++ sinkron dengan Asyncify lalu baca hasilnya setelah operasi selesai.

Emscripten menyediakan beberapa alat untuk interaksi tersebut:

  • emscripten::val untuk menyimpan dan mengoperasikan nilai JavaScript di C++.
  • EM_JS untuk menyematkan cuplikan JavaScript dan mengikatnya sebagai fungsi C/C++.
  • EM_ASYNC_JS yang mirip dengan EM_JS, tetapi mempermudah penyematan cuplikan JavaScript asinkron.
  • EM_ASM untuk menyematkan cuplikan pendek dan mengeksekusinya secara inline, tanpa mendeklarasikan fungsi.
  • --js-library untuk skenario lanjutan saat Anda ingin mendeklarasikan banyak fungsi JavaScript secara bersamaan sebagai satu library.

Dalam postingan ini, Anda akan mempelajari cara menggunakan semuanya untuk tugas serupa.

kelas emscripten::val

Class emcripten::val disediakan oleh Embind. Fungsi ini dapat memanggil API global, mengikat nilai JavaScript ke instance C++, dan mengonversi nilai antara jenis C++ dan JavaScript.

Berikut cara menggunakannya dengan .await() Asyncify untuk mengambil dan mengurai beberapa 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>();

Kode ini berfungsi dengan baik, tetapi melakukan banyak langkah perantara. Setiap operasi di val harus melakukan langkah-langkah berikut:

  1. Mengonversi nilai C++ yang diteruskan sebagai argumen ke dalam beberapa format perantara.
  2. Buka JavaScript, baca dan konversi argumen menjadi nilai JavaScript.
  3. Menjalankan fungsi
  4. Konversikan hasil dari JavaScript ke format menengah.
  5. Tampilkan hasil yang dikonversi ke C++, dan C++ akhirnya akan membacanya kembali.

Setiap await() juga harus menjeda sisi C++ dengan melepaskan seluruh stack panggilan modul WebAssembly, kembali ke JavaScript, menunggu, dan memulihkan stack WebAssembly setelah operasi selesai.

Kode tersebut tidak memerlukan apa pun dari C++. Kode C++ hanya bertindak sebagai driver untuk serangkaian operasi JavaScript. Bagaimana jika Anda dapat memindahkan fetch_json ke JavaScript dan mengurangi overhead langkah perantara secara bersamaan?

Makro EM_JS

EM_JS macro memungkinkan Anda memindahkan fetch_json ke JavaScript. EM_JS di Emscripten memungkinkan Anda mendeklarasikan fungsi C/C++ yang diterapkan oleh cuplikan JavaScript.

Seperti WebAssembly itu sendiri, ia memiliki batasan hanya mendukung argumen numerik dan nilai yang ditampilkan. Untuk meneruskan nilai lain, Anda harus mengonversinya secara manual melalui API yang sesuai. Berikut ini beberapa contohnya.

Penerusan angka tidak memerlukan konversi apa pun:

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

int x = add_one(41);

Saat meneruskan string ke dan dari JavaScript, Anda perlu menggunakan fungsi konversi dan alokasi yang sesuai dari 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);
});

Terakhir, untuk jenis nilai yang lebih kompleks dan arbitrer, Anda dapat menggunakan JavaScript API untuk class val yang disebutkan sebelumnya. Dengan menggunakannya, Anda dapat mengonversi nilai JavaScript dan class C++ menjadi handle perantara dan kembali:

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

Dengan mempertimbangkan API tersebut, contoh fetch_json dapat ditulis ulang untuk melakukan sebagian besar pekerjaan tanpa meninggalkan 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>();

Kami masih memiliki beberapa konversi eksplisit di titik masuk dan keluar fungsi, tetapi sisanya adalah kode JavaScript biasa. Tidak seperti val yang setara, fungsi ini kini dapat dioptimalkan oleh mesin JavaScript dan hanya perlu menjeda sisi C++ satu kali untuk semua operasi asinkron.

Makro EM_ASYNC_JS

Satu-satunya bagian kiri yang terlihat tidak rapi adalah wrapper Asyncify.handleAsync—satu-satunya tujuan adalah memungkinkan eksekusi fungsi JavaScript async dengan Asyncify. Faktanya, kasus penggunaan ini sangat umum sehingga sekarang ada makro EM_ASYNC_JS khusus yang menggabungkannya.

Berikut cara menggunakannya untuk menghasilkan versi final dari contoh 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 adalah cara yang direkomendasikan untuk mendeklarasikan cuplikan JavaScript. Ini efisien karena mengikat cuplikan yang dinyatakan secara langsung seperti impor fungsi JavaScript lainnya. Ini juga memberikan ergonomi yang baik dengan memungkinkan Anda untuk secara eksplisit mendeklarasikan semua jenis dan nama parameter.

Namun, dalam beberapa kasus, Anda ingin memasukkan cuplikan cepat untuk panggilan console.log, pernyataan debugger;, atau sesuatu yang serupa dan tidak ingin repot-repot mendeklarasikan fungsi yang sepenuhnya terpisah. Dalam kasus yang jarang terjadi, EM_ASM macros family (EM_ASM, EM_ASM_INT, dan EM_ASM_DOUBLE) mungkin menjadi pilihan yang lebih sederhana. Makro tersebut mirip dengan makro EM_JS, tetapi mengeksekusi kode inline tempat makro tersebut disisipkan, bukan menentukan fungsi.

Karena tidak mendeklarasikan prototipe fungsi, parameter tersebut memerlukan cara yang berbeda untuk menentukan jenis nilai yang ditampilkan dan argumen akses.

Anda harus menggunakan nama makro yang tepat untuk memilih jenis nilai yang ditampilkan. Blok EM_ASM diharapkan berfungsi seperti fungsi void, blok EM_ASM_INT dapat menampilkan nilai bilangan bulat, dan blok EM_ASM_DOUBLE menampilkan angka floating point yang sesuai.

Setiap argumen yang diteruskan akan tersedia dengan nama $0, $1, dan seterusnya dalam isi JavaScript. Seperti halnya EM_JS atau WebAssembly pada umumnya, argumen hanya dibatasi untuk nilai numerik—bilangan bulat, angka floating point, pointer, dan handle.

Berikut adalah contoh cara menggunakan makro EM_ASM untuk mencatat log nilai JS arbitrer ke konsol:

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

Terakhir, Emscripten mendukung deklarasi kode JavaScript di file terpisah dalam format library kustomnya sendiri:

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

Kemudian, Anda perlu mendeklarasikan prototipe yang sesuai secara manual di sisi C++:

extern "C" void log_value(EM_VAL val_handle);

Setelah dideklarasikan di kedua sisi, library JavaScript dapat ditautkan bersama dengan kode utama melalui --js-library option, yang menghubungkan prototipe dengan implementasi JavaScript yang sesuai.

Namun, format modul ini tidak standar dan memerlukan anotasi dependensi yang cermat. Dengan demikian, sebagian besar sistem ini dicadangkan untuk skenario lanjutan.

Kesimpulan

Dalam postingan ini kita telah melihat berbagai cara untuk mengintegrasikan kode JavaScript ke dalam C++ saat bekerja dengan WebAssembly.

Menyertakan cuplikan tersebut memungkinkan Anda untuk mengekspresikan urutan operasi yang panjang dengan cara yang lebih bersih dan efisien, dan memanfaatkan library pihak ketiga, JavaScript API baru, dan bahkan fitur sintaksis JavaScript yang belum dapat diungkapkan melalui C++ atau Embind.