一次编译,到处调用:WebAssembly 组件模型与 WASI 0.3

2017 年前后,媒体给 WebAssembly 写的标题基本都是"JavaScript 的终结者"。我当时信了一半:确实快,确实能在浏览器里跑 ffmpeg,但除了少数几个 demo,它始终没变成我日常会用的东西。

后来我大概明白了问题在哪:它解决的是"能跑",而我一直缺的是"能被叫"。

集装箱码头边停靠着一艘装满彩色集装箱的货轮,龙门吊立在远处

一个 C++ 写的库编译成 wasm,我要用它是这样调的:先 malloc 一块它的线性内存,把字符串 UTF-8 编码后写进去,再传过去一个指针和一个长度,然后拿到另一个指针,再从它的内存里把结果读出来。每一步都要手写胶水,每个库一套,而且你永远不敢确定自己有没有踩出内存越界。

这就是 WebAssembly 组件模型(Component Model) 要补的那一刀。它不是让 wasm 更快,而是给它加了一层"接口"。今年 6 月 WASI 0.3 正式通过,配套的 async 也下沉到了 ABI 层——折腾了这些年,这事儿终于有点样子了。这篇把我这段时间翻的东西整理一遍,也给准备上车的同学标一下哪几段铁轨还没修好。

# 一、第一波为什么没兑现

先把话说清楚:core WebAssembly 其实成功了,只是成功的方向和当年的口号不一样。

它没有取代 JS,而是变成了隐身在后面的那层:Figma 的渲染引擎、Google Earth、PDF.js 里的一些热点路径、各种在线设计工具的编解码器。这些场景的共同点是"一段很重的原生代码,需要在网页里跑起来"。

但作为"通用的软件分发格式",它卡在一个很难看的地方:core wasm 的函数签名里只有数字。

(func (param i32 i64 f32 f64) (result i32))

没有字符串,没有结构体,没有列表,没有枚举。想传一个字符串,唯一的办法是"约定:地址 0x1000 开始放字节,长度放在另一个 i32 里"。这套约定没有任何地方管得着,于是每个工具链都发明了自己的规则,Emscripten 一套、Rust 一套、Go 又是另一套。

我当时在做图片处理的 demo,为了在 JS 和 wasm 之间传一个字符串,代码大概长这样:

// 分配 → 编码 → 写入 → 调用 → 读回 → 释放,六个步骤只为传一句话
const ptr = wasm.exports.alloc(len)
const bytes = new TextEncoder().encode(input)
new Uint8Array(wasm.exports.memory.buffer, ptr, len).set(bytes)
const retPtr = wasm.exports.process(ptr, len)
// ...再从 retPtr 那里按约定的布局把结果取出来
wasm.exports.free(ptr)

这还算好的。真正要命的是下面三件事:

  1. 两个不同语言写的模块拼不起来。 Rust 编译的 wasm 和 C++ 编译的 wasm,就算函数签名数字对得上,两边的内存布局和调用约定也对不上,中间必须再写一层胶水。
  2. 系统调用是个大坑。 wasm 本身没有任何 IO 能力,最早的 WASI Preview 1 基本是把 POSIX 照搬了一遍(fd、path_open、clock_time_get)。能跑移植过来的 Unix 程序,但用起来非常别扭,也没有异步。
  3. 错误处理全靠约定。 返回 -1 代表失败?还是返回 0?错误信息去哪儿拿?

一句话概括:当年那个"一次编译到处运行"的承诺里,"运行"做到了,"到处"没做到。 每个模块都像一个只带了一封信的漂流瓶,只有把它捞起来的那个人知道该怎么读。

一双手正在深色桌面上拼装一辆彩色乐高小车

# 二、组件模型补的那一刀:给漂流瓶换集装箱

组件模型的思路很朴素:在 core module 外面再包一层"壳",这个壳里带着类型信息。

core module  →  只有数字进出的二进制
component    →  core module + 一份机器可读的接口描述 + 链接规则

技术上说,component 是一个满足 Core WebAssembly 规范的 module,加上若干个自定义 section,用来描述它能导入什么、导出什么、以及用哪套 ABI 把这些高级类型 lift/lower 到 core wasm 的数字世界里。

这里有两个关键名词:

  • module(模块):就是今天这个只有数字的世界。
  • component(组件):带类型、带资源语义、可被自动链接的一层。

举个最直观的例子,同样是一个"给字符串做处理"的函数,在 core wasm 里是 (i32, i32) -> i32,在组件模型里是这样的:

