一次编译,到处调用:WebAssembly 组件模型与 WASI 0.3
- 作者:Bougie
- 创建于:2026-09-28
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)
这还算好的。真正要命的是下面三件事:
- 两个不同语言写的模块拼不起来。 Rust 编译的 wasm 和 C++ 编译的 wasm,就算函数签名数字对得上,两边的内存布局和调用约定也对不上,中间必须再写一层胶水。
- 系统调用是个大坑。 wasm 本身没有任何 IO 能力,最早的 WASI Preview 1 基本是把 POSIX 照搬了一遍(fd、
path_open、clock_time_get)。能跑移植过来的 Unix 程序,但用起来非常别扭,也没有异步。 - 错误处理全靠约定。 返回 -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 成熟。企业内部做插件系统是香的,因为它没这个问题;做公共分发的话,"去哪儿找组件"本身就还是个问题。
# 七、一份"什么时候该上车"的清单
按我自己的判断排,从上往下收益递减:
该上车的:
- 插件系统 / 扩展市场 —— 这是最无可争议的赢家。你要的是"别人用任意语言写的逻辑,安全地在你的进程里跑",还希望有明确的能力边界。组件模型天生就是为这个设计的,宿主的内存隔离 + 显式 world 声明这套组合拳,比你自己用进程 IPC 或者脚本沙箱省事得多。
- 边缘计算、冷启动敏感的函数 —— 单租户隔离 + 毫秒级启动,是它相对容器的原生优势。
- 把一门语言的老库喂给另一门语言 —— 一个 Rust 写的压缩/编解码库给 Node 或 Python 用,且双方愿意接受一次 ABI 层的拷贝。这种"我就要这一个函数"的场景,组件的收益立刻能兑现。
- 微服务之间高频互调 —— 如果你已经全部跑在 wasmtime 上,
middlewareworld + 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:
- 封面:Container terminal = 福岡/香椎パークポート (opens new window),作者 Gazouya-japan,CC BY-SA 4.0
- 图一:Playing with Lego bricks (opens new window),作者 Nenad Stojkovic,CC BY 2.0
- 图二:Blueprint reading (opens new window),American Technical Society,公有领域
- 图三:T2 luggage conveyors (opens new window),作者 Kuba Bożanowski,CC BY 2.0
- 图四:MiRO8 (opens new window),作者 Ikar.us,CC BY 2.0 DE