WebAssembly 并不总比 JavaScript 快
WebAssembly 不一定比 JavaScript 快。真实基准测试显示 TypeScript 何时更快,以及跨边界调用为何会拖垮性能。

上个月的一个周六,我花了一整天把一个 Rust 编译成 WASM 的图片元数据解析器,改写成了 TypeScript。那个 WASM 版本已经在生产环境跑了八个月,运行得没问题,也没人抱怨。但我一直盯着性能追踪数据,总觉得哪里不对劲:WASM 解析器花在 JS 与 WASM 边界之间搬运数据上的时间,居然比真正解析还多。于是我就动手重写了。纯 TypeScript,不用 WASM。结果在我们的中位数负载上快了 38%。我并没有写出更好的算法,只是不再为跨越边界付“过路费”了。
这次经历让我的想法变了。我一直是那种默认认为 WebAssembly 就是快方案的开发者,想都不想。结果发现,这个假设错的次数比我们多数人意识到的要多得多。
“WASM 永远更快”这个迷思该打破了
大多数开发者心里的模型大概是这样的:编译型语言可以编译到 WASM,WASM 能跑出接近原生的速度,所以 WASM 比 JavaScript 快。这条推理链上的每一步都大体成立,但结论站不住,因为它忽略了计算之外的各种开销,比如数据进出的成本,以及两套运行时并存带来的额外负担。
JavaScript 引擎已经强到离谱了。V8、SpiderMonkey、JavaScriptCore 背后是几十年的优化积累,出资的都是地球上最有钱的一批公司。它们做推测性 JIT 编译、内联缓存、逃逸分析和隐藏类转换。对很多工作负载来说,尤其是 Web 应用里常见的字符串密集、对象密集、回调密集的场景,现代 JS 引擎生成的机器码,跟静态编译器产出的结果相比,差距小得出奇。
WASM 享受不到这些优化。它是 AOT 编译的,你发布什么,运行的就是什么。这对可预测性是好事,但也意味着 WASM 无法像 JIT 那样根据运行时的模式动态调整。
边界调用问题:千次调用,积少成多
这是最大的问题。每一次从 JavaScript 调用 WASM(或者反过来)都有开销。引擎需要封送数据、校验类型、切换执行上下文。单次调用很便宜,大概几微秒。但如果一次操作要跨越边界上千次,那这些开销就会把性能彻底吃掉。
我经常见到这种模式:团队用 Rust 写了一个解析器、转换器或校验器,编译成 WASM,然后外面包一层 JavaScript 胶水代码,每个节点、每个 token、每一步都去调用一次 WASM。WASM 核心本身单独看可能快得惊人,但胶水代码会把它变成一条收费公路。
// The toll road pattern — looks clean, performs terribly
const wasmParser = await initParser();
const ast = wasmParser.parse(source); // JS -> WASM
for (const node of wasmParser.walkTree(ast)) { // WASM -> JS per node
if (matchesRule(node)) { // JS land
wasmParser.transform(node, newValue); // JS -> WASM per match
}
}
// A 500-line document generates ~10,000 boundary crossings.
// At 2μs each, that's 20ms of pure overhead before any real work.
// Same logic, pure TypeScript — zero boundary tax
const ast = parse(source);
for (const node of walkTree(ast)) {
if (matchesRule(node)) {
transform(node, newValue);
}
}
// V8 JIT-compiles the hot loop. Total time: often under 5ms.
我在一个真实代码库上完整测过这个场景。WASM 版本处理一批 100 个配置文件用了 340 毫秒,TypeScript 移植版只用了 195 毫秒。原因并不是 TypeScript 语言更快,它本来就没那么快,而是工作从头到尾都没有离开过同一个执行上下文。
为什么字符串会毁掉 WASM 的性能
WASM 操作的是线性内存,也就是一块扁平的字节缓冲区。而 JavaScript 字符串完全是另一回事,它们是引擎托管的对象,以各种专门的内部格式存储,比如 Latin1、UTF-16、rope、cons string。把一个字符串从 JS 传进 WASM,要先编码成 UTF-8,再在线性内存里分配空间,然后把字节复制过去。把结果传回来又得再复制一次,并且解码。
如果是数值计算,这些都无所谓。你传进一个 TypedArray,拿回一堆数字就行。但如果代码大量处理字符串,比如解析器、模板引擎、校验器、序列化器,那每一段字符串片段都要付出“编码、复制、处理、再复制、解码”这一整套税。
// What actually happens when you pass a string to WASM
// (wasm-bindgen generates code like this behind the scenes)
function pushStringToWasm(s: string): [ptr: number, len: number] {
const encoded = new TextEncoder().encode(s); // allocate + encode
const ptr = wasm.__wbindgen_malloc(encoded.length);
new Uint8Array(wasm.memory.buffer).set(encoded, ptr); // copy
return [ptr, encoded.length];
}
function pullStringFromWasm(ptr: number, len: number): string {
const bytes = new Uint8Array(wasm.memory.buffer, ptr, len); // view
return new TextDecoder().decode(bytes.slice()); // copy + decode
}
// A parser that handles 3,000 string fragments per document
// runs this cycle 6,000 times (in + out). That adds up.
我分析过我们的 WASM 解析器,发现总执行时间的 31% 花在了字符串序列化上。不是解析,也不是建树,纯粹是字符串在边界两侧来回搬运。TypeScript 版本直接消除了这一整类工作。
如果你的热路径以字符串为主,那 WASM 很可能让它更慢,而不是更快。序列化开销是真实存在的,而且累积得很快。
JIT 编译:没人谈论的性能优势
WASM 的 AOT 模型意味着性能可预测、表现稳定,这在某些场景下非常好。但 JavaScript 的 JIT 模型是,V8 会观察代码的实际运行,然后生成针对真实数据流优化过的机器码。时间一长,JIT 编译出来的代码可能快得离谱。
以对象形状特化为例。如果你处理的是一组属性相同、顺序也相同的对象数组,V8 会识别出这种单态模式,生成的机器码直接按固定内存偏移访问属性,不需要哈希表查找,也不需要类型检查。动态 JavaScript 能跑出接近 C 结构体的性能,原理大致就是这样。
// V8 loves this pattern — all objects share one hidden class
interface Token {
kind: number; // numeric enum, not string
start: number;
end: number;
flags: number;
}
// Flat array of uniform objects = V8's happy place
const tokens: Token[] = [];
for (let i = 0; i < source.length; ) {
const kind = classifyChar(source.charCodeAt(i));
const start = i;
i = scanToken(source, i, kind);
tokens.push({ kind, start, end: i, flags: 0 });
}
// After a few hundred iterations, V8 compiles this to
// machine code with fixed-offset property access. Fast.
WASM 做不到这一点。它的性能特征在编译时就固定了。对于紧凑、可预测的数值循环,这没问题,Rust 编译器早就把它优化到极致了。但对于大多数 Web 应用里那种杂乱的、多态的、回调密集的代码,JIT 在运行时特化的能力确实是实打实的优势。
包体积与冷启动:你容易忽略的指标
吞吐量并不是唯一的性能指标。用户感受到的还有加载时间、交互延迟和首次出结果的时间。在这三方面,WASM 都有实实在在的成本。
- 一个使用 wasm-bindgen 的 Rust WASM 模块,gzip 后通常在 100KB 到 1.5MB 之间。同等功能的 TypeScript 代码,压缩并 gzip 后通常只有 10 到 40KB。
- WASM 模块在执行前必须先编译成原生代码。流式编译有帮助,但浏览器仍然需要做这部分工作。
- JavaScript 引擎采用惰性解析,函数在被调用之前不会被编译。WASM 则要一次性付出全部编译成本。
- V8 的代码缓存让重复访问时可以完全跳过 JS 解析。WASM 也有编译缓存机制,但成熟度还比较低。
这是我们项目里的一组真实对比。WASM 解析器的包体积是 380KB(gzip 后),TypeScript 替代版本只有 26KB。在网络良好的环境下,这点差距还好。但在限速的 3G 网络下(我每次做性能评审都会模拟这种情况),WASM 版本额外增加了 1.8 秒的加载时间。这已经不是可以忽略的误差,而是用户留下还是直接关掉页面的区别。
冷启动也很关键。WASM 模块在一台中端手机上花了大约 110 毫秒才完成编译和实例化,TypeScript 版本则在 20 毫秒内就可以交互。对于用户每天要用几十次的工具来说,这些时间累积起来会让人非常烦躁。
WASM 真正占优的场景(以及你应该用它的时候)
我不想给人留下 WASM 是个糟糕技术的印象。它是一项很好的技术,只是有非常明确的甜蜜点。问题在于,很多人把它用在甜蜜点之外,然后又纳闷为什么变慢了。
当计算量大、边界调用次数少、数据以数值为主时,WASM 就会赢。比如:对像素缓冲区做图像滤镜、加密哈希、物理模拟、音频 DSP、视频编解码。把一大块数据塞进线性内存,让 WASM 在紧凑的循环里算完,再把结果取回来。进去一次,出来一次,中间是大量计算。这正是 WASM 的闪光点。
当你需要确定性的性能时,WASM 也有优势。JIT 编译很强,但不可预测:一个 JS 函数的前几次调用可能是解释执行,之后是基线编译,最后也许才是优化编译。如果你需要从第一次调用起就保持稳定的延迟,比如游戏循环、实时音频、金融计算,WASM 的提前编译就能提供这种保证。
当你要移植一个现有的原生代码库时,选择 WASM 显然是正确的。没人会为了避开 WASM 的开销,把 SQLite 用 JavaScript 重写一遍,那太疯狂了。现有的 C 代码凝结了几十年的优化成果,你不可能复制得出来。
实用决策框架:选 WASM 还是 TypeScript?
我在三个项目里反复做过这种对比,最后总结出一份简单的检查清单。它谈不上完美,但已经两次帮我避免了错误的决定。
- 统计边界调用次数。如果你的 WASM 模块与 JS 之间的调用(不论哪个方向)在一次操作中超过几百次,TypeScript 大概率会赢。拿不准就做性能分析。
- 看数据类型。主要是数字和 TypedArray?那 WASM 很合适。主要是字符串、对象和回调?那就留在 JS 里。
- 衡量启动成本。如果代码在页面加载时或用户点击后运行,WASM 的编译开销就很重要。如果它运行在长期存活的 Worker 里,启动成本就会被摊薄。
- 检查包体积预算。如果你的包体积已经很吃紧,再加一个 400KB 的 WASM 模块会很痛苦。
- 考虑开发体验成本。WASM 会引入 Rust 或 C++ 的构建系统、Source Map 的复杂性,以及更难调试的问题。如果性能收益很有限,这些复杂度就不值得。
- 用真实数据做基准测试。微基准测试会骗人。边界开销的模式只有在生产规模的输入和真实的调用模式下才会显现出来。
重写之后的收获:前后对比
下面列出我们解析器迁移的实际数字。这些不是合成的基准测试,而是来自我们真实文档语料库的数据,共 200 个文件,每个从 50 行到 2000 行不等。
Metric | Rust/WASM | TypeScript | Delta
------------------------|--------------|--------------|--------
Median parse time | 23.1ms | 14.4ms | -38%
P99 parse time | 58.3ms | 30.7ms | -47%
Bundle size (gzip) | 380KB | 26KB | -93%
Cold start | 142ms | 18ms | -87%
Boundary crossings/doc | ~12,000 | 0 | -100%
String serialization | 31% of time | 0% | eliminated
Debug/profile ease | painful | native | huge win
TypeScript 版本并不是直接移植。我重新设计了 AST 的表示方式,让它更适配 V8 的优化器:使用单态对象形状,用数字枚举代替字符串标签,用扁平数组存储代替大量指针的树结构。这些优化在 Rust 里你几乎不会去想,因为 Rust 编译器已经替你处理了这一层细节。而在 JavaScript 里,你得主动迎合 JIT。
// AST node design optimized for V8's hidden class system
// Every node has identical shape = monomorphic access = fast
const NODE_HEADING = 1;
const NODE_PARAGRAPH = 2;
const NODE_CODE = 3;
const NODE_LIST = 4;
interface ASTNode {
type: number; // numeric, not string — cheaper comparisons
start: number; // byte offset
end: number;
parent: number; // index into nodes array, not a reference
firstChild: number; // -1 if none
nextSibling: number; // -1 if none
flags: number; // bitfield for boolean props
}
// Pre-allocate and reuse — minimal GC pressure
const pool: ASTNode[] = new Array(1024);
let poolIdx = 0;
function allocNode(type: number, start: number, end: number, parent: number): number {
const i = poolIdx++;
pool[i] = { type, start, end, parent, firstChild: -1, nextSibling: -1, flags: 0 };
return i;
}
关键的洞察是什么?最快的代码,并不是用最快的语言写出来的代码,而是与运行时协作、而不是与之对抗的代码。我们的 Rust 代码本身写得很漂亮,但编译成 WASM 嵌入 JavaScript 应用之后,它却处处都在与平台对抗。
别再假设,开始测量
我现在依然在用 WASM。我有一个基于 WASM 的图片处理流水线,比我用 JavaScript 能写出来的任何方案都快 4 倍,因为它直接操作原始像素缓冲区,没有任何边界调用。工具对了,活儿才对。
但我已经不再在需要性能时默认选 WASM 了。我的新默认做法是:先写 TypeScript 版本,用真实数据测量,只有当性能分析器告诉我确实需要时,才动用 WASM,而且只用在真正慢的那条热路径上。不是整个模块,也不是整个功能,只是那段真正受益于提前编译的紧凑计算循环。
先用 TypeScript 写,然后测量。如果够快,就直接上线。如果不够快,再把热循环(且仅仅是热循环)移到 WASM 里。这样你最终会得到更少的代码、更小的包体积,以及更快的应用。
Web 平台提供了两种强大的执行模型。两种都用,但要用在它们真正有帮助的地方,而不是凭直觉认为它们应该在哪里有用。


