WebAssembly 常被误解成”让网页变快的银弹”。它在特定场景确实有用,但用错地方反而更慢。搞清楚它是什么、代价在哪,判断力就有了。
WASM 是什么
WebAssembly 是一种低层的、可移植的二进制指令格式,设计目标是作为高级语言的编译目标。它跑在一个基于栈的虚拟机里,有线性内存、有类型化的函数和局部变量。
几点必须厘清:
- 它不是 JavaScript 的替代品,而是补充。WASM 模块无法直接访问 DOM,所有 DOM 操作必须回调到 JS;
- 它是编译目标而非编写语言。虽然有
wat文本格式,但实际开发是写 C/C++/Rust/Go/AssemblyScript 再编译; - 它的类型系统很基础:只有 i32/i64/f32/f64(外加 v128 用于 SIMD、引用类型等扩展)。没有字符串、没有对象、没有数组——这些都要靠线性内存里的字节自己组织。
(module
(func $add (param $a i32) (param $b i32) (result i32)
local.get $a
local.get $b
i32.add)
(export "add" (func $add)))
WASM 的安全模型是沙箱化的:模块只能访问自己那块线性内存,无法任意读取进程内存,所有外部能力通过显式导入(import)获得。这是它与原生插件的根本区别。
适合的场景
计算密集型任务:一次调用、内部循环量大、逻辑可预测。
- 图像/视频编解码、滤镜处理;
- 音频处理与合成;
- 加解密、哈希、签名;
- 压缩/解压缩;
- 物理仿真、数值计算、密码学运算;
- 游戏引擎与渲染核心;
- 大文件的解析(如 PDF、CAD 格式、数据库引擎)。
已有原生代码的复用:这是 ROI 最高的场景。一个成熟的 C/C++ 库(编解码器、解析器、加密算法库)已经有多年打磨与测试,用 Emscripten 编译到 WASM 就能在浏览器里跑,避免用 JS 重写一遍还可能引入新 bug。
emcc decoder.c -O3 -s WASM=1
-s EXPORTED_FUNCTIONS='["_decode","_free"]'
-s ALLOW_MEMORY_GROWTH=1
-o decoder.js
-O3 是优化级别,ALLOW_MEMORY_GROWTH=1 允许内存增长(否则初始内存写满就崩),EXPORTED_FUNCTIONS 决定哪些函数对 JS 可见。
对性能一致性要求高的场景:JS 靠 JIT 优化,运行一段时间后才达到峰值性能,且不同类型反馈可能导致去优化。WASM 的编译结果更可预测,冷启动后性能曲线平稳。
不适合的场景
DOM 密集的操作。WASM 碰不到 DOM,每次操作都要跨界调用 JS,开销远大于直接在 JS 里做。
频繁的小调用。这是最容易踩的坑:
// 反例:循环里每次调用一个极小的 WASM 函数
for (let i = 0; i < 1_000_000; i++) {
sum += wasm.add(a[i], b[i]); // 一百万次跨界调用
}
正确做法是把循环搬进 WASM,只调用一次:
#[no_mangle]
pub extern "C" fn sum_arrays(a: *const f32, b: *const f32, len: usize) -> f32 {
let a = unsafe { std::slice::from_raw_parts(a, len) };
let b = unsafe { std::slice::from_raw_parts(b, len) };
a.iter().zip(b).map(|(x, y)| x + y).sum()
}
判断标准不是”这个计算有多重”,而是”跨界调用次数有多少”。把跨界次数降到与数据量无关,WASM 才有意义。
IO 密集或等待外部的任务:网络请求、数据库查询、用户交互响应——瓶颈不在计算,换成 WASM 毫无收益。
小而简单的逻辑:为了一个几十行的函数引入一整套编译工具链、几 MB 的 wasm 文件、异步加载流程,收益不成比例。
跨界调用的开销与数据拷贝
JS 与 WASM 之间的边界要用具体数字来感知:
- 函数调用本身:现代引擎的跨界调用已经优化得相当快,通常在几十纳秒量级,不是主要成本;
- 数字参数:i32/f64 这类标量基本零成本传递;
- 字符串:必须编码成字节写入线性内存,再把指针和长度传给 WASM;返回时反向操作。编码/解码 + 拷贝是实打实的成本;
- 数组/结构化数据:需要写入线性内存。要么拷贝,要么让 WASM 直接读写 JS 的内存视图。
// 字符串传入的典型写法
function passString(mod, str) {
const bytes = new TextEncoder().encode(str);
const ptr = mod.exports.alloc(bytes.length + 1);
const mem = new Uint8Array(mod.exports.memory.buffer);
mem.set(bytes, ptr);
mem[ptr + bytes.length] = 0; // C 字符串需要 结尾
mod.exports.process(ptr);
mod.exports.free(ptr); // 手动释放,否则内存泄漏
}
这段代码的每一步都有成本。如果要传一万个字符串,这一万次 encode + alloc + set + free 会成为新瓶颈。
减少拷贝的思路:
- 让 WASM 复用同一块内存区域(自己实现内存池/arena),避免反复 alloc/free;
- 大块数据用
SharedArrayBuffer配合多线程,避免主线程拷贝(需要 COOP/COEP 响应头); - 结构化数据用二进制格式(如 FlatBuffers、protobuf)直接在线性内存里布局,JS 侧用
DataView读写,跳过 JSON 序列化; - 一次传入批量数据,在 WASM 内部循环。
手写内存管理意味着要自己处理释放。Rust 侧可以用 wasm-bindgen 生成胶水代码,把 String/Vec 的转换自动化,代价是引入一层抽象和额外体积。
线性内存模型
WASM 的内存是一块连续的、可按页(64KB)增长的 ArrayBuffer,通过 WebAssembly.Memory 暴露给 JS:
const memory = new WebAssembly.Memory({ initial: 256, maximum: 2048 }); // 256 页 = 16MB
几个要点:
- 内存是模块私有的,JS 只能通过一个
ArrayBuffer视图访问,越界访问会抛错而不是读到别的内存; - 增长会 detach 旧 buffer。
memory.grow()后之前创建的Uint8Array视图会失效,必须重新创建。这是最常见的运行时错误来源:
const view = new Uint8Array(memory.buffer);
memory.grow(1); // 增长
view[0] = 1; // TypeError: 视图已 detach
- 编译时不加
ALLOW_MEMORY_GROWTH/ 不设maximum,内存写满就抛错,不会自动扩展; - 内存不自动回收。C/Rust 里
malloc出来的东西要自己free,或者在 Rust 里依赖所有权系统 + 一次性 drop 整个 arena。
启动与体积代价
WASM 模块不是零成本加载的:
- 下载体积:即使编译后体积不大(简单模块几十 KB,带完整运行时的可能几 MB),也需要一次网络往返;
- 编译时间:浏览器拿到二进制后要做验证与编译。流式编译(
WebAssembly.instantiateStreaming)能边下载边编译,比先arrayBuffer()再instantiate快:
// 必须返回 application/wasm 的 MIME 类型,否则 instantiateStreaming 失败
const { instance } = await WebAssembly.instantiateStreaming(fetch('m.wasm'), imports);
- 初始化开销:模块里的静态数据初始化、内存分配都在这时发生;
- 流式编译的前提:服务器必须正确返回
Content-Type: application/wasm,否则回退到非流式路径,白白多等一次。
一个实用的判断:如果整个任务在 JS 里跑只需几十毫秒,加上加载与编译的固定成本后,WASM 版本大概率是负收益。WASM 的价值要能覆盖它的固定成本。
SIMD 与多线程的现状边界
SIMD(128 位向量运算)在主流浏览器里已经可用,编译时需要开启对应特性:
RUSTFLAGS="-C target-feature=+simd128" cargo build --target wasm32-unknown-unknown
它适合数据并行的场景——对一批像素做同样的运算、矩阵运算、向量加减。收益取决于算法本身能否向量化,不是所有循环都能自动受益;手动对齐内存布局、避免 gather/scatter 这类非连续访问,才能吃到大部分红利。要留意的是:目标浏览器是否支持需要在运行时检测并准备非 SIMD 的回退版本。
多线程依赖 SharedArrayBuffer + WebAssembly.Threads,而 SharedArrayBuffer 要求页面启用跨源隔离(COOP/COEP 响应头):
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
这个要求会连带影响页面上所有第三方资源的加载——第三方脚本、图片、iframe 都需要相应配置,否则被阻塞。这是多线程 WASM 在实际项目里落地困难的主因,不是技术能力问题而是集成成本问题。
另外线程创建有固定开销,任务太小不值得开线程;线程间同步(Atomics.wait 会阻塞,主线程上不允许使用)也需要谨慎设计。
落地前先问三个问题
- 这段逻辑的耗时里,计算占比有多少? 如果大部分时间在等 IO 或操作 DOM,换 WASM 无效;
- 能不能把跨界调用次数降到常数级? 不能的话,通信成本会吃掉收益;
- 固定成本(下载 + 编译 + 初始化)由多少次调用来摊薄? 只调用一次的一次性任务,几乎不可能划算。
三个都能给出肯定答案,再评估工具链、构建集成、调试体验(sourcemap、DWARF 调试支持)这些工程成本。WASM 是工具箱里的一把专用工具,不是通用替代品。
评论(4)
一路看下来,最认同「先能跑再优化」。
看完了,去把我们那条链路重画了一遍,确实清楚多了。
有个小建议:加个示意图会更直观。
讲真,这种文章比那些「十分钟精通」有用一百倍。
请 登录 后参与评论。