·22 min read

WebAssembly 入门指南:让浏览器跑得更快

WASMWebAssemblyFrontend
QUEST LOG

什么是 WebAssembly?

WebAssembly(简称 WASM)是一种可以在浏览器中高效运行的二进制指令格式。它不是用来替代 JavaScript 的,而是作为 JavaScript 的补充,让你可以把计算密集型的工作交给 WASM 来处理。

简单来说:WASM 是一个编译目标——你可以用 C、C++、Rust、Go 等语言编写代码,然后编译成 .wasm 文件,在浏览器中以接近原生速度运行。

为什么需要 WASM?

JavaScript 是解释型语言,尽管 JIT 编译器让它越来越快,但在某些场景下仍然不够用:

  • 游戏引擎:物理模拟、碰撞检测需要大量计算
  • 图像/视频处理:逐像素处理高分辨率媒体
  • 密码学:加密解密需要高性能数学运算
  • CAD/3D 建模:复杂几何计算
  • 科学计算:数据分析、模拟

WASM 的二进制格式可以更快地被解码和执行,同时它采用静态类型,避免了 JavaScript 中大量的类型检查和运行时优化开销。

WASM 核心概念

在深入代码之前,先了解几个核心概念:

概念 说明
Module 编译后的 .wasm 文件,包含函数、内存、全局变量等
Memory WASM 的线性内存空间,以“页”(64KB)为单位,可通过 ArrayBuffer 与 JS 共享
Table 函数引用表,用于实现函数指针和动态分发
Type System WASM 仅支持四种数值类型:i32i64f32f64
Import/Export Module 可以导入 JS 函数,也可以导出自己的函数给 JS 调用

WASM 文本格式(WAT)

WAT(WebAssembly Text Format)是 WASM 的可读文本表示。下面是一个最简单的例子——它将两个 i32 整数相加并返回结果:

(module
  (func (export "add") (param i32 i32) (result i32)
    local.get 0   ;; 获取第一个参数
    local.get 1   ;; 获取第二个参数
    i32.add))     ;; 执行整数加法

这个 (module) 定义了一个 WASM 模块,里面有:

  • 一个函数,接收两个 i32 参数,返回一个 i32
  • (export "add") 把这个函数导出,名字叫 add
  • 函数体用 local.get 读取参数,i32.add 执行加法

交互示例

下面每个示例都是可在线运行的。点击“运行”按钮,你就能看到 WASM 和 JavaScript 如何协作。

示例 1:最简单的 WASM 函数

示例 1:两数相加

WAT 源码
(module
(func (export "add") (param i32 i32) (result i32)
  local.get 0
  local.get 1
  i32.add))
JavaScript
// 调用 WASM 导出的 add 函数
const a = 42;
const b = 58;
const result = exports.add(a, b);
log(a + " + " + b + " = " + result);

// 再测几组
log("100 + 200 = " + exports.add(100, 200));
log("999 + 1 = " + exports.add(999, 1));

这个例子展示了最基本的 WASM 用法:

  1. 浏览器通过 WebAssembly.instantiate() 加载 WASM 二进制
  2. instance.exports 获取导出的 add 函数
  3. 像调用普通 JavaScript 函数一样调用它

WASM 会进行类型检查——如果你传入字符串或其他类型,调用会直接失败。

示例 2:单参数运算

示例 2:计算平方

WAT 源码
(module
(func (export "square") (param i32) (result i32)
  local.get 0
  local.get 0
  i32.mul))
JavaScript
// 计算几个数的平方
for (let i = 1; i <= 10; i++) {
log(i + "² = " + exports.square(i));
}

// WASM 函数可以被赋值、传参,和普通函数一样
const sq = exports.square;
log("\n25² = " + sq(25));

i32.mul 是 WASM 的乘法指令。WASM 使用栈式虚拟机模型——操作数先入栈,指令再消费栈顶的值。local.get 0 会把同一个参数入栈两次,i32.mul 把它们相乘。

示例 3:共享内存

示例 3:读写 WASM 内存

WAT 源码
(module
(memory (export "memory") 1)        ;; 1 页 = 64KB
(func (export "store") (param i32 i32)
  local.get 0    ;; 地址
  local.get 1    ;; 值
  i32.store)     ;; 将 32 位整数写入内存
(func (export "load") (param i32) (result i32)
  local.get 0    ;; 地址
  i32.load))     ;; 从内存读取 32 位整数
JavaScript
// WASM 的线性内存以 ArrayBuffer 形式暴露给 JS
const memory = exports.memory;
// 创建一个 Uint32Array 视图来读写 32 位整数
const view = new Uint32Array(memory.buffer);

