第21.3章:WebAssembly:PWA 中的 C++ 核心
1. 为什么用 WebAssembly
21.1 的架构是"可移植核心 + 平台特定 UI"。 在 PWA 里,UI 是 HTML 和 CSS,核心是 WebAssembly。这样同一份 C++ 代码既能跑在原生平台, 也能跑在 Web 上,而 UI 栈只有一套,浏览器能跑的地方它就能跑。
WebAssembly 不是原生代码的替代品。它是"浏览器就是平台"这种情况下的可移植目标。浏览器本身 已经是沙盒,所以应用不需要商店、原生 SDK 或平台特定的安装器。代价是线性内存模型、JavaScript 边界, 以及更小的系统接口。
2. 工具链
使用较新的 clang 和 WASI 系统根。关键参数是:
clang++ --config=$HOME/cfgs/wasm32-wasip1-noeh-nomtg.cfg \
-fherbceptions -lherbceptions \
-nostartfiles -Wl,--no-entry \
-Wl,--export=__wasm_call_ctors \
-Wl,--export=__wasm_call_dtors \
-Wl,--export=app_run \
-Wl,--export=app_ptr \
-Wl,--export=app_len \
app.cpp -o app.wasm
这个 config 面向 wasm32-wasip1,使用 libc++ 和一个没有遗留异常机制的系统根,
并传入 -fno-exceptions。最后这个参数并不矛盾:
-fno-exceptions 关闭遗留 unwinder,而 -fherbceptions 启用赫布异常语言特性,
-lherbceptions 链接它的运行时。WASI 系统根为 wasm32-wasip1 提供了
libherbceptions.a。再加上 -fno-rtti;核心不需要它,而且能让二进制更小。
-nostartfiles 和 --no-entry 让模块成为一个库,而不是一个 WASI 命令。
没有 _start;JavaScript 调用导出的函数。实例化后必须调用
__wasm_call_ctors,让静态构造函数运行;如果希望在卸载时运行析构函数,就调用
__wasm_call_dtors。
3. 一个最小 C++ 模块
核心不向控制台打印。它格式化到一个缓冲区,JavaScript 读取这个缓冲区。这就是 20.2 里"设备是一个类型"的设计, 只不过这里的设备是浏览器:
#include <cstddef>
#include <herbceptions/error>
#include <fast_io.h>
#include <fast_io_dsal/string.h>
namespace
{
::fast_io::u8string text;
void render(int value) throws
{
if (value < 0)
{
throw throws ::std::errc::invalid_argument;
}
text.clear();
::fast_io::u8ostring_ref_fast_io out{__builtin_addressof(text)};
::fast_io::io::print(out, u8"value = ", value, u8"\n");
}
} // namespace
extern "C" int app_run(int value) noexcept
{
try
{
render(value);
return 0;
}
catch throws(::std::error e)
{
return static_cast<int>(e.to_errc());
}
}
extern "C" const char8_t *app_ptr() noexcept { return text.c_str(); }
extern "C" ::std::size_t app_len() noexcept { return text.size(); }
这个小例子里有三条规则:
-
异常不跨越边界。导出函数是
noexcept,捕获赫布异常并返回错误码。 JavaScript 没有 C++ unwinder,也不应该看到它。 -
边界是字节和整数。导出指针和长度;JS 侧按 UTF-8 解码。不要传
std::string、std::vector,或者生命周期不清晰的对象指针。 -
格式化由 fast_io 做。核心不用
std::cout、printf或FILE*;它写进u8string,那是模块自己拥有的内存。
如果模块用随机数或时间戳,fast_io 会调用 random_get 和
clock_time_get 这样的 WASI import。你从 JavaScript 提供它们。
4. JavaScript 胶水
实例化模块,并提供它 import 的 WASI 函数:
let wasm;
const imports = {
wasi_snapshot_preview1: {
proc_exit(code) {
throw new Error(`wasm exited with code ${code}`);
},
random_get(ptr, len) {
crypto.getRandomValues(
new Uint8Array(wasm.exports.memory.buffer, ptr, len)
);
return 0;
},
clock_time_get(id, precision, out) {
const ns = BigInt(Date.now()) * 1_000_000n;
new DataView(wasm.exports.memory.buffer)
.setBigUint64(out, ns, true);
return 0;
}
}
};
const response = await fetch("/app.wasm");
const { instance } = await WebAssembly.instantiateStreaming(response, imports);
wasm = instance;
instance.exports.__wasm_call_ctors();
const status = instance.exports.app_run(42);
const ptr = instance.exports.app_ptr();
const len = instance.exports.app_len();
const text = new TextDecoder().decode(
new Uint8Array(instance.exports.memory.buffer, ptr, len)
);
console.log(status, text);
instantiateStreaming 要求服务器发送
Content-Type: application/wasm。如果服务器不发送,就回退到:
const bytes = await (await fetch("/app.wasm")).arrayBuffer();
const { instance } = await WebAssembly.instantiate(bytes, imports);
内存是线性的,而且会增长。不要在可能分配内存的调用之间保留 Uint8Array;
每次可能让它增长的调用之后,都从 memory.buffer 重新创建视图。字符串也一样:
指针在下一次导致字符串重新分配的调用之前有效,所以要在回调模块之前把字节读走。
5. 浏览器里的 fast_io
C++ 侧看起来就是普通的 fast_io 代码。print 写进 u8string;
格式化引擎不知道、也不关心目标是 WebAssembly 线性内存缓冲区而不是文件。这就是 concept
设计的要点:设备是一个类型,同一份格式化代码为它实例化。
同一个核心可以原生编译并链接平台 UI,也可以编译成 WebAssembly 并链接 Web UI。DRM、编解码器、 密码学和网络代码不需要第二份实现。这就是 21.1 的架构落到实处。
6. 体积、速度和调试
-
用
-O2或-Os编译,开启 LTO,使用--gc-sections, 再对结果跑wasm-opt -Oz。 -
保留
-fno-rtti,避开<iostream>、<locale>和其他只属于宿主环境的重量级内容。WasmPass 参考二进制约 26 KB,对于一个专注的模块来说, 这是现实的目标。 -
调试时用
-g构建。先用wasm-objdump -x检查 import 和 export 表; 大多数"跑不起来"的情况都是缺 import、缺 export,或者构造函数没被调用。 - 在浏览器 DevTools 里测试,不要只在 Node 里测。WebAssembly 内存、JS 边界和 Service Worker 只存在于浏览器里。
7. 参考实现
WasmPass 是一个能用的 PWA,用编译成
WebAssembly 的 fast_io 生成密码、用户名和 PIN。它用的就是这里描述的结构:导出函数、
指针/长度 getter、一个 wasi_snapshot_preview1 import 对象,以及一个缓存
.wasm 二进制的 Service Worker。
你不需要了解 WasmPass 的内部实现才能用这一章。它是这套构建确实能工作的证明,不是主题。 想要更小的起点,用 pwa-wasm-fast_io-demo。