WebAssembly 在现代 Web 开发中:超越 JavaScript

二十多年来,JavaScript 一直是 Web 上无可争议的语言,但浏览器的运行时早已不再是单一语言的环境。WebAssembly 悄然成为每个现代浏览器中的第二套运行时,带来了 JavaScript 独自难以高效实现的能力。图像编辑器、视频编解码器、数据库引擎、三维渲染器、语言运行时——今天这些都跑在浏览器标签页里,被编译成一种以接近原生速度执行的低层二进制格式。

WebAssembly 是什么,为什么重要

WebAssembly 是一种在基于栈的虚拟机中执行的低层二进制指令格式。它被设计成编程语言的可移植编译目标,使客户端与服务端应用都能部署到 Web 上。四家浏览器厂商——Google、Mozilla、Apple 和 Microsoft——共同参与了它的设计,2019 年它成为 W3C 标准。自 2017 年起,所有主流浏览器都已支持。

但 WebAssembly 不是 JavaScript 的替代品,而是补充。JavaScript 仍然是操作 DOM、处理事件和编写界面逻辑的语言。WebAssembly 承担 JavaScript 从未被设计来做的计算密集型工作。两套运行时共处同一个标签页:模块导入 JavaScript 函数,JavaScript 通过定义良好的接口调用 Wasm 的导出。

WebAssembly 的目标不是取代 JavaScript,而是解决 JavaScript 无法高效解决的问题。Web 平台因此更强——开发者可以在同一个应用中,为每件事选择合适的工具。

基础:二进制格式与线性内存

WebAssembly 模块使用一种二进制格式(.wasm 文件),它为快速解析和小体积传输而设计。这个格式很紧凑:典型模块即使与压缩后的等价 JavaScript 相比也明显更小。它把类型、函数、导入、导出、内存声明和指令编码成一种稠密表示,浏览器可以在一次线性扫描中完成校验和编译。

每个 WebAssembly 模块都运行在一台概念上的栈式机器上。指令把值压入求值栈,再弹出以执行运算。支持的值类型只有 i32、i64、f32 和 f64——32 位与 64 位的整数和浮点数。在 Wasm 这一层没有字符串、对象、数组或任何其他高层类型。所有复杂数据结构都必须在线性内存中以原始字节表示。

线性内存是 WebAssembly 中最重要的概念。每个模块可以声明一块或多块连续、可扩展的内存。这块内存是一个扁平的字节数组,模块通过加载与存储指令读写它。宿主——JavaScript 运行时或 Wasm 运行时——控制初始大小和最大大小,并可按每页 65,536 字节的方式申请扩容。在宿主一侧,这块内存以 ArrayBuffer 的形式可见,也就是说 JavaScript 读写的正是模块所用的同一块内存。

正是这种共享内存模型让 WebAssembly 高效。宿主与模块之间交换数据无需序列化、也无需复制——它们只是往同一个缓冲区里写。当一个 Rust 函数向 JavaScript 返回字符串时,它把字节写进线性内存,并返回一个指针(整数偏移)和一个长度。JavaScript 直接从 ArrayBuffer 读取这些字节。没有 JSON 解析,没有消息传递,除函数调用本身之外没有额外开销。

模块结构与校验

一个 WebAssembly 模块由若干段组成:类型段定义函数签名,函数段声明函数,代码段存放实际字节码,内存段定义线性内存,导出段把函数和内存交给宿主。任何模块执行之前,浏览器都会对它做结构性校验并强制类型安全。校验确保所有指令操作数与其预期类型相符、所有函数调用引用有效签名、所有内存访问都落在声明的边界之内。这个过程只需毫秒,并保证畸形模块无法钻运行时的空子。

安全模型是显式而受限的。除非宿主通过导入函数明确授予相应能力,否则 WebAssembly 模块既不能访问 DOM,也不能发起网络请求、读取文件或与系统交互。模块运行在沙箱中,除了自己的线性内存和导入函数表之外接触不到任何东西。这使得来自不受信任来源的模块也能安全执行——无论在浏览器还是服务端,这一性质都至关重要。

