Emscripten 和 npm

如何将 WebAssembly 集成到此设置中?在本文中,我们将以 C/C++ 和 Emscripten 为例来解决此问题。

WebAssembly (wasm) 通常被视为一种性能原语,或是在 Web 上运行现有 C++ 代码库的方式。通过 squoosh.app,我们想表明 wasm 至少还有第三种视角:利用其他编程语言的庞大生态系统。借助 Emscripten,您可以使用 C/C++ 代码,Rust 内置了 wasm 支持Go 团队也在努力实现此功能。我相信,很快就会有更多语言版本。

在这些场景中,wasm 不是应用的核心,而只是一个拼图块:另一个模块。您的应用已经包含 JavaScript、CSS、图片资源、以 Web 为中心的构建系统,甚至可能还包含 React 等框架。如何将 WebAssembly 集成到此设置中?在本文中,我们将以 C/C++ 和 Emscripten 为例来解决此问题。

Docker

在处理 Emscripten 时,我发现 Docker 非常有用。C/C++ 库通常是为在构建时所用的操作系统上运行而编写的。拥有一致的环境非常有帮助。借助 Docker,您可以获得一个已设置好可与 Emscripten 搭配使用的虚拟化 Linux 系统,该系统已安装所有工具和依赖项。如果缺少某些内容,您可以直接安装,而无需担心它会如何影响您自己的机器或其他项目。如果出现问题,请丢弃容器并重新开始。如果它能正常运行一次,您就可以确信它会继续正常运行并产生相同的结果。

Docker Registry 中有一个 trzeci 提供的 Emscripten 映像,我一直在广泛使用它。

与 npm 集成

在大多数情况下,Web 项目的入口点是 npm 的 package.json。按照惯例,大多数项目都可以使用 npm install && npm run build 构建。

一般来说,Emscripten 生成的 build 制品(一个 .js 文件和一个 .wasm 文件)应被视为另一个 JavaScript 模块和另一个资源。JavaScript 文件可由 webpack 或 rollup 等打包程序处理,wasm 文件应像任何其他较大的二进制素材资源(例如图片)一样处理。

因此,Emscripten 构建工件需要在“正常”构建流程开始之前构建:

{
    "name": "my-worldchanging-project",
    "scripts": {
    "build:emscripten": "docker run --rm -v $(pwd):/src trzeci/emscripten
./build.sh",
    "build:app": "<the old build command>",
    "build": "npm run build:emscripten && npm run build:app",
    // ...
    },
    // ...
}

新的 build:emscripten 任务可以直接调用 Emscripten,但如前所述,我建议使用 Docker 来确保构建环境一致。

docker run ... trzeci/emscripten ./build.sh 指示 Docker 使用 trzeci/emscripten 映像启动新容器并运行 ./build.sh 命令。build.sh 是您接下来要编写的 shell 脚本!--rm 会告知 Docker 在容器运行完毕后将其删除。这样,您就不会随着时间的推移而积累过时的机器映像。-v $(pwd):/src 表示您希望 Docker 将当前目录 ($(pwd))“镜像”到容器内的 /src。您对容器内 /src 目录中的文件所做的任何更改都会镜像到实际项目中。这些镜像目录称为“绑定挂载”。

我们来看看 build.sh

#!/bin/bash

set -e

export OPTIMIZE="-Os"
export LDFLAGS="${OPTIMIZE}"
export CFLAGS="${OPTIMIZE}"
export CXXFLAGS="${OPTIMIZE}"

echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
(
    # Compile C/C++ code
    emcc \
    ${OPTIMIZE} \
    --bind \
    -s STRICT=1 \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s MALLOC=emmalloc \
    -s MODULARIZE=1 \
    -s EXPORT_ES6=1 \
    -o ./my-module.js \
    src/my-module.cpp

    # Create output folder
    mkdir -p dist
    # Move artifacts
    mv my-module.{js,wasm} dist
)
echo "============================================="
echo "Compiling wasm bindings done"
echo "============================================="

这里有很多内容需要剖析!