package example:txt@0.1.0;

interface processor {
  /// 记录一次失败,带结构化的错误
  record error {
    code: u32,
    message: string,
  }

  /// 返回处理后的文本
  process: func(input: string) -> result<string, error>;
}

多出来的这份东西叫 WIT(WebAssembly Interface Type),是整个设计里我觉得最值得看的一门小语言。它不长,主要就这几样:

WIT 类型 含义
string / list<T> 字符串和列表,直接传值,不再自己约定地址和长度
record 结构体,命名字段
variant / enum 带数据的枚举 / 纯枚举,对应 Rust 的 enum、TS 的 discriminated union
option<T> / result<T, E> 可空值 / 带类型错误,映射到宿主语言的 null、Result、异常
resource Method(handle) 句柄:代表一个不由你持有所有权的对象,比如打开的文件、socket
func / async func 同步函数 / WASI 0.3 起的异步函数

注意 result<string, error> —— 错误现在是签名的一部分,编译器会逼你处理它,不是靠文档里写"失败时返回空指针"。

上面那六步 JS 胶水,用 jco(JS 那边的组件工具链)生成绑定之后,调用就变成:

import { processor } from './target/component.js'

const out = processor.process('hello')  // 字符串直接进,record 错误直接 catch

一张老式建筑图纸,上面画着对称的楼层平面和密密麻麻的尺寸标注

# 三、world:把"依赖谁"也写进接口

WIT 里还有一个我觉得比函数签名更重要的概念:world。

一个 world 描述"我这个组件需要什么、能提供什么":

world gateway {
  import wasi:filesystem/types@0.3.0;   // 我需要文件系统
  import wasi:http/client@0.3.0;        // 我需要发 HTTP 请求
  export wasi:http/handler@0.3.0;       // 我能处理 HTTP 请求
}

这看着不起眼,实际解决了一个很现实的问题:能力变成了显式的、可检查的东西。

以前拿到一个 wasm 文件,你不知道它会不会偷偷读写某个目录下的文件、或者偷偷访问网络,除非在运行时给它塞一个会 trap 的 import。现在它声明了 wasi:filesystem/types,宿主一看就知道:这个组件要文件权限。不给,它就是 import 不全,在链接阶段直接失败,而不是运行到一半才崩。

这也是为什么它特别适合做插件系统:主程序暴露一个稳定的 WIT 世界,第三方用任意语言写组件填进来,越界的权限在实例化前就被 contractual 检查挡住,安全评审的成本也低得多。

# 四、Canonical ABI:谁负责把字符串搬过去

那"省掉的胶水"是被谁写掉的?答案是 Canonical ABI。

它是组件模型的一部分,规定了高级类型如何 lift(从线性内存的数字表示提升为语言里的值)/ lower(反向降回线性内存)的完整规则。比如:

  • string 在线性内存里是 (ptr: i32, len: i32) 加上 UTF-8 编码
  • record 是所有字段按对齐规则 flatten 之后的结果
  • result<T, E> 用一个判别值(discriminant)再加结构的方式表示
  • resource 用一个表(table)里的索引表示,带所有权语义
  • 跨边界传递 list<T> 需要一次拷贝,接收方拿到自己的线性内存里

这段规则值得敬佩,但也别指望它消除所有成本:按目前的 MVP 规范,跨组件传值仍然要 lift/lower,字符串和列表还是要在各自的线性内存之间复制。

也就是说 —— "零成本"这种说法是广告词。组件模型默认是 shared-nothing(每个组件有自己的线性内存),拷贝是保证隔离性的代价。想要真正干掉拷贝,需要"共享内存 + 零拷贝"的后续提案(社区在讨论的方向),那还在提案阶段,今天落地的东西里没有。

一句话记牢:它省掉的是"手写的胶水",不是"搬运本身的成本"。 如果你的热点路径一次要传几百 MB,光有个好接口也没用。

机场行李提取处的传送带在暖色灯光下运转,红色编号牌十分醒目

# 五、WASI 0.3:把 async 下沉到 ABI

这是今年 6 月(2026-06-11)真正通过的那个版本,也是我觉得分量最重的一步。

先说 WASI 0.2 有个什么臭毛病:每个组件自带一个事件循环。

