Skip to main content

WebAssembly (Wasm)

WebAssembly 是一种运行在现代网络浏览器中的新型代码,并且提供新的性能特性和效果。它并不是用来取代 JavaScript 的,而是与之互补。它允许 C/C++、Rust 等语言编译到 Web 上运行,主要用于解决 JS 在 CPU 密集型计算(如音视频处理、3D 渲染)上的性能瓶颈

为什么 WebAssembly 比 JavaScript 快?

面试官经常会问:“JS 经过 V8 的 JIT 优化已经很快了,为什么还要搞 Wasm?”

  • 体积更小,加载更快:Wasm 是二进制格式,比起文本格式的 JS,下载速度更快,且浏览器不需要进行词法分析和语法分析(Parse)。
  • 跳过编译阶段:JS 需要经过 JIT(即时编译)将热点代码转化为机器码,而且如果类型改变还会发生“去优化(Deoptimization)”被打回原形。Wasm 本身就是静态类型的汇编级代码,浏览器拿到后直接就能翻译成底层机器码执行,性能极其稳定,不存在去优化。
  • 内存管理更高效:JS 依赖引擎的垃圾回收(GC),GC 运行时会导致主线程短暂卡顿(Stop-the-world)。Wasm 通常使用手动内存管理(如 Rust/C++ 编译过来的代码),没有 GC 的运行时开销。

WebAssembly 不是什么?

  • 不是汇编语言:它是一种底层的“虚拟指令集架构”,并不依赖于特定的物理硬件(所以能在各种浏览器里跨平台跑)。
  • 不能直接操作 DOM:这是很多人的误区。目前 Wasm 没有直接访问 DOM 的能力,它必须通过 JS 的胶水代码来间接修改 DOM。因此,用 Wasm 写普通的 UI 页面毫无意义,甚至会因为与 JS 频繁通信的开销而变得更慢。
  • 不是 JS 的替代品:Wasm 的定位是辅助引擎,专门干 JS 不擅长的脏活累活。

典型应用场景

  1. 音视频解码/编辑:B 站的 Web 端视频播放器,利用 Wasm 移植了底层的 C++ 解码库(如 FFmpeg),实现了在浏览器端直接硬解 HEVC/H.265 格式的视频。
  2. 大型桌面软件 Web 化
    • Figma:网页版的设计工具,底层图形渲染引擎由 C++ 编译为 Wasm。
    • AutoCAD / Photoshop:这两家都通过 Wasm 将他们庞大的 C++ 桌面端代码库原封不动地搬到了浏览器里。
  3. 图像处理与游戏引擎:在浏览器中运行 Unity、Unreal 引擎导出的 3D 游戏,或者进行复杂滤镜的实时计算。
  4. 3D 模型压缩与极速解析 (Draco):Google 开源的 Draco 3D 数据压缩算法。在 Three.js 等 WebGL/WebGPU 项目中,通过加载体积极小的 Draco 压缩模型,利用由 C++ 编译而来的 Wasm 版解码器,在浏览器端进行极其吃 CPU 算力的位运算与几何解码。解压后的顶点数据直接通过 ArrayBuffer 零拷贝共享给 JS 并推入 GPU,极大缩短了 3D 场景的首屏加载时间。

Wasm 与 JS 是如何通信的?

  • WebAssembly 模块导出了一个实例,JS 可以直接像调用普通 JS 函数一样调用 Wasm 里的函数(比如 wasmInstance.exports.fibonacci(10))。
  • 内存共享:它们之间传递复杂数据(如大数组、图像数据)不能靠传参(因为会有拷贝开销),而是通过一块 共享的线性内存 (WebAssembly.Memory)。JS 将数据写入这块 ArrayBuffer 内存,Wasm 直接从内存同一位置读取处理,处理完 JS 再去读,实现"零拷贝"极速通信。

Wasm 的内存模型

1. 几个绕不开的基础概念

(1)页(Page) —— 线性内存的最小分配单位是 64KB。无论你声明初始多大,最少也要先分到 1 页。32 位 Wasm 最多支持 2^16 = 65536 页 ≈ 4GB(64 位 Wasm 在 MVP 之后才逐步放开到 TB 级)。

(2)从 C 程序的视角看内存布局(Wasm 编译自 C/Rust 时):

[低地址]
0x0000 ┬─ 静态数据段(字符串常量、全局变量、exported symbols)

├─ 堆(malloc 出来的小对象、HashMap 节点等)


│ ← 堆指针 brk 向上增长

▲ ← 栈指针 rsp 向下增长

├─ 栈(函数调用帧、局部变量、参数)
0xFFFFFF (≈4GB) ┘

这就是为什么 Wasm 编译后的 wasm 文件里通常能看到 (memory (export "mem") 1) 这种声明 —— 它告诉宿主:"我至少要 1 页(64KB)的连续内存来跑"。

(3)两个"堆"在浏览器里并存