set -e 会将 shell 置于“快速失败”模式。如果脚本中的任何命令返回错误,整个脚本会立即中止。这非常有用,因为脚本的最后输出始终是成功消息或导致 build 失败的错误。

通过 export 语句,您可以定义几个环境变量的值。它们允许您将其他命令行参数传递给 C 编译器 (CFLAGS)、C++ 编译器 (CXXFLAGS) 和链接器 (LDFLAGS)。它们都会通过 OPTIMIZE 接收优化器设置,以确保所有内容都以相同的方式进行优化。OPTIMIZE 变量有以下几个可能的值:

  • -O0:不进行任何优化。不会消除任何无用代码,并且 Emscripten 也不会缩小其发出的 JavaScript 代码。适合调试。
  • -O3:积极优化性能。
  • -Os:以性能为主要标准,以大小为次要标准进行积极优化。
  • -Oz:积极优化大小,必要时牺牲性能。

对于网站,我主要推荐 -Os

emcc 命令本身就有很多选项。请注意,emcc 应该“可以替代 GCC 或 clang 等编译器”。因此,您可能从 GCC 中了解到的所有标志很可能也会由 emcc 实现。-s 标志的特殊之处在于,它允许我们专门配置 Emscripten。所有可用选项都可以在 Emscripten 的 settings.js 中找到,但该文件可能非常庞大。以下是我认为对 Web 开发者最重要的 Emscripten 标志:

  • --bind 启用 embind
  • -s STRICT=1 放弃了对所有已弃用的 build 选项的支持。这可确保您的代码以向前兼容的方式构建。
  • -s ALLOW_MEMORY_GROWTH=1 允许在必要时自动增加内存。在撰写本文时,Emscripten 最初会分配 16MB 的内存。当您的代码分配内存块时,此选项会决定在内存耗尽时,这些操作是否会导致整个 wasm 模块失败,或者是否允许粘合代码扩展总内存以适应分配。
  • -s MALLOC=... 选择要使用的 malloc() 实现。emmalloc 是专门针对 Emscripten 的小型快速 malloc() 实现。另一种选择是 dlmalloc,这是一个成熟的 malloc() 实现。只有在您频繁分配大量小型对象或想要使用线程时,才需要切换到 dlmalloc
  • -s EXPORT_ES6=1 会将 JavaScript 代码转换为 ES6 模块,其中包含可与任何打包器搭配使用的默认导出。还需要设置 -s MODULARIZE=1

以下标志并非总是必需的,或者仅有助于进行调试:

  • -s FILESYSTEM=0 是一个与 Emscripten 相关的标志,当您的 C/C++ 代码使用文件系统操作时,它能够为您模拟文件系统。它会对编译的代码进行一些分析,以决定是否在粘合代码中包含文件系统模拟。不过,有时这种分析可能会出错,导致您为可能不需要的文件系统模拟支付相当高的额外粘合代码费用(70kB)。使用 -s FILESYSTEM=0 可以强制 Emscripten 不包含此代码。
  • -g4 会使 Emscripten 在 .wasm 中包含调试信息,并为 wasm 模块生成源映射文件。如需详细了解如何使用 Emscripten 进行调试,请参阅其调试部分

成功了!为了测试此设置,我们来创建一个小小的 my-module.cpp

    #include <emscripten/bind.h>

    using namespace emscripten;

    int say_hello() {
      printf("Hello from your wasm module\n");
      return 0;
    }

    EMSCRIPTEN_BINDINGS(my_module) {
      function("sayHello", &say_hello);
    }

以及 index.html

    <!doctype html>
    <title>Emscripten + npm example</title>
    Open the console to see the output from the wasm module.
    <script type="module">
    import wasmModule from "./my-module.js";

    const instance = wasmModule({
      onRuntimeInitialized() {
        instance.sayHello();
      }
    });
    </script>

(此处提供了一个包含所有文件的 gist。)

如需构建所有内容,请运行

$ npm install
$ npm run build
$ npm run serve

