Emscripten's embind

它会将 JS 绑定到您的 wasm!

在我上一篇 wasm 文章中,我介绍了如何将 C 库编译为 wasm,以便在网页上使用。我(以及许多读者)注意到的一点是,您必须手动声明正在使用的 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() 函数,其余部分则是“魔法”。我现在可以在无需任何样板代码的情况下从 JavaScript 调用 processMessage() 函数:

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

为了完整起见,我还应该向您展示 embind 如何借助 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

总结

embind 可大幅提升使用 wasm 和 C/C++ 的开发者体验。本文并未涵盖 embind 提供的所有选项。如果您感兴趣,建议您继续阅读 embind 的文档。 请注意,使用 embind 进行 Gzip 压缩时,Wasm 模块和 JavaScript 粘合代码可能会增加高达 11k(尤其是在小模块上)。如果您的 Wasm 表面非常小,则 embind 的使用费用可能会超出生产环境中的价格!尽管如此,您绝对应该试一试。