┌─────────────────────────┐    ┌─────────────────────────┐
│ JS 引擎堆(V8 托管) │ │ Wasm 线性内存 │
│ │ │ │
│ - JS 对象 / 闭包 │ │ - C 结构体 / 缓冲区 │
│ - 字符串 / Map / Set │ │ - 编译过来的静态数据 │
│ - 自动 GC 回收 │ │ - 手动 malloc/free │
│ │ │ │
│ 受 V8 内存上限限制 │ │ 默认 4GB 上限 │
│ (64 位约 1.4G) │ │ (需 initial 声明) │
└─────────────────────────┘
▲ ▲
│ │
│ 唯一桥梁:memory.buffer │
└────────── ArrayBuffer ───┘
(两边都看得到同一块字节)

两者完全隔离,唯一的桥梁就是 memory.buffer 这个 ArrayBuffer

2. "零拷贝"通信怎么做到的?

面试官追问:"传大数组/图像数据时,Wasm 怎么做到零拷贝?"

// 1) 实例化时把 memory 注入模块
const memory = new WebAssembly.Memory({ initial: 10 });
const { instance } = await WebAssembly.instantiateStreaming(
fetch("/decode.wasm"),
{ env: { memory } },
);

// 2) JS 往这块内存里"丢数据"
const view = new Uint8Array(memory.buffer);
view[0] = 0xff;
view[1] = 0xd8; // 假装写个 JPEG 头两个字节
// 注意:此时 Wasm 内部已经能直接看到这两个字节

// 3) 调 Wasm 函数处理
const outPtr = instance.exports.decode(view.byteOffset, view.length);
// 4) JS 再从同一块内存里读结果
const result = new Uint8Array(memory.buffer, outPtr, 1024);

关键点:JS 写、Wasm 读,没有发生任何数据拷贝。因为底层就是同一块物理内存被两个 runtime 同时看到 —— JS 通过 ArrayBuffer 看到,Wasm 通过它的"指针 + 长度"看到。

这也是为什么在 Three.js 里用 Draco 解压 3D 模型(开头举的例子)能做到极速:解压出来的顶点数据直接推 GPU,根本不需要在 JS 堆和 Wasm 堆之间搬来搬去

3. 三个最容易踩的坑

坑 1:memory.grow() 之后,旧视图直接"形同虚设"

const memory = new WebAssembly.Memory({ initial: 1 }); // 64KB
const view = new Uint8Array(memory.buffer); // 捕获了当前的 buffer 引用

memory.grow(1); // 扩容 1 页(64KB)

// ⚠️ 看起来 view.length 没变,写入的数据会写到错的地方
console.log(view.length); // 65536
view[70000] = 0xff; // 越界写入新页 → 默默写错地方甚至触发 trap

根因memory.grow 在 V8 / 浏览器实现下可能会重新分配底层物理内存(类似 realloc),旧的 ArrayBuffer 引用就成了悬空指针

安全做法:每次使用前重新取一次视图,不要把视图缓存住:

function writeU8(offset, val) {
new Uint8Array(memory.buffer)[offset] = val; // 现取现用
}

坑 2:C 端忘了 free,Node 进程 heapUsed 完全看不到

这是真实生产环境里很隐蔽的一类泄漏。Wasm 没有 GC,malloc/free 是源语言自己负责的:

// decode.c (编译成 Wasm 前)
uint8_t* decode(const uint8_t* in, size_t len) {
uint8_t* tmp = malloc(8192); // ⚠️ 分配临时缓冲
// ... 解码逻辑 ...
memcpy(out, tmp, decoded_len);
return out; // ❌ 忘了 free(tmp)
}

每次调用 decode 都会在 Wasm 线性内存里泄漏 8KB。但在 Node 进程里 process.memoryUsage().heapUsed 纹丝不动 —— 因为那块内存根本不在 V8 堆里。

排查姿势:直接读 memory.buffer.byteLength(线性内存总大小),或者用 WebAssembly.Memory.prototype.buffer 在 DevTools Memory 面板里搜"Detached ArrayBuffer"。

坑 3:数组越界 ≠ 自动扩容,会直接 trap

JS 数组下标越界顶多返回 undefined,Wasm 里越界访问会直接抛 WebAssembly.RuntimeError(也叫 "trap")。所以如果你要在 JS 侧往 Wasm 写超过 4GB 的数据(比如加载一个超大 GLB 模型),必须自己提前 memory.grow() 扩好,否则中途就崩了。

4. 沙箱与隔离:每个实例默认有独立线性内存

每个 WebAssembly.Instance 都有自己独立的线性内存,外部 JS 拿不到里面的"指针地址"(没有 dereference 能力),只能通过导出的函数间接调用。

未经验证的 .wasm 丢到浏览器里跑,也不会像普通 C 程序那样任意读你机器内存 —— 它被关在 4GB 的沙箱里,连自家模块外的字节都摸不到

想"两个 Wasm 实例共享同一块内存",得显式 import 同一个 WebAssembly.Memory 实例 —— 这也是 Emscripten pthread 模式、SharedArrayBuffer 跨界共享的底层原理。