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. Selanjutnya, Anda 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 dan membaca 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 mengikatkannya sebagai fungsi C/C++.
  • EM_ASYNC_JS yang mirip dengan EM_JS, tetapi mempermudah penyematan cuplikan JavaScript asinkron.
  • EM_ASM untuk menyematkan cuplikan singkat dan menjalankannya 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.

Class emscripten::val

Class emcripten::val disediakan oleh Embind. Library 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 beberapa format perantara.
  2. Buka JavaScript, baca, dan konversikan argumen menjadi nilai JavaScript.
  3. Menjalankan fungsi
  4. Konversikan hasil dari JavaScript ke format perantara.
  5. Tampilkan hasil yang dikonversi ke C++, dan C++ akhirnya membacanya kembali.

Setiap await() juga harus menjeda sisi C++ dengan menguraikan seluruh stack panggilan modul WebAssembly, kembali ke JavaScript, menunggu, dan memulihkan stack WebAssembly saat 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 diimplementasikan oleh cuplikan JavaScript.

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

Meneruskan 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 arbitrer yang lebih kompleks, 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 sebaliknya:

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

Kita masih memiliki beberapa konversi eksplisit di titik entri dan keluar fungsi, tetapi sisanya sekarang adalah kode JavaScript reguler. Tidak seperti val yang setara, sekarang dapat dioptimalkan oleh mesin JavaScript dan hanya perlu menjeda sisi C++ sekali untuk semua operasi asinkron.

Makro EM_ASYNC_JS

Satu-satunya bagian yang tersisa yang tidak terlihat bagus adalah wrapper Asyncify.handleAsync—satu-satunya tujuannya adalah untuk memungkinkan eksekusi fungsi JavaScript async dengan Asyncify. Bahkan, kasus penggunaan ini sangat umum sehingga kini ada makro EM_ASYNC_JS khusus yang menggabungkannya.

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

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

Karena tidak mendeklarasikan prototipe fungsi, fungsi ini memerlukan cara yang berbeda untuk menentukan jenis nilai yang ditampilkan dan mengakses argumen.

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.

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

Berikut adalah contoh cara menggunakan makro EM_ASM untuk mencatat 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 dalam 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. Oleh karena itu, sebagian besar dikhususkan untuk skenario lanjutan.

Kesimpulan

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

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