第21.3章:WebAssembly:PWA 中的 C++ 核心

English

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(); }

这个小例子里有三条规则:

如果模块用随机数或时间戳,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. 体积、速度和调试

7. 参考实现

WasmPass 是一个能用的 PWA,用编译成 WebAssembly 的 fast_io 生成密码、用户名和 PIN。它用的就是这里描述的结构:导出函数、 指针/长度 getter、一个 wasi_snapshot_preview1 import 对象,以及一个缓存 .wasm 二进制的 Service Worker。

你不需要了解 WasmPass 的内部实现才能用这一章。它是这套构建确实能工作的证明,不是主题。 想要更小的起点,用 pwa-wasm-fast_io-demo。