编译到 Wasm:Rust、C、Go 与 Zig 对比

各语言编译到 WebAssembly 的质量差别很大。有些语言产出紧凑高效、开销极小的模块;另一些则需要一套运行时,给二进制体积平添数兆字节。选择源语言取决于你的性能要求、团队的熟悉程度,以及所需集成的复杂度。

Rust:Wasm 的黄金标准

在所有语言中,Rust 对 WebAssembly 的支持最好。wasm-pack 工具链无缝处理编译、优化和 JavaScript 绑定的生成。Rust 产出的二进制很小,因为它没有垃圾回收器、没有沉重的运行时,而且所有权模型天然契合线性内存的管理。一个带有若干导出函数的典型 Rust 模块,在 LTO 和优化之后介于 10 KB 到 50 KB 之间,与手写 Wasm 不相上下。

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern "C" {
    fn alert(s: &str);
}

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

#[wasm_bindgen]
pub fn greet(name: &str) -> String {
    format!("Hello, {}! Wasm says hello.", name)
}

wasm_bindgen 这个 crate 会生成 JavaScript 胶水代码,自动完成与线性内存周旋的那套动作。从 JavaScript 调用 fibonacci,看起来和用起来都跟调用任何其他 JavaScript 函数一样。在幕后,wasm_bindgen 把字符串参数转换成「指针 + 长度」的组合,调用 Wasm 函数,再把结果转回 JavaScript 字符串。这层抽象带来的开销很小,让 Rust 与 Wasm 的结合足以用于日常开发。

通过 Emscripten 使用 C 和 C++

Emscripten 是最早的 Wasm 编译工具链。它以 LLVM 为后端把 C 和 C++ 代码编译成 Wasm,并提供一层完备的 POSIX 兼容层,使现有原生应用只需极少的源码改动就能移植到 Web 上。Emscripten 自带虚拟文件系统、OpenGL 到 WebGL 的转换、pthread 模拟,以及一套负责模块生命周期的 JavaScript 运行时。

Go 与官方 Wasm 后端

Go 从 1.11 版开始支持 WebAssembly,并在每个版本中持续改进。把 Go 程序编译成 Wasm 很直接——设置 GOOS=js GOARCH=wasm 然后执行 go build。不过与 Rust 相比,Go 的 Wasm 支持有明显局限。Go 会把自己的运行时(goroutine 调度器、垃圾回收器、defer 栈)放进每一个二进制里,因此一个最小的 Go 模块起步就在 2 MB 左右。这在服务端尚可接受,因为体积不那么关键;但在浏览器场景下,下载与解析时间至关重要,这个体积难以承受。

Zig:新的挑战者

Zig 自我定位为 C 的现代替代品,对 WebAssembly 提供一等支持。Zig 编译器可以直接以 Wasm 为目标,不需要 Emscripten 之类的外壳。它产出的模块在体积与性能上都能与 Rust 抗衡,而 comptime 特性支持的元编程甚至可以完全消除运行时开销。除非你显式链接,Zig 不会夹带任何隐藏的运行时——没有垃圾回收器,没有分配器,也没有启动代码。

  • Rust 产出的 Wasm 二进制最小(典型模块 10-50 KB),并拥有以 wasm-pack 和 wasm-bindgen 为核心的最佳工具生态。
  • 通过 Emscripten 使用 C 和 C++ 是把现有原生代码库移植到 Wasm 的最佳选择,兼容性最高,但二进制体积更大。
  • Go 很容易编译到 Wasm,但因内置运行时而产出较大的二进制(2 MB 以上),更适合服务端 Wasm。
  • Zig 产出紧凑、没有运行时开销的模块,非常适合最小化与嵌入式的 Wasm 场景。

在浏览器中使用 Wasm

在浏览器中加载并执行 WebAssembly 模块,遵循一套在所有现代浏览器中一致的标准接口。JavaScript 接口在 WebAssembly 命名空间下提供 compile、instantiate 和 instantiateStreaming 三个函数。更推荐流式变体,因为它在字节下载的同时就开始编译,把网络 I/O 与编译重叠起来。

