您如何将 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,您可以获得一个虚拟化的 Linux 系统,该系统已经设置为与 Emscripten 一起工作,并安装了所有工具和依赖项。如果缺少某些东西,您可以直接安装它,而无需担心它如何影响您自己的机器或其他项目。如果出现问题,只需丢弃容器并重新开始即可。如果它工作一次,您可以确信它将继续工作并产生相同的结果。
Docker Registry 上有 trzeci 的 Emscripten 镜像,我一直在广泛使用。
与 npm 集成
在大多数情况下,Web 项目的入口点是 npm 的 package.json
。按照惯例,大多数项目都可以使用 npm install && npm run build
构建。
通常,Emscripten 生成的构建产物(.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 置于“快速失败”模式。如果脚本中的任何命令返回错误,则整个脚本会立即中止。这非常有帮助,因为脚本的最后一个输出将始终是成功消息或导致构建失败的错误。
通过 export
语句,您可以定义几个环境变量的值。它们允许您将其他命令行参数传递给 C 编译器 (CFLAGS
)、C++ 编译器 (CXXFLAGS
) 和链接器 (LDFLAGS
)。它们都通过 OPTIMIZE
接收优化器设置,以确保所有内容都以相同的方式进行优化。OPTIMIZE
变量有几个可能的值:
-O0
:不进行任何优化。不消除任何死代码,Emscripten 也不会缩小它发出的 JavaScript 代码。适合调试。-O3
:为性能积极优化。-Os
:为性能积极优化,并将大小作为次要标准。-Oz
:为大小积极优化,并在必要时牺牲性能。
对于 Web,我主要推荐 -Os
。
emcc
命令本身有很多选项。请注意,emcc 应该“作为 GCC 或 clang 等编译器的直接替代品”。因此,您可能从 GCC 知道的所有标志很可能也由 emcc 实现。-s
标志很特殊,因为它允许我们专门配置 Emscripten。所有可用选项都可以在 Emscripten 的 settings.js
中找到,但该文件可能非常庞大。以下是我认为对于 Web 开发者最重要的 Emscripten 标志列表:
--bind
启用 embind。-s STRICT=1
放弃对所有已弃用的构建选项的支持。这确保您的代码以向前兼容的方式构建。-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 的调试部分阅读更多关于使用 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 应该会在 DevTools 控制台中显示以下输出:

添加 C/C++ 代码作为依赖项
如果您想为您的 Web 应用程序构建 C/C++ 库,您需要其代码成为您项目的一部分。您可以手动将代码添加到您的项目存储库中,也可以使用 npm 来管理这些类型的依赖项。假设我想在我的 Web 应用程序中使用 libvpx。libvpx 是一个 C++ 库,用于使用 VP8(.webm
文件中使用的编解码器)对图像进行编码。但是,libvpx 不在 npm 上,也没有 package.json
,所以我无法直接使用 npm 安装它。
为了摆脱这种困境,有 napa。napa 允许您将任何 git 存储库 URL 作为依赖项安装到您的 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 使用 configure
和 make
进行构建。幸运的是,Emscripten 可以帮助确保 configure
和 make
使用 Emscripten 的编译器。为此,有包装器命令 emconfigure
和 emmake
。
# ... 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
文件,并且演示页面确实会输出常量:

您还会注意到构建过程需要很长时间。构建时间长的原因可能有很多。在 libvpx 的情况下,它需要很长时间,因为它每次运行构建命令时都会为 VP8 和 VP9 编译编码器和解码器,即使源文件没有更改。即使对您的 my-module.cpp
进行少量更改,也需要很长时间才能构建。一旦 libvpx 的构建产物第一次构建完成,就将其保留下来将非常有益。
实现此目的的一种方法是使用环境变量。
# ... 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
命令允许我们通过将参数传递给构建脚本来设置环境变量。test
命令将在 $SKIP_LIBVPX
设置(为任何值)时跳过构建 libvpx。
现在您可以编译您的模块,但跳过重新构建 libvpx:
$ npm run build:emscripten -- SKIP_LIBVPX=1
自定义构建环境
有时库依赖于其他工具进行构建。如果 Docker 镜像提供的构建环境中缺少这些依赖项,则需要自行添加它们。例如,假设您还想使用 doxygen 构建 libvpx 的文档。Doxygen 在您的 Docker 容器内不可用,但您可以使用 apt
安装它。
如果您在 build.sh
中执行此操作,则每次要构建库时,您都会重新下载并重新安装 doxygen。这不仅会造成浪费,还会阻止您在离线时处理您的项目。
在这里,构建您自己的 Docker 镜像是有意义的。Docker 镜像通过编写描述构建步骤的 Dockerfile
来构建。Dockerfiles 非常强大,并且有许多命令,但大多数时候您只需使用 FROM
、RUN
和 ADD
即可。在这种情况下:
FROM trzeci/emscripten
RUN apt-get update && \
apt-get install -qqy doxygen
使用 FROM
,您可以声明要用作起点的 Docker 镜像。我选择了 trzeci/emscripten
作为基础 — 您一直使用的镜像。使用 RUN
,您指示 Docker 在容器内运行 shell 命令。这些命令对容器所做的任何更改现在都是 Docker 镜像的一部分。为了确保您的 Docker 镜像已构建并在您运行 build.sh
之前可用,您必须稍微调整您的 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 逐步执行 Dockerfiles,并将每个步骤的结果分配给它自己的镜像。这些中间镜像通常称为“层”。如果 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。