如何将 WebAssembly 集成到此设置中?在本文中,我们将以 C/C++ 和 Emscripten 为例。
WebAssembly (wasm) 通常被框架视为性能基元或在网页上运行现有 C++ 代码库的一种方式。借助 squoosh.app,我们希望证明 wasm 至少有第三个视角:利用其他编程语言的庞大生态系统。借助 Emscripten,您可以使用 C/C++ 代码,内置 Rust wasm 支持,并且 Go 团队也在努力解决该问题。我相信以后还会支持许多其他语言。
在这些情况下, wasm 不是应用的核心,而是一个拼图组件:又是一个模块。您的应用已经拥有 JavaScript、CSS、图像资源、以 Web 为中心的构建系统,甚至可能像 React 这样的框架。如何将 WebAssembly 集成到此设置中?在本文中,我们将以 C/C++ 和 Emscripten 为例。
Docker
我发现 Docker 在您使用 Emscripten 时非常有用。C/C++ 库通常是为了与构建它们的操作系统配合使用而编写的。保持稳定的环境非常有用。借助 Docker,您可以获得一个虚拟化 Linux 系统,该系统已经设置为使用 Emscripten,并且安装了所有工具和依赖项。如果缺少了某项内容,您只需自行安装,无需担心这会对您自己的机器或其他项目产生怎样的影响。如果出现问题,请丢弃容器并重新开始。如果它运行一次,就可确定它将继续运行并产生相同的结果。
Docker Registry 包含我广泛使用的 trzeci 提供的 Emscripten 映像。
与 npm 集成
在大多数情况下,Web 项目的入口点是 npm 的 package.json
。按照惯例,大多数项目都可以使用 npm install &&
npm run build
进行构建。
一般来说,Escripten 生成的 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 置于“快速失败”模式。如果脚本中的任何命令返回错误,整个脚本会立即取消。这种做法非常有用,因为该脚本的最后一个输出始终是成功消息或导致构建失败的错误。
使用 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
允许在必要时自动增加内存。在编写代码时,Escripten 最初会分配 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++ 代码使用文件系统操作时,它可以为您模拟文件系统。它会对其编译的代码进行一些分析,以决定是否在粘合代码中包含文件系统模拟。然而,有时分析结果会出错,您需要为文件系统模拟支付高昂的 70 kB 的额外粘合代码,而您可能并不需要这些代码。您可以使用-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/C++ 代码作为依赖项
如果您想为 Web 应用构建 C/C++ 库,则需要将其代码添加到项目中。您可以手动将代码添加到项目的代码库中,也可以使用 npm 管理这些类型的依赖项。假设我想在我的 WebView 中使用 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
时,NAP 负责将 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 的 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
命令允许我们通过向构建脚本传递参数来设置环境变量。如果设置了 $SKIP_LIBVPX
(设为任何值),test
命令将跳过构建 libvpx。
现在您可以编译模块,但跳过重新构建 libvpx:
$ npm run build:emscripten -- SKIP_LIBVPX=1
自定义构建环境
有时,库依赖于其他工具进行构建。如果 Docker 映像提供的构建环境中缺少这些依赖项,您需要自行添加。例如,假设您还想使用 doxygen 构建 libvpx 的文档。Doxygen 在 Docker 容器内不可用,但您可以使用 apt
安装它。
如果要在 build.sh
中执行此操作,则每次想要构建库时,您都需要重新下载并重新安装 doxygen。这样做不仅会造成浪费,而且还会阻碍您在离线状态下处理项目。
这时,您需要构建自己的 Docker 映像。Docker 映像通过编写描述构建步骤的 Dockerfile
进行构建。Dockerfile 非常强大,包含很多命令,但在大多数情况下,只需使用 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 会逐步执行 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。