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.

Tiếng Ingvar Stepanyan
Tiếng Ingvar Stepanyan

Khi tích hợp WebAssembly với web, bạn cần một cách để gọi các API bên ngoài như API web và thư viện của bên thứ ba. Sau đó, bạn cần một 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ư cách chuyể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ộ, bạn cũng có thể phải chờ các lời hứa trong mã C/C++ đồng bộ của mình bằng tuỳ chọn Asyncify và đọc kết quả sau khi thao tác kết thúc.

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 các đoạn mã JavaScript và liên kết chúng dưới dạng các hàm C/C++.
  • EM_ASYNC_JS tương tự như EM_JS, nhưng giúp bạn 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ác đoạn mã đó nội dòng mà không cần khai báo hàm.
  • --js-library cho các trường hợp nâng cao mà bạn muốn khai báo nhiều hàm JavaScript cùng nhau 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 nhiệm vụ tương tự nhau.

emscripten::val lớp

Lớp emcripten::val do Embind cung cấp. Thư viện này có thể gọi các API toàn cục, liên kết 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 chính sách 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 số định dạng trung gian.
  2. Chuyển tới JavaScript, đọc và chuyển đổi các đố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ả được chuyển đổi sang C++ và C++ cuối cùng sẽ đọc kết quả trở lại.

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ã nà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 hàng loạt thao tác JavaScript. Điều gì sẽ xảy ra nếu bạn có thể chuyển fetch_json sang JavaScript và giảm mức hao tổn đồng thời cho các bước trung gian?

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 chỉ hỗ trợ các đối số số và giá trị trả về. Để truyền bất kỳ giá trị nào khác, bạn cần chuyển đổi các giá trị đó theo cách thủ công qua API tương ứng. Dưới đây là một số ví dụ.

Số chuyển 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 chuỗi đến và từ JavaScript, bạn cần sử dụng các hàm phân bổ và chuyển đổi 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, bạn 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 các trình xử lý 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 các API đó, bạn có thể viết lại ví dụ về fetch_json để làm được nhiều việc nhất 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 chuyển đổi rõ ràng tại điểm vào và thoát của hàm, 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, công cụ này có thể tối ưu hoá bằng công cụ JavaScript 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

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

Bạn có thể sử dụng phương thức này để tạo phiên bản cuối cùng 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. Cách này hiệu quả vì liên kết trực tiếp các đoạn mã đã khai báo như mọi quá trình nhập hàm JavaScript khác. Thành phần này cũng mang lại hiệu quả công thái học 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 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 một số ít trường hợp, 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ã nội tuyến tại nơi chúng được chèn, 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à đối số truy cập.

Bạn cần sử dụng đúng tên macro để chọn loại trả về. Các khối EM_ASM 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à các 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 hoặc WebAssembly nói chung, các đối số chỉ giới hạn ở giá trị số – số nguyên, số dấu phẩy động, con trỏ và tên người dùng.

Dưới đây là ví dụ về cách sử dụng macro EM_ASM để ghi lại 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ợ 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 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 với cá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 chuẩn và yêu cầu chú thích phần phụ thuộc cẩn thận. Do đó, phương pháp 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.

Khi có các đoạn mã như vậy, bạn có thể thể hiện các trình tự dài của các thao tác một cách rõ ràng và hiệu quả hơn, đồng thời có thể sử dụng 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ể biểu thị được thông qua C++ hoặc Embind.