前往 localhost:8080 应该会在开发者工具控制台中显示以下输出:

开发者工具显示了通过 C++ 和 Emscripten 打印的消息。

添加 C/C++ 代码作为依赖项

如果您想为 Web 应用构建 C/C++ 库,则需要将该库的代码纳入您的项目。您可以手动将代码添加到项目的代码库中,也可以使用 npm 来管理此类依赖项。假设我想在我的 Web 应用中使用 libvpx。libvpx 是一个 C++ 库,用于使用 VP8(.webm 文件中使用的编解码器)对图片进行编码。不过,libvpx 不在 npm 上,也没有 package.json,因此我无法直接使用 npm 安装它。

为了摆脱这种困境,我们推出了 napa。借助 napa,您可以将任何 Git 代码库网址作为依赖项安装到 node_modules 文件夹中。

将 napa 安装为依赖项:

$ npm install --save napa

并确保将 napa 作为安装脚本运行:

{
// ...
"scripts": {
    "install": "napa",
    // ...
},
"napa": {
    "libvpx": "git+https://github.com/webmproject/libvpx"
}
// ...
}

当您运行 npm install 时,napa 会负责将 libvpx GitHub 代码库克隆到 node_modules 下,并命名为 libvpx

您现在可以扩展构建脚本来构建 libvpx。libvpx 使用 configuremake 进行构建。幸运的是,Emscripten 可以帮助确保 configuremake 使用 Emscripten 的编译器。为此,您可以使用封装容器命令 emconfigureemmake

# ... above is unchanged ...
echo "============================================="
echo "Compiling libvpx"
echo "============================================="
(
    rm -rf build-vpx || true
    mkdir build-vpx
    cd build-vpx
    emconfigure ../node_modules/libvpx/configure \
    --target=generic-gnu
    emmake make
)
echo "============================================="
echo "Compiling libvpx done"
echo "============================================="

echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
# ... below is unchanged ...

C/C++ 库分为两部分:标头(传统上为 .h.hpp 文件),用于定义库公开的数据结构、类、常量等;以及实际库(传统上为 .so.a 文件)。如需在代码中使用库的 VPX_CODEC_ABI_VERSION 常量,您必须使用 #include 语句添加库的头文件:

#include "vpxenc.h"
#include <emscripten/bind.h>

int say_hello() {
    printf("Hello from your wasm module with libvpx %d\n", VPX_CODEC_ABI_VERSION);
    return 0;
}

问题在于,编译器不知道从哪里查找 vpxenc.h-I 标志就是用于此目的。它会告知编译器要检查哪些目录中的头文件。此外,您还需要向编译器提供实际的库文件:

