Emscripten's embind

它将 JS 绑定到您的 wasm!

在我的上一篇 wasm 文章中,我介绍了如何将 C 库编译为 wasm,以便在 Web 上使用它。让我(以及许多读者)印象深刻的一点是,您必须以粗略且略显笨拙的方式手动声明您正在使用的 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

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 映像,如果您愿意的话)。如需使用 embind,请添加 --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 会免费为您提供此功能,以及类型检查:

当您使用错误数量的实参或实参类型错误来调用函数时,开发者工具会显示错误

这非常棒,因为我们可以尽早捕获一些错误,而无需处理偶尔会非常棘手的 wasm 错误。

对象

许多 JavaScript 构造函数和函数都使用选项对象。这是 JavaScript 中的一种不错的模式,但要在 wasm 中手动实现却非常繁琐。embind 也能在这方面提供帮助!

例如,我编写了一个非常实用的 C++ 函数来处理字符串,并且迫切希望在 Web 上使用它。以下是我实现这一目标的方法:

#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() 函数,其余部分是 embind 魔法。现在,我可以从 JavaScript 调用 processMessage() 函数,而无需任何样板代码:

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

为了完整起见,我还应该向您展示 embind 如何允许您公开整个类,这与 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 呢?

embind 是为 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

总结

在处理 wasm 和 C/C++ 时,embind 可大幅提升开发者体验。本文未涵盖 embind 提供的所有选项。如果您感兴趣,建议您继续阅读 embind 的文档。 请注意,使用 embind 会使您的 wasm 模块和 JavaScript 粘合代码在 gzip 压缩后最多增加 11k,尤其是在小型模块上。如果您只有非常小的 wasm 表面,那么在生产环境中,embind 的成本可能高于其价值!不过,您绝对应该试一试。