Emscripten 的魔法

它會將 JS 繫結至您的 wasm!

在先前的這篇文章中,我說明瞭如何編譯 C 程式庫,方便您在網路上使用。我 (以及許多讀者) 自己感到並不意外,就是您必須手動宣告要使用哪些 wasm 模組的功能。為協助您調整設定,以下是我提及的程式碼片段:

const api = {
    version: Module.cwrap('version', 'number', []),
    create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
    destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
};

在此宣告為 EMSCRIPTEN_KEEPALIVE 標記的函式名稱、這些函式的傳回類型,以及這些函式的引數類型。之後,我們可以使用 api 物件的方法叫用這些函式。不過,透過這種方式使用 wasm 不支援字串,且需要您手動移動記憶體區塊,導致許多程式庫 API 變得相當繁瑣。有更好的方式嗎?為什麼會在這裡? 不然,這篇文章要介紹什麼?

C++ 名稱管理

雖然從開發人員體驗就能建構有助於這些繫結的工具,但實際上有更棘手的原因:當您編譯 C 或 C++ 程式碼時,每個檔案都會分別編譯。接著,連結器會負責將所有這些稱為「物件檔案」的通訊,並將其轉換成 wasm 檔案。使用 C 時,您仍然可以在物件檔案中找到函式名稱,以供連結器使用。您只需要能呼叫 C 函式名稱,我們即可以字串的形式提供 cwrap()

另一方面,C++ 支援函式超載,也就是說,只要簽名不同 (例如類型不同的參數),您就可以多次實作相同的函式。在編譯器層級,類似 add 的好名稱會經過遮蓋,成為在連接器的函式名稱中編碼簽名的內容。因此,我們無法再使用名稱來查詢函式。

進入組合

embind 是 Emscripten 工具鍊的一部分,可提供多項 C++ 巨集,方便您為 C++ 程式碼加上註解。您可以宣告打算從 JavaScript 使用的函式、列舉、類別或值類型。我們先以簡單的函式開始吧:

#include <emscripten/bind.h>

using namespace emscripten;

double add(double a, double b) {
    return a + b;
}

std::string exclaim(std::string message) {
    return message + "!";
}

EMSCRIPTEN_BINDINGS(my_module) {
    function("add", &add);
    function("exclaim", &exclaim);
}

與前一篇文章相比,我們已不再納入 emscripten.h,因為不再需要使用 EMSCRIPTEN_KEEPALIVE 註解函式。相反地,我們有一個 EMSCRIPTEN_BINDINGS 區段,會在其中列出要將函式提供給 JavaScript 的名稱。

如要編譯此檔案,可以使用與前文相同的設定 (如有需要,您也可以使用相同的 Docker 映像檔)。如要使用組合鍵,我們新增了 --bind 標記:

$ emcc --bind -O3 add.cpp

現在,我們要建立 HTML 檔案,載入剛剛建立的 wasm 模組:

<script src="/a.out.js"></script>
<script>
Module.onRuntimeInitialized = _ => {
    console.log(Module.add(1, 2.3));
    console.log(Module.exclaim("hello world"));
};
</script>

如您所見,我們已不再使用 cwrap()。這項做法一開箱就能使用。但更重要的是,我們不必擔心手動複製記憶體區塊,以便讓字串正常運作!embind 可讓您免費使用這些程式碼,而且還支援類型檢查:

叫用含有錯誤引數數量的函式,或引數類型錯誤時,開發人員工具就會發生錯誤

這非常實用,因為我們可以及早擷取一些錯誤,而不必處理偶爾難以察覺的錯誤。

物件

許多 JavaScript 建構函式和函式會使用選項物件。在 JavaScript 中 這是個很不錯的模式,但要靠手動處理極度繁瑣

舉例來說,我發想了這個實用的 C++ 函式來處理我的字串,我急著想在網路上使用這個函式。我的做法如下:

#include <emscripten/bind.h>
#include <algorithm>

using namespace emscripten;

struct ProcessMessageOpts {
    bool reverse;
    bool exclaim;
    int repeat;
};

std::string processMessage(std::string message, ProcessMessageOpts opts) {
    std::string copy = std::string(message);
    if(opts.reverse) {
    std::reverse(copy.begin(), copy.end());
    }
    if(opts.exclaim) {
    copy += "!";
    }
    std::string acc = std::string("");
    for(int i = 0; i < opts.repeat; i++) {
    acc += copy;
    }
    return acc;
}

EMSCRIPTEN_BINDINGS(my_module) {
    value_object<ProcessMessageOpts>("ProcessMessageOpts")
    .field("reverse", &ProcessMessageOpts::reverse)
    .field("exclaim", &ProcessMessageOpts::exclaim)
    .field("repeat", &ProcessMessageOpts::repeat);

    function("processMessage", &processMessage);
}

我正在定義 processMessage() 函式的選項結構。在 EMSCRIPTEN_BINDINGS 區塊中,我可以使用 value_object 讓 JavaScript 將此 C++ 值視為物件。如果我偏好使用這個 C++ 值做為陣列,也可以使用 value_array。我也繫結了 processMessage() 函式,其餘部分則組合成「魔法」。現在可以從 JavaScript 呼叫 processMessage() 函式,無需任何樣板程式碼:

console.log(Module.processMessage(
    "hello world",
    {
    reverse: false,
    exclaim: true,
    repeat: 3
    }
)); // Prints "hello world!hello world!hello world!"

類別

為了達成完整剖析,我也應該向您展示如何透過組合方式,公開整個類別,進而帶來許多與 ES6 類別的協同效應。您現在可能已經開始看到模式:

#include <emscripten/bind.h>
#include <algorithm>

using namespace emscripten;

class Counter {
public:
    int counter;

    Counter(int init) :
    counter(init) {
    }

    void increase() {
    counter++;
    }

    int squareCounter() {
    return counter * counter;
    }
};

EMSCRIPTEN_BINDINGS(my_module) {
    class_<Counter>("Counter")
    .constructor<int>()
    .function("increase", &Counter::increase)
    .function("squareCounter", &Counter::squareCounter)
    .property("counter", &Counter::counter);
}

在 JavaScript 端,這幾乎就像原生類別一樣:

<script src="/a.out.js"></script>
<script>
Module.onRuntimeInitialized = _ => {
    const c = new Module.Counter(22);
    console.log(c.counter); // prints 22
    c.increase();
    console.log(c.counter); // prints 23
    console.log(c.squareCounter()); // prints 529
};
</script>

那 C 呢?

embin 專為 C++ 編寫,且只適用於 C++ 檔案中,但這並不代表您無法連結至 C 檔案!如要混合 C 和 C++,您只需要將輸入檔案分為兩個群組:一個用於 C 版本,另一個用於 C++ 檔案,然後為 emcc 新增 CLI 旗標,如下所示:

$ emcc --bind -O3 --std=c++11 a_c_file.c another_c_file.c -x c++ your_cpp_file.cpp

結語

透過 embin,可以大幅提升使用 wasm 和 C/C++ 的開發人員體驗。本文並未涵蓋所有繫結優惠選項。如果您有興趣,建議您繼續參閱embind 說明文件。請注意,使用 embin 時, wasm 模組和 JavaScript 膠水程式碼在 gzip 情況下可放大多達 11k,特別是在小型模組上。如果您只有極小的 wasm 途徑, emb 在實際工作環境中的成本可能會高於價值!不過,你一定要試試看