# ... above is unchanged ...
echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
(
    # Compile C/C++ code
    emcc \
    ${OPTIMIZE} \
    --bind \
    -s STRICT=1 \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s ASSERTIONS=0 \
    -s MALLOC=emmalloc \
    -s MODULARIZE=1 \
    -s EXPORT_ES6=1 \
    -o ./my-module.js \
    -I ./node_modules/libvpx \
    src/my-module.cpp \
    build-vpx/libvpx.a

# ... below is unchanged ...

如果您现在运行 npm run build,您会看到该进程构建了一个新的 .js 和一个新的 .wasm 文件,并且演示页面确实会输出常量:

显示通过 emscripten 输出的 libvpx 的 ABI 版本的开发者工具。

您还会注意到,构建过程需要很长时间。构建时间过长的原因可能有很多。对于 libvpx,由于每次运行 build 命令时,它都会为 VP8 和 VP9 编译编码器和解码器,即使源文件没有更改,也需要很长时间。即使对 my-module.cpp 进行小幅更改,也需要很长时间才能完成构建。在首次构建 libvpx 后,保留其 build 制品会非常有利。

一种实现方式是使用环境变量。

# ... above is unchanged ...
eval $@

echo "============================================="
echo "Compiling libvpx"
echo "============================================="
test -n "$SKIP_LIBVPX" || (
    rm -rf build-vpx || true
    mkdir build-vpx
    cd build-vpx
    emconfigure ../node_modules/libvpx/configure \
    --target=generic-gnu
    emmake make
)
echo "============================================="
echo "Compiling libvpx done"
echo "============================================="
# ... below is unchanged ...

(以下 gist 包含所有文件。)

借助 eval 命令,我们可以通过向 build 脚本传递参数来设置环境变量。如果设置了 $SKIP_LIBVPX(设置为任何值),test 命令将跳过构建 libvpx。

现在,您可以编译模块,但跳过重建 libvpx:

$ npm run build:emscripten -- SKIP_LIBVPX=1

自定义 build 环境

有时,库需要依赖其他工具才能构建。如果 Docker 映像提供的构建环境中缺少这些依赖项,您需要自行添加。举例来说,假设您还想使用 doxygen 构建 libvpx 的文档。Doxygen 在您的 Docker 容器中不可用,但您可以使用 apt 安装它。

如果您在 build.sh 中执行此操作,则每次想要构建库时,都需要重新下载并重新安装 doxygen。这不仅会造成浪费,还会导致您在离线时无法处理项目。

在这种情况下,构建自己的 Docker 映像是有意义的。通过编写描述构建步骤的 Dockerfile 来构建 Docker 映像。Dockerfile 非常强大,包含许多命令,但大多数情况下,您只需使用 FROMRUNADD 即可。在此示例中:

FROM trzeci/emscripten

RUN apt-get update && \
    apt-get install -qqy doxygen

借助 FROM,您可以声明要用作起点的 Docker 映像。我选择了 trzeci/emscripten 作为基础映像,也就是您一直使用的映像。借助 RUN,您可以指示 Docker 在容器内运行 shell 命令。这些命令对容器所做的任何更改现在都已成为 Docker 映像的一部分。为了确保在运行 build.sh 之前已构建 Docker 映像并可供使用,您需要稍微调整一下 package.json

{
    // ...
    "scripts": {
    "build:dockerimage": "docker image inspect -f '.' mydockerimage || docker build -t mydockerimage .",
    "build:emscripten": "docker run --rm -v $(pwd):/src mydockerimage ./build.sh",
    "build": "npm run build:dockerimage && npm run build:emscripten && npm run build:app",
    // ...
    },
    // ...
}

(以下 gist 包含所有文件。)

这将构建您的 Docker 映像,但前提是该映像尚未构建。然后,一切都像以前一样运行,但现在构建环境可以使用 doxygen 命令,这也会导致构建 libvpx 的文档。

总结

C/C++ 代码和 npm 并不自然契合,这一点并不令人意外,但您可以借助一些额外的工具和 Docker 提供的隔离功能,让它们能够顺畅地协同工作。此设置不适用于所有项目,但它是一个不错的起点,您可以根据需要进行调整。如果您有改进建议,请分享。

附录:利用 Docker 映像层

另一种解决方案是使用 Docker 和 Docker 的智能缓存方法来封装更多此类问题。Docker 会逐步执行 Dockerfile,并为每个步骤的结果分配自己的映像。这些中间图片通常称为“层”。如果 Dockerfile 中的命令未发生变化,那么在重新构建 Dockerfile 时,Docker 实际上不会重新运行相应步骤。而是会重复使用上次构建映像时的层。

以前,您必须费一番功夫才能避免每次构建应用时都重新构建 libvpx。现在,您可以将 libvpx 的构建说明从 build.sh 移到 Dockerfile,以利用 Docker 的缓存机制:

FROM trzeci/emscripten

RUN apt-get update && \
    apt-get install -qqy doxygen git && \
    mkdir -p /opt/libvpx/build && \
    git clone https://github.com/webmproject/libvpx /opt/libvpx/src
RUN cd /opt/libvpx/build && \
    emconfigure ../src/configure --target=generic-gnu && \
    emmake make

(以下 gist 包含所有文件。)

请注意,您需要手动安装 git 并克隆 libvpx,因为在运行 docker build 时没有绑定挂载。作为副作用,不再需要 napa。