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 để 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ó cách để gọi các API bên ngoài như API web và thư viện 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ộ, bạn cũng có thể cần phải chờ các lời hứa trong mã C/C++ đồng bộ bằng Asyncify và đọc kết quả sau khi thao tác hoàn tất.

Emscripten cung cấp một số công cụ cho các 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 các đoạn mã đó dưới dạng hàm C/C++.
  • EM_ASYNC_JS tương tự như EM_JS, nhưng giúp bạn dễ dàng nhúng các đoạn mã JavaScript không đồng bộ.
  • EM_ASM để nhúng các đoạn mã ngắn và thực thi các đoạn mã đó cùng 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 duy nhất.

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

Lớp emscripten::val

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

Sau đây là cách sử dụng phương thức này với .await() của Asyncify để tìm nạp và phân tích cú pháp một số 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 tháo dỡ 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 thao tác JavaScript. Điều gì sẽ xảy ra nếu bạn có thể di chuyển fetch_json sang JavaScript và đồng thời giảm mức hao tổn của 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 một hàm C/C++ được triển khai bằng một đoạn mã JavaScript.

Giống như chính WebAssembly, WebAssembly.js cũng có giới hạn chỉ hỗ trợ các đối số và giá trị trả về dạng số. Để 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 truyền số không cần chuyển đổi:

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

int x = add_one(41);

Khi truyền 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ị tuỳ ý, phức tạp hơn, bạn có thể sử dụng API JavaScript cho lớp val đã đề cập trước đó. Khi sử dụng, bạn có thể chuyển đổi các giá trị JavaScript và lớp C++ thành các tay đ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ụ fetch_json để thực hiện hầu hết công 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 ở điểm truy cập và điểm 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ụ JavaScript có thể 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 thao tác không đồng bộ.

Macro EM_ASYNC_JS

Phần duy nhất còn lại không đẹp mắt 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 bằng 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.

Sau đây là cách bạn có thể sử dụng lớp này để tạo phiên bản cuối cùng của ví dụ 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. Phương thức này hiệu quả vì liên kết trực tiếp các đoạn mã đã khai báo giống như mọi lệnh nhập hàm JavaScript khác. Phương thức này cũng mang lại hiệu quả cao về mặt công thái học bằng cách 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 một nội dung tương tự và không muốn phải khai báo một hàm riêng biệt. Trong những trường hợp hiếm gặ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 thực thi mã cùng dòng tại vị trí 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 các hàm này cần một cách khác để chỉ định loại dữ liệu trả về và truy cập vào đối số.

Bạn cần sử dụng đúng tên macro để chọn loại dữ liệu trả về. Các khối EM_ASM dự kiến sẽ hoạt động như các hàm void, các 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 phần nội dung JavaScript. Cũng như EM_JS hoặc WebAssembly nói chung, các đối số chỉ được giới hạn ở các giá trị số – số nguyên, số dấu phẩy động, con trỏ và tay cầm.

Sau đây là ví dụ về cách bạn có thể 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ợ 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 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 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.