async function loadWasm(url: string) {
  const response = await fetch(url);
  const results = await WebAssembly.instantiateStreaming(response);
  return results.instance.exports;
}

async function main() {
  const wasm = await loadWasm("/fibonacci.wasm");
  console.log(wasm.fibonacci(40));
}

main().catch(console.error);

instantiateStreaming 返回一个对象,其中 instance 属性是编译好的模块实例,exports 对象包含模块中声明为导出的全部函数和内存。你也可以传入第二个参数 imports 对象,把模块在编译时期望的 JavaScript 函数与值提供给它。

在生产中,通常会在构建阶段预先编译模块,或使用打包器插件。Webpack 5 原生支持 Wasm 导入,Vite 的支持尚属实验性。这些打包器会自动处理获取、实例化和生命周期管理,让你像导入 JavaScript 模块一样导入 Wasm 模块。使用 wasm-pack 时,导入写法形如 import init from './pkg/fibonacci.js',它会给你一个基于 Promise 的初始化函数,负责所有胶水代码。

浏览器端的一个关键考量是首次编译。模块在首次加载时会从二进制格式编译为原生代码。小模块只需几毫秒;而大模块——超过 10 MB 的那种——可能需要数百毫秒,并阻塞主线程。解决办法是在 Web Worker 中使用 WebAssembly.compileStreaming:编译在主线程之外完成,并返回一个已编译的模块对象,主线程可以立刻实例化它。对于加载大模块的生产应用,这种做法是必需的。

WASI 与服务端 Wasm

WebAssembly 最初只为浏览器而设计,但它的安全模型与性能特征使它在服务端同样有吸引力。难点在于浏览器提供了特定的宿主接口——DOM、fetch、WebSocket——而服务端环境没有这些。WebAssembly System Interface 解决了这一点:它定义了一套标准的、类 POSIX 的系统调用,供模块在浏览器之外使用。

WASI 为文件 I/O、网络、时钟访问、随机数生成、环境变量和命令行参数提供抽象。以 WASI 支持编译的模块可以读文件、打开网络套接字,并通过一套在任何兼容运行时中都通用的标准接口与操作系统交互。这正是把 Wasm 当作服务端应用运行的基础。

WASI 标准通过若干快照逐步演进。WASI preview 1 是当下多数工具链支持的稳定基线。WASI preview 2 引入了与组件模型相配的设计和基于能力的安全机制,每个模块都要显式声明自己需要哪些系统资源。preview 2 是一次重要的架构进步,因为它让细粒度权限成为可能——一个只需读取某个特定文件的模块,无法触及系统上的任何其他文件。

WASI 把 WebAssembly 从一项浏览器技术,变成一种通用的沙箱化运行时。同一个模块既能在浏览器标签页中运行,也能在服务端的 Wasm 运行时、无服务器函数或边缘节点上运行,而无需改动。当「任何地方」包含了 JavaScript 到不了的环境时,「一次编写,到处运行」便有了新的含义。

Wasm 运行时与部署

在浏览器之外运行 WebAssembly,需要一个服务端的 Wasm 运行时。已有几套成熟方案,各自的设计取向与适用场景不同。最重要的三个是 Wasmtime、Wasmer,以及面向嵌入式系统的 WAMR(WebAssembly Micro Runtime)。

Wasmtime

Wasmtime 是由 Bytecode Alliance 开发的独立运行时,该组织同时推动着 WASI 标准。它以 Rust 编写,使用专为 Wasm 设计的代码生成器 Cranelift 编译模块,并提供 Rust、C、Python 等语言的接口。Wasmtime 是服务端 Wasm 中使用最广的运行时,Cloudflare Workers、Fastly Compute@Edge 以及另外几个边缘计算平台的背后都是它。

use wasmtime::*;

fn main() -> Result<()> {
    let engine = Engine::default();
    let module = Module::from_file(&engine, "hello.wasm")?;
    let mut store = Store::new(&engine, ());
    let instance = Instance::new(&mut store, &module, &[])?;

    let hello = instance
        .get_typed_func::<(), ()>(&mut store, "hello")?;
    hello.call(&mut store, ())?;

    Ok(())
}

Wasmtime 的嵌入接口干净而地道。你创建一个 Engine(编译环境),从文件或字节加载 Module,创建一个 Store(持有 Wasm 线性内存的执行上下文),再实例化模块以访问它导出的函数。内存管理、陷阱处理以及 WASI 系统调用的转发,都由运行时自动完成。

Wasmer

Wasmer 是另一款流行的运行时,支持多种编译后端——Cranelift、LLVM,以及优化最少、编译最快的 Singlepass。Wasmer 为 Rust、C、C++、Python、Go、PHP、Ruby、Java 等提供 SDK,几乎可以从任何编程环境中调用。它还提供一个包注册中心(WAPM)用于分发 Wasm 模块,类似 npm 但面向 Wasm。

两者的关键差别在于:Wasmer 侧重开发体验与易嵌入,Wasmtime 侧重标准符合度与安全性。二者都很出色,选择取决于你更看重生态的广度(Wasmer),还是严格的规范符合度与生产履历(Wasmtime)。

  • Wasmtime:生产环境服务端 Wasm 的首选,严格符合 WASI,使用 Cranelift JIT 编译器,被 Cloudflare 和 Fastly 采用。
  • Wasmer:把 Wasm 嵌入非 Rust 应用的首选,支持 LLVM 编译以获得接近原生的速度,并拥有 WAPM 包生态。
  • WAMR:物联网与嵌入式设备的首选,体积极小,支持解释器与 JIT 两种模式,配有 iwasm 命令行工具。

性能特征:Wasm 何时胜出

WebAssembly 相对 JavaScript 的性能优势取决于负载类型。对于受 CPU 限制、计算密集的操作,Wasm 通常比 JavaScript 快 1.5 到 3 倍。对于图像处理或数据压缩这类内存密集的操作,优势可能更大,因为 Wasm 直接掌控内存布局与分配方式。而对于 I/O 密集或以 DOM 为主的负载,Wasm 没有优势,甚至会因模块与 JavaScript 宿主之间的桥接而增加开销。

编译模型是另一个因素。JavaScript 采用 JIT 编译:代码立即开始执行,热点路径随时间逐步优化。Wasm 则是提前编译或在模块加载时编译,因此一开始就达到峰值性能——没有预热期。对于生命周期短的函数或一次性执行,这消除了 JIT 预热延迟,而这种延迟正是 JavaScript 首次调用较慢的原因。

Wasm 真正出众之处在于可预测性。JavaScript 的 JIT 优化并不确定:同一个函数可能因接收到的类型、被调用的次数以及编译器启发式规则的当前状态而被优化或反优化。Wasm 的执行是确定的。每条指令都有固定代价。没有垃圾回收停顿,没有类型混淆,没有优化退回。因此,当延迟的稳定性比理论峰值吞吐更重要时——音频处理、物理模拟、金融计算——Wasm 是正确的选择。

  • Wasm 胜出的场景:图像与视频处理、音频合成与分析、数据压缩与解压、密码学运算、游戏引擎、物理模拟、数据库查询执行。
  • JavaScript 胜出的场景:DOM 操作、事件处理、网络请求编排、用框架渲染界面、字符串与文本处理、体量小或很少调用的工具函数。
  • 边界正在移动:随着 Wasm 通过 WASI 和组件模型获得更多 Web 接口,更多负载会迁向 Wasm,但 JavaScript 仍是主要的接口层。

组件模型与 Wasm 的未来

WebAssembly 组件模型是自 Wasm 首次发布以来最重要的演进。它针对早期 Wasm 的根本局限:模块彼此孤立,没有标准化的互通方式。组件模型引入了一套高层、与语言无关的接口体系,使 Wasm 模块能够像积木一样组合起来。

在当前模型中,模块导出的函数只接受和返回整数与浮点数。如果一个模块需要把字符串、数组或复杂数据结构传给另一个模块,双方必须就内存布局达成约定——共享同一段线性内存,并协调分配与释放。这既脆弱又依赖具体语言。组件模型通过定义一套标准化的接口类型系统解决了这一点,它能跨模块边界处理字符串、记录、变体、列表以及嵌套数据结构。

接口类型以与语言无关的方式描述组件之间流动的数据。一个组件可以导出接受字符串、返回含两个整数字段之记录的函数;任何其他组件——无论用 Rust、C 还是 Go 编写——都能调用它,而无需知道对方内部的内存布局。运行时会自动生成适配逻辑,在调用方与被调用方的表示之间进行转换。

真实案例

WebAssembly 不是一项停留在理论的技术。它支撑着当今生产环境中一些要求最苛刻的 Web 应用,而且随着工具成熟、性能收益变得无可辩驳,它的普及正在加速。

Figma:Wasm 的先行者

Figma 是最广为人知的 Wasm 成功案例。它的核心渲染引擎用 C++ 编写,经 Emscripten 编译为 Wasm。Figma 把整个 C++ 代码库——包括 Skia 图形库、HarfBuzz 文本排版器以及自研的布局引擎——编译成一个 Wasm 模块。JavaScript 层负责输入事件与 DOM 管理,Wasm 则承担全部渲染、命中测试与布局计算。结果是一个跑在浏览器标签页里、性能足以媲美原生桌面应用的设计工具。

SQLite:浏览器中的数据库

SQLite 大概是世界上部署最广的 Wasm 模块。该项目发布了官方 Wasm 构建,可在浏览器中运行完整的 SQLite 数据库引擎。用户完全在客户端查询 SQLite 数据库,不需要任何服务端组件。这个构建由 Emscripten 工具链产出,并包含虚拟文件系统支持,可把数据库持久化到 IndexedDB。

这带来的影响相当可观。需要客户端查询能力的应用——数据分析工具、报表面板、科学计算笔记本——可以用 Wasm 版 SQLite 直接在浏览器里执行复杂的聚合、连接与窗口函数,而无需把数据送往服务器。sql.js 和 better-sqlite3-wasm 这类库把 SQLite 的 Wasm 模块包装成清爽的 JavaScript 接口,用起来与任何 npm 包无异。

Google 地球:用 Wasm 做三维渲染

浏览器版 Google 地球在三维地形处理与数据解压流程上改用了 Wasm 重建。用户浏览时,Wasm 模块接收压缩的二进制数据,用编译到 Wasm 的优化 C++ 算法解压,把几何数据处理成可渲染的网格,再交给 WebGL——全部在一帧动画的预算之内完成,且没有任何数据复制开销。

  • Figma 用 Wasm 运行其 C++ 渲染引擎,在浏览器中实现了原生级别的矢量图形编辑性能。
  • SQLite 提供官方 Wasm 构建,在客户端运行完整的数据库引擎,无需服务器即可离线查询。
  • Google 地球依靠 Wasm 对流式地理空间数据做实时解压与几何处理,维持每秒 60 帧。
  • Adobe 在 Web 版 Photoshop 中用 Wasm(经 Emscripten 编译的 C++ 代码)处理图像滤镜与色彩空间转换。
  • Zoom 在其 Web 客户端中用 Wasm 做视频解码与背景虚化。

边缘与无服务器场景中的 Wasm

无服务器平台之所以把 WebAssembly 用作边缘计算的运行时,是因为它在冷启动上胜过容器。Wasm 模块的启动以微秒计,而不是毫秒——没有操作系统要引导,没有进程要派生,也没有运行时要初始化。对于冷启动延迟直接影响使用体验的平台来说,这是质的改变。

Cloudflare Workers 是第一个大规模拥抱 Wasm 的平台。Workers 运行在 V8 隔离环境中,既能执行 JavaScript,也能执行 Wasm 模块。把计算逻辑放进 Wasm 模块的 Worker,可以在冷启动后 5 毫秒之内开始响应请求。Worker 环境基于 Service Worker 规范提供一组有限的接口——fetch、Cache、KV 存储和 Durable Objects——带 WASI 支持的 Wasm 模块可通过宿主环境的导入机制使用它们。

Fastly 的 Compute@Edge 以 Wasmtime 为核心运行时,并要求所有计算都编译为 Wasm。开发者用 Rust、Go 或 JavaScript(由 Fastly 预先编译成 Wasm)编写边缘逻辑,产出的模块运行在 Fastly 的边缘节点上。这一模型带来强隔离——每个请求都在全新的 Wasm 实例中执行,没有共享状态——同时具备让边缘计算变得可行的启动速度。

边缘 Wasm 的生态尚年轻,但增长很快。Fermyon Spin、Deislabs 等平台提供了专为构建无服务器 Wasm 应用而设计的框架。它们把 WASI 运行时的细节抽象掉,提供熟悉的模式——HTTP 处理器、键值存储、定时任务——而底层一切都通过 Wasm 执行。其承诺是:用任何能编译到 Wasm 的语言把业务逻辑写一次,就能不加修改地部署到任何兼容 WASI 的平台上。

今天就开始使用 Wasm

从哪里入手取决于你的目标。如果想在浏览器里用 Wasm,最快的路径是 Rust 加 wasm-pack。安装 Rust,添加 wasm32-unknown-unknown 目标,再用 wasm-pack new 创建项目。生成的项目自带一个可运行的示例,其中有一个可从 JavaScript 调用的 Wasm 函数、一个产出 npm 兼容包的构建脚本,以及一套在无界面浏览器中运行模块的测试骨架。

如果想探索服务端 Wasm,先从 Wasmtime 及其命令行工具开始。安装 wasmtime,用 WASI 支持编译任意 C 或 Rust 程序,再用 wasmtime program.wasm 运行它。你会发现,一个编译成 Wasm 的命令行程序表现得就像原生可执行文件——它从标准输入读取、向标准输出写入、访问文件,并以状态码退出。在终端里运行 Wasm 程序的体验出奇地顺滑。

若要在边缘与无服务器场景中实验,Cloudflare Workers 提供支持 Wasm 的免费额度。写一个 Rust 函数,编译成 Wasm,部署到 Cloudflare 的全球网络。你会看到大多数地区的响应时间都在 10 毫秒以内,也会亲身体会到 Wasm 如何让冷启动不再成为顾虑。

上手 Wasm 最务实的第一步,不是重写整个应用。找出一个计算开销大的函数——一个滤镜、一次变换、一处校验——用 Rust 重写它,编译成 Wasm,再集成进现有代码库。测一测差距。当你看到五十行 Wasm 带来三倍提速时,就会明白这项技术为什么重要。

工具生态正在快速改善。wasm-pack、cargo-generate 和 wasm-tools 提供了成熟的开发流程。Binaryen 工具链中的 wasm-opt 可优化模块,把体积再压下 20% 到 30%。Wasmtime 与 Wasmer 仍在持续缩小与原生执行之间的差距。而组件模型很快会让 Wasm 模块以 Wasm 初问世时不可能的方式互相协作。

WebAssembly 不是我们所熟知的 Web 的替代品。它是这个平台的一次充实——一套做 JavaScript 做不到之事的第二运行时。下次当你面对一个性能问题、感觉自己是在与 JavaScript 的动态本性搏斗时,不妨想想代码中计算密集的那部分能否抽成一个 Wasm 模块。工具已可用于生产,性能收益是真实的,而 Wasm 的未来——组件模型、WASI preview 2 以及更广的语言支持——只会越来越好。

Web 平台从设计之初就是可扩展的。WebAssembly 是这个平台十年来最重要的一次扩展。对于想要构建应用、去触碰 Web 能力边界的开发者来说,理解它并非可选项。

See what your own repository can account for.

Thirty minutes on a repository you choose, including the part the record cannot attribute.