Nhúng đoạn mã JavaScript trong C++ bằng Emscripten

Tìm hiểu cách nhúng mã JavaScript vào thư viện WebAssembly của bạn để giao tiếp với thế giới bên ngoài.

Khi tích hợp WebAssembly với web, bạn cần có một cách để gọi các API bên ngoài, chẳng hạn như API web và thư viện của bên thứ ba. Sau đó, bạn cần có cách để lưu trữ các giá trị và thực thể đối tượng mà các API đó trả về, cũng như một cách để truyền các giá trị được lưu trữ đó đến các API khác sau này. Đối với các API không đồng bộ, có thể bạn cũng cần phải chờ các lời hứa trong mã C/C++ đồng bộ của mình bằng Asyncify và đọc kết quả sau khi thao tác này hoàn tất.

Emscripten cung cấp một số công cụ cho những hoạt động tương tác như vậy:

  • emscripten::val để lưu trữ và thao tác trên các giá trị JavaScript trong C++.
  • EM_JS để nhúng và liên kết các đoạn mã JavaScript dưới dạng hàm C/C++.
  • EM_ASYNC_JS tương tự như EM_JS nhưng giúp nhúng các đoạn mã JavaScript không đồng bộ dễ dàng hơn.
  • EM_ASM để nhúng các đoạn mã ngắn và thực thi cùng dòng mà không khai báo hàm.
  • --js-library cho các trường hợp nâng cao khi bạn muốn khai báo nhiều hàm JavaScript cùng lúc dưới dạng một thư viện.

Trong bài đăng này, bạn sẽ tìm hiểu cách sử dụng tất cả những thành phần này cho các tác vụ tương tự.

lớp emscripten::val

Lớp emcripten::val do Embind cung cấp. C++ có thể gọi API chung, liên kết các giá trị JavaScript với các phiên bản C++ và chuyển đổi giá trị giữa các loại C++ và JavaScript.

Dưới đây là cách sử dụng tệp này với .await() của Asyncify để tìm nạp và phân tích cú pháp một số tệp 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>();

Mã này hoạt động tốt, nhưng thực hiện nhiều bước trung gian. Mỗi thao tác trên val cần thực hiện các bước sau:

  1. Chuyển đổi các giá trị C++ được truyền dưới dạng đối số sang một định dạng trung gian.
  2. Chuyển đến JavaScript, đọc và chuyển đổi đối số thành giá trị JavaScript.
  3. Thực thi hàm
  4. Chuyển đổi kết quả từ JavaScript sang định dạng trung gian.
  5. Trả về kết quả đã chuyển đổi cho C++ và cuối cùng C++ sẽ đọc lại kết quả đó.

Mỗi await() cũng phải tạm dừng phía C++ bằng cách gỡ bỏ toàn bộ ngăn xếp lệnh gọi của mô-đun WebAssembly, quay lại JavaScript, chờ và khôi phục ngăn xếp WebAssembly khi thao tác hoàn tất.

Mã như vậy không cần bất cứ thứ gì từ C++. Mã C++ chỉ đóng vai trò là trình điều khiển cho một loạt các thao tác JavaScript. Điều gì xảy ra nếu bạn có thể di chuyển fetch_json sang JavaScript và giảm mức hao tổn của các bước trung gian cùng một lúc?

Macro EM_JS

EM_JS macro cho phép bạn di chuyển fetch_json sang JavaScript. EM_JS trong Emscripten cho phép bạn khai báo hàm C/C++ được triển khai bằng một đoạn mã JavaScript.

Giống như chính WebAssembly, API này có giới hạn là chỉ hỗ trợ các đối số dạng số và giá trị trả về. Để truyền các giá trị khác, bạn cần chuyển đổi các giá trị đó theo cách thủ công thông qua các API tương ứng. Dưới đây là một số ví dụ.

Việc chuyển số không cần bất kỳ chuyển đổi nào:

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

int x = add_one(41);

Khi chuyển các chuỗi đến và từ JavaScript, bạn cần sử dụng các hàm chuyển đổi và phân bổ tương ứng từ 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);
});

Cuối cùng, đối với các loại giá trị phức tạp, tuỳ ý hơn và có thể sử dụng API JavaScript cho lớp val đã đề cập trước đó. Bằng cách sử dụng lớp này, bạn có thể chuyển đổi các giá trị JavaScript và lớp C++ thành ô điều khiển trung gian và ngược lại:

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