// 用 WASM 的 store 函数写入
exports.store(0, 42); // 地址 0
exports.store(4, 100); // 地址 4 (每个 i32 占 4 字节)
exports.store(8, 256); // 地址 8

// 用 WASM 的 load 函数读取
log("地址 0: " + exports.load(0));
log("地址 4: " + exports.load(4));
log("地址 8: " + exports.load(8));

// JS 也可以直接读写同一块内存!
view[3] = 999; // 地址 12
log("\nJS 写入地址 12: " + exports.load(12));
log("JS 读取地址 4: " + view[1]);

这个例子展示了 WASM 和 JavaScript 共享内存的关键能力:

  • (memory 1) 申请了 1 页(64KB) 线性内存
  • i32.store 将 32 位整数写入指定地址
  • i32.load 从指定地址读取 32 位整数
  • JS 通过 exports.memory.buffer 获取这块内存的 ArrayBuffer,直接读写

这意味着你可以在 WASM 中处理大量数据(如图像像素),然后让 JS 读取结果来渲染。零拷贝,非常高效。

示例 4:性能对比 — WASM vs JS vs Worker

这个例子对比三种方式执行相同计算密集型任务(3000 万次循环累加)的耗时:

  • WASM:二进制指令直接在浏览器虚拟机中执行,接近原生速度
  • 纯 JS:JIT 编译,有类型检查和优化开销
  • Web Worker:在后台线程执行,不阻塞主线程,但执行速度与纯 JS 相同

示例 4:性能对比

WAT 源码
(module
(func (export "compute") (param i32) (result i32)
  (local i32)
  i32.const 0
  local.set 1         ;; sum = 0
  loop $loop
    local.get 1
    local.get 0
    i32.add           ;; sum += n
    local.set 1
    local.get 0
    i32.const 1
    i32.sub           ;; n--
    local.tee 0
    i32.const 0
    i32.gt_s          ;; n > 0 ?
    br_if $loop       ;; 是则继续循环
  end
  local.get 1))
JavaScript
const ITERATIONS = 30000000;

// 辅助:纯 JS 版本
function jsCompute(n) {
var sum = 0;
for (var i = n; i > 0; i--) sum += i;
return sum;
}

// 1. WASM 测试
log("=== 累加 " + ITERATIONS.toLocaleString() + " 次循环 ===");
var wasmStart = performance.now();
var wasmResult = exports.compute(ITERATIONS);
var wasmTime = performance.now() - wasmStart;
log("WASM  : " + wasmTime.toFixed(1) + "ms  (结果: " + wasmResult + ")");

// 2. 纯 JS 测试
var jsStart = performance.now();
var jsResult = jsCompute(ITERATIONS);
var jsTime = performance.now() - jsStart;
log("纯 JS : " + jsTime.toFixed(1) + "ms  (结果: " + jsResult + ")");

log("\nWASM 比纯 JS 快 " + (jsTime / wasmTime).toFixed(2) + "x");

// 3. Web Worker 测试(异步,不阻塞主线程)
return new Promise(function(resolve) {
var workerCode = 'self.onmessage=function(e){var n=e.data,s=0;for(var i=n;i>0;i--)s+=i;self.postMessage(s)}';
var blob = new Blob([workerCode],{type:"application/javascript"});
var worker = new Worker(URL.createObjectURL(blob));
var workerStart = performance.now();
worker.postMessage(ITERATIONS);
worker.onmessage = function(e) {
  var workerTime = performance.now() - workerStart;
  log("Worker: " + workerTime.toFixed(1) + "ms  (结果: " + e.data + ")");
  log("Worker 速度与纯 JS 相近,但计算在后台线程,\n不阻塞主线程,页面保持响应。");
  resolve();
};
});

WASM 为什么更快?

  1. 预编译:WASM 在 instantiate() 阶段就已经完成编译,执行时无需 JIT 预热
  2. 静态类型:WASM 指令直接操作 i32/f64 等固定类型,无需运行时类型检查
  3. 无 GC:线性内存无需垃圾回收,没有 GC 暂停
  4. 紧凑指令集:二进制格式更小,解码更快

Worker 的价值不在于速度,而在于不阻塞主线程。 对于需要保持 UI 流畅的场景(如动画、输入响应),把重计算放到 Worker 中是更好的选择。Worker + WASM 的组合(在 Worker 中加载 WASM)则兼具两者优势。

在 JS 中加载 WASM

加载 WASM 有多种方式,取决于你的项目环境。

方式 1:原生 fetch(纯 JS/TS,无需构建工具)

最灵活、最通用的方式,在任意 JS 运行环境中都能用:

// 基本用法
const response = await fetch("/path/to/module.wasm");
const buffer = await response.arrayBuffer();
const { instance } = await WebAssembly.instantiate(buffer);