听起来无所谓,实际非常致命 —— 因为两个自带事件循环的组件没法组合。组件 A 在自己的 poll 里等 IO,组件 B 也在自己的 poll 里等 IO,谁也不认识谁,没人来调度它们。所以 WASI 0.2 里只要一个组件用了异步/流式接口,它在组合性上就废了。

于是当时的异步 API 长得很别扭,一个 HTTP handler 要三步曲:

// WASI 0.2:start / subscribe / finish 三部曲
handle: func(request: request) -> ...;
// 或者更典型的:start-request → subscribe-incoming-response → finish-request

WASI 0.3 的做法是把异步本身写进 ABI:

// WASI 0.3
interface handler {
  handle: async func(request: request) -> result<response, error-code>;
}

对应发生的变化,官方给了一张很好懂的对照表:

WASI 0.2(wasi:io 时代) WASI 0.3(Component Model 原生)
resource pollable future<T>
resource input-stream stream<u8>
poll(list<pollable>) 对 future 做 await,由 runtime 调度
资源上的 subscribe() 调用返回 future<...>
start-foo / finish-foo foo: async func(...)

而落到 Rust 里,绑定生成器会直接吐成你熟悉的样子:

use wasi::http::types::{ErrorCode, Request, Response};

impl Guest for Component {
    async fn handle(request: Request) -> Result<Response, ErrorCode> {
        let body = request.consume().await?;
        Ok(Response::new("hello"))
    }
}

几个我觉得值得单独拎出来的设计:

1. 事件循环归宿主。 现在 runtime 统一管那一个事件循环,组件之间跨多少层边界都能调度到。被挂起的任务由 runtime 负责唤醒,写 future 的那一方可以是宿主,也可以是另一个组件,甚至是持有读端的同一个组件。

2. future<T> 和 stream<T> 是资源句柄。 它们跟 resource 一样是所有权句柄,跨边界传一次就把所有权交出去了;区别在于它们不能被借用(borrow)。这条规矩省掉了大量"谁负责关"的扯皮。