Với những API đó, bạn có thể viết lại ví dụ về fetch_json để thực hiện hầu hết mọi việc mà không cần rời khỏi 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>();

Chúng ta vẫn có một vài lượt chuyển đổi rõ ràng tại điểm truy cập và điểm thoát của hàm này, nhưng phần còn lại hiện là mã JavaScript thông thường. Không giống như val tương đương, giờ đây, mã này có thể được công cụ JavaScript tối ưu hoá và chỉ yêu cầu tạm dừng phía C++ một lần cho tất cả các hoạt động không đồng bộ.

Macro EM_ASYNC_JS

Chỉ còn lại một chút trông không đẹp mắt là trình bao bọc Asyncify.handleAsync — mục đích duy nhất của nó là cho phép thực thi async các hàm JavaScript với Asyncify. Trên thực tế, trường hợp sử dụng này phổ biến đến mức hiện có một macro EM_ASYNC_JS chuyên biệt kết hợp các trường hợp này với nhau.

Dưới đây là cách bạn có thể sử dụng công cụ này để tạo phiên bản hoàn chỉnh của ví dụ về 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

Bạn nên dùng EM_JS để khai báo các đoạn mã JavaScript. Đây là một cách hiệu quả vì liên kết trực tiếp các đoạn mã đã khai báo như mọi hàm JavaScript khác để nhập. API này cũng mang lại hiệu quả tốt khi cho phép bạn khai báo rõ ràng tất cả các loại và tên tham số.

Tuy nhiên, trong một số trường hợp, bạn sẽ muốn chèn một đoạn mã nhanh cho lệnh gọi console.log, câu lệnh debugger; hoặc nội dung tương tự và không muốn bận tâm đến việc khai báo một hàm hoàn toàn riêng biệt. Trong những trường hợp hiếm hoi đó, EM_ASM macros family (EM_ASM, EM_ASM_INTEM_ASM_DOUBLE) có thể là lựa chọn đơn giản hơn. Các macro đó tương tự như macro EM_JS, nhưng chúng thực thi mã cùng dòng khi được chèn vào, thay vì xác định một hàm.

Vì không khai báo nguyên mẫu hàm nên chúng cần một cách khác để chỉ định loại dữ liệu trả về và truy cập vào các đối số.

Bạn cần sử dụng đúng tên macro để chọn loại dữ liệu trả về. Khối EM_ASM được dự kiến sẽ hoạt động như các hàm void, khối EM_ASM_INT có thể trả về một giá trị số nguyên và khối EM_ASM_DOUBLE trả về số dấu phẩy động tương ứng.

Mọi đối số đã truyền sẽ có sẵn dưới tên $0, $1, v.v. trong nội dung JavaScript. Giống như EM_JS hay WebAssembly nói chung, đối số chỉ giới hạn ở các giá trị số – số nguyên, số dấu phẩy động, con trỏ và tay cầm.

Dưới đây là ví dụ về cách bạn có thể sử dụng macro EM_ASM để ghi một giá trị JS tuỳ ý vào bảng điều khiển:

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

Cuối cùng, Emscripten hỗ trợ việc khai báo mã JavaScript trong một tệp riêng ở định dạng thư viện tuỳ chỉnh:

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

Sau đó, bạn cần khai báo các nguyên mẫu tương ứng theo cách thủ công ở phía C++:

extern "C" void log_value(EM_VAL val_handle);

Sau khi khai báo ở cả hai bên, bạn có thể liên kết thư viện JavaScript với mã chính thông qua --js-library option, kết nối các nguyên mẫu có phương thức triển khai JavaScript tương ứng.

Tuy nhiên, định dạng mô-đun này không theo tiêu chuẩn và yêu cầu chú thích phần phụ thuộc cẩn thận. Do đó, mã này chủ yếu được dành riêng cho các trường hợp nâng cao.

Kết luận

Trong bài đăng này, chúng ta đã xem xét nhiều cách để tích hợp mã JavaScript vào C++ khi làm việc với WebAssembly.

Việc thêm các đoạn mã như vậy cho phép bạn biểu thị những chuỗi thao tác dài theo cách rõ ràng và hiệu quả hơn, đồng thời khai thác thư viện của bên thứ ba, API JavaScript mới và thậm chí cả các tính năng cú pháp JavaScript chưa thể hiện được qua C++ hoặc Embind.