// 调用导出的函数
console.log(instance.exports.add(1, 2));

importObject 让 WASM 调用 JS 函数:

const importObject = {
  env: {
    // 让 WASM 可以调用 console.log
    log_number: (n) => console.log("WASM says:", n),
    // 提供三角函数等 WASM 没有的内置函数
    sin: Math.sin,
  },
};

const response = await fetch("/module.wasm");
const buffer = await response.arrayBuffer();
const { instance } = await WebAssembly.instantiate(buffer, importObject);

importObject 允许 WASM 模块调用 JavaScript 函数,这是双向通信的基础。

如果 WASM 操作了共享内存,通过 instance.exports.memory 获取:

const memory = instance.exports.memory;
// JS 可以读写同一块 ArrayBuffer
const view = new Uint32Array(memory.buffer);
view[0] = 42;

方式 2:Vite 构建工具

Vite 对 .wasm 文件有原生支持,你可以像导入模块一样导入它——Vite 自动处理 fetch、编译和导出绑定:

// 默认导出是初始化函数,具名导出是 WASM 函数
import init, { add, fibonacci } from "./math.wasm";

await init(); // 必须先初始化
console.log(add(1, 2)); // 之后才能调用

init 是 Vite 自动生成的初始化函数(负责 fetch + instantiate),add / fibonacci 是 WASM 模块中 (export "add" ...) 的函数。必须 await init() 后才可调用。

如果不想阻塞模块加载,可以用异步 import:

const { default: init, add } = await import("./math.wasm");
await init();

方式 3:Webpack 构建工具

Webpack 4+ 支持 WASM,但需要开启实验特性。在 webpack.config.js 中:

module.exports = {
  experiments: {
    asyncWebAssembly: true, // 启用异步 WASM
    // syncWebAssembly: true,  // 同步模式(不推荐,会阻塞)
  },
};

然后在代码中直接 import:

// 异步导入(推荐)
import("./math.wasm").then(({ add }) => {
  console.log(add(1, 2));
});

// 顶层 await 也可以
const { add } = await import("./math.wasm");

注意 Webpack 的 WASM 文件大小会被限制(默认 4KB 以内内联),更大的文件会自动走异步 chunk 加载。

方式 4:Vite/Webpack 中配合 Worker

在构建工具中,可以把 WASM 和 Worker 结合起来,同时享受工具链和后台线程的优势:

// worker.js
import init, { compute } from "./heavy.wasm";

self.onmessage = async (e) => {
  await init();
  const result = compute(e.data);
  self.postMessage(result);
};

// 主线程
const worker = new Worker(new URL("./worker.js", import.meta.url));
worker.postMessage(30000000);
worker.onmessage = (e) => console.log("结果:", e.data);

Vite 的 new URL(..., import.meta.url) 和 Webpack 的 new Worker(...) 都会自动处理 Worker 和 WASM 的打包。

编译语言到 WASM

在实际项目中,你通常不会手写 WAT。以下是主流编译方案:

Rust → WASM

# 使用 wasm-pack
cargo install wasm-pack
wasm-pack build --target web
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn fibonacci(n: u32) -> u32 {
    match n {
        0 => 0,
        1 => 1,
        _ => fibonacci(n - 1) + fibonacci(n - 2),
    }
}

C/C++ → WASM

# 使用 Emscripten
emcc hello.c -o hello.js -s WASM=1
#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE
int add(int a, int b) {
    return a + b;
}

AssemblyScript

AssemblyScript 是 TypeScript 的子集,专门编译到 WASM:

// AssemblyScript 代码
export function add(a: i32, b: i32): i32 {
  return a + b;
}
npx asc add.ts -o add.wasm --optimize

WASM 的限制

虽然 WASM 很强大,但它也有一些限制需要了解:

  1. 只能操作数值:WASM 不能直接操作 DOM、不能发起网络请求——这些都需要通过调用导入的 JS 函数来完成
  2. 不支持垃圾回收:线性内存需要手动管理(不过 GC 提案正在推进中)
  3. 类型有限:仅支持 i32i64f32f64。复杂类型(字符串、对象)需要编码为内存中的字节
  4. 调试困难:相比 JS,WASM 的调试工具还不够成熟

什么时候用 WASM?

WASM 并不是“更好的 JavaScript”。把整个应用都编译成 WASM 通常没有意义。它最适合用在:

  • 识别出性能瓶颈在计算密集的部分
  • 现成的 C/Rust 库想在浏览器中复用
  • 需要跨语言复用同一套计算逻辑(前端 + 后端 + CLI)

常见场景:图像编辑器(Figma)、游戏引擎(Unity WebGL)、代码编辑器、CAD 查看器等。

延伸阅读