3. 完成式,不是就绪式。 这套 async ABI 是 completion-based 的,官方把它类比 Linux 的 io_uring 和 Windows 的 IOCP —— 需要的话可以在上面模拟 epoll/kqueue 那套 readiness 的模型,但底层是完成式。配套地,异步 ABI 同时照顾了 stackless 协程(Rust、Python、JS、C# 目前走这条)和 stackful 协程(Go:看起来是同步阻塞调用,runtime 在 ABI 边界把 goroutine 停住,等流就绪再唤醒)。

4. 顺手修掉了"流到底有没有出错"。 0.2 把终止错误塞在每次 read 的返回值里,于是只读一半就停的人分不清"流关了"和"出错了"。0.3 让 stream 多返回一个独立的 future:

// WASI 0.2
read-via-stream: func() -> result<input-stream, error-code>;
// WASI 0.3:元组里第二个 future 独立于消费进度结算
read-via-stream: func() -> tuple<stream<u8>, future<result<_, error-code>>>;

5. wasi:http 多了个 middleware world。 0.3 把 wasi:http 整理成两个世界:

world service {
  import client;    // 能发请求
  export handler;   // 能处理请求
}

world middleware {  // 取代了 0.2 时代的 proxy world
  include service;
  import handler;   // 还能把请求转交给下一个 handler
}

middleware 的价值在于可以做 service chaining:两个 HTTP 组件如果被 runtime 配在同一个进程里,就能直接拼起来调用而不用走网络。官方给的说法是,微服务之间的互调耗时可以从毫秒级降到纳秒级(六个数量级)。听着夸张,但逻辑是对的 —— 省掉的是那一段完整的序列化 + 内核网络栈。

炼油厂的塔器与纵横交错的管线在蓝天白云下延伸

# 六、现在这套东西的真实水位

吹完之后必须泼冷水,我把目前的家底老实交代一遍:

第一件事:浏览器还跑不了组件。 这是最容易误会的一点。名字里带 Assembly 的这一套,今天的主场根本不在浏览器里。浏览器原生加载的仍然是 core wasm module;一个 component 想在网页里用,得先用 jco transpile 降级编译成 core wasm + 一个 ES module 包装(把 lift/lower 那部分用 JS 重新实现一遍)。能用,但不算原生,也有体积和启动成本。

WASI 0.3 稳了,但 Component Model 1.0 还没批。 0.3 spec 已经投票通过,属于 stable release:今天为它编译的程序,哪怕后面出 0.3.x patch 也保证兼容。但"组件模型本身"的正式标准化版本还在往 1.0 走的路上,官方专门写文讲过这条路线图和遗留工作。换句话说:接口在收敛,地基还没浇完。 真用于生产要做"核心链路可随时切回 native"的心理准备。

工具链还在追。 Wasmtime 是跑得最快的:45 起就能跑 candidate,46 起组件模型 async 默认开启,47 起 GC 和异常处理也默认开了(这两个提案对 Java/Kotlin/OCaml 这类托管语言进 wasm 很关键)。JS 侧的 jco 紧随其后对齐 0.3;guest 侧的 Rust、Go、JS、Python、C# 绑定生成器陆续在补。选型前务必查一下你用的那门语言现在到哪一步,别拿官网博客当实际情况。

生态的厚度还是薄。 组件 registry(怎么分发、怎么版本化、怎么 audit)这套东西远不如 npm / crates.io 成熟。企业内部做插件系统是香的,因为它没这个问题;做公共分发的话,"去哪儿找组件"本身就还是个问题。

# 七、一份"什么时候该上车"的清单

按我自己的判断排,从上往下收益递减:

该上车的:

  1. 插件系统 / 扩展市场 —— 这是最无可争议的赢家。你要的是"别人用任意语言写的逻辑,安全地在你的进程里跑",还希望有明确的能力边界。组件模型天生就是为这个设计的,宿主的内存隔离 + 显式 world 声明这套组合拳,比你自己用进程 IPC 或者脚本沙箱省事得多。
  2. 边缘计算、冷启动敏感的函数 —— 单租户隔离 + 毫秒级启动,是它相对容器的原生优势。
  3. 把一门语言的老库喂给另一门语言 —— 一个 Rust 写的压缩/编解码库给 Node 或 Python 用,且双方愿意接受一次 ABI 层的拷贝。这种"我就要这一个函数"的场景,组件的收益立刻能兑现。
  4. 微服务之间高频互调 —— 如果你已经全部跑在 wasmtime 上,middleware world + service chaining 是真的能省掉网络那一跳。

先别上的:

  • DOM 密集型的前端逻辑。 跨 JS/wasm 边界的调用依然不便宜,把 React 组件搬进 wasm 是给自己找麻烦。正确的用法是"重计算的纯函数进 wasm,其余照旧"。
  • 普通的 CRUD 服务。 你换来的收益填不上工具链和人才缺口。
  • 要求零拷贝的大数据热路径。 上面说过了,shared-nothing 意味着一定会拷。
  • 任何"全公司押注"级别的决策。 至少等到 Component Model 1.0 尘埃落定。

# 八、一点感想

我以前觉得 WebAssembly 的第一波浪潮是失败的,因为它没有兑现任何人承诺过的东西。

现在看法变了:那波浪潮兑现的是"可移植的机器码",只是市场想要的是"可移植的软件包"。 这两件事差着整整一层——一层类型系统、一层所有权语义、一层标准化的 IO。组件模型和 WIT 补的就是这一层。

有意思的是,这层东西对用户是不可见的。没有人会因为你用了 Component Model 而觉得你的产品更快、更好看。它解决的是一个纯工程师视角的痛苦:不必再为每个组合手写一遍搬运字符串的代码。

这类事情的回报通常不体现在 demo 里,而是体现在三五年后你回头看:"奇怪,我怎么已经两年没写过那种到处 malloc 内存的胶水了。"

而最新的 0.3 把 async 也下沉到 ABI,这一步我觉得格外聪明。它没有给每门语言发一个 runtime(那是不可能的统一),而是规定了一个足够底层的并发模型,让 Rust 的 async/await、Go 的 goroutine、Python 的 asyncio 各自用各自的方式长在上面。这种"只定骨架不定肉"的做法,比给自己发明一套 Universal Runtime 靠谱多了。

当然铁轨还没修完:浏览器那边没跟上、1.0 没批、工具链还在追。但不影响你现在就做一件低成本的事 —— 把手里那些"给别的语言用的库"的边界,用 WIT 写一遍。 就算最后没编译成组件,那份明确的接口清单,本身也值回票价。


(本文涉及的状态截至 2026 年 9 月底:WASI 0.3.0 已于 2026-06-11 通过 WASI Subgroup 投票,Component Model 1.0 仍在推进中。各语言绑定生成器与 runtime 的支持度变动较快,动手前请查一下最新的支持情况。)

# 图片版权

文中配图来自 Wikimedia Commons: