Rust + WebAssembly 全链路解析

第19章 生产案例:从图像处理到密码学

作者 杨艺韬 · 19,850 字 · 发布于 · 更新于

"In theory, there is no difference between theory and practice. In practice, there is." — Yogi Berra

前 18 章讨论了 WASM 的规范、工具链、性能优化和可观测性——都是在"抽象"层面。本章用十个案例把这些知识落地:从浏览器里的渲染引擎、图像处理管道、密码学模块,到 TensorFlow.js 的 WASM 后端、边缘计算、区块链合约,再到把桌面级应用搬上 Web 的 AutoCAD 与 VS Code。

先说取数口径:公开可核实的部分(谁在用 WASM、用在架构的哪一层、官方文档写明的结构)照实引用;具体的耗时、体积、倍数除非另有出处,都是本专栏注明了环境的实测或量级示意,不能当作各家公司的官方数据外推。没有出处的数字比没有数字更危险——它会让读者以为自己拿到了可以直接照搬的基线。

前三个案例分别代表了 WASM 在浏览器中的三种典型定位:渲染引擎是"计算引擎"定位——编辑器内核与渲染在 WASM 里,部分界面和浏览器图形 API 的调用经由 JS;图像处理管道是"性能热点替换"定位——逐个函数尝试用 WASM 替换 JS 热点,并用实测决定留不留;密码学模块是"安全隔离区"定位——用 WASM 的线性内存隔离敏感数据。这三种定位覆盖了 WASM 在浏览器里的绝大多数用法;后七个案例则把视野扩展到边缘、链上和桌面级应用的 Web 化。

19.1 案例一:Figma 的渲染引擎

Figma 是最有名的 WASM 生产应用之一。它的官方工程博客把这条技术路线交代得很清楚(以下链接均于 2026-09-24 核对):

  • 2015-12-07,联合创始人 Evan Wallace 在 Building a professional design tool on the web 里写道:"Our editor is written in C++ and cross-compiled to JavaScript using the emscripten cross-compiler"——当时的编译目标还是 asm.js。渲染器是自研的、基于 tile 的引擎,"All rendering is done on the GPU",走 WebGL;整个编辑器内部"looks a lot like a browser inside a browser":有自己的 DOM、自己的合成器、自己的文字排版引擎。
  • 2017-06-08,Figma is powered by WebAssembly 宣布同一份 C++ 代码改编译到 WASM,"our load time improved by more than 3x after we switched to WebAssembly regardless of document size"。
  • 2019-08-22,How to build a plugin system on the web and also sleep well at night 描述:"Our design editor is powered by WebGL and WebAssembly, with some of the user interface implemented in Typescript & React."
  • 2025-09-18,Figma rendering: Powered by WebGPU 写明:"Our renderer is written in C++. We compile this C++ code to WebAssembly (Wasm) using Emscripten." 同一份 C++ 还编译成原生 x64/arm64 程序,用于服务器端渲染、测试和调试;浏览器里改用 WebGPU 时用的是 Emscripten 内置的 WebGPU 绑定——"C++ WebGPU calls ultimately end up using the WebGPU browser API in JavaScript"——并在会话中遇到 WebGPU 问题时动态回退到 WebGL。

先划清一条界线:Figma 的渲染器至今是 C++,官方从未公布"把渲染引擎迁到 Rust"的计划或数据。Figma 确实在生产中用 Rust,但用在服务器端——Rust in production at Figma 讲的是把多人协作服务器里性能敏感的部分改写成 Rust 子进程,2025 年的 Supporting faster file load times with memory optimizations in Rust 讲的同样是服务器端。本节后半部分"如果这样的引擎换成 Rust"是本专栏借这个架构做的推演,不是 Figma 的决策。

架构

graph TD
  subgraph "JS 层"
    A["部分界面<br/>TypeScript + React"]
  end
  subgraph "WASM(C++ 经 Emscripten 编译)"
    D["编辑器内核<br/>自有 DOM / 合成器 / 文字排版"] --> E["tile 渲染器"]
    E --> F["图形接口抽象层"]
  end
  subgraph "浏览器图形 API"
    G["WebGPU"]
    H["WebGL(回退)"]
  end
  A <-->|交互与状态| D
  F -->|Emscripten 绑定,经 JS 调用| G
  F -.->|会话中出错时回退| H
  style D fill:#6366f1,color:#fff
  style G fill:#10b981,color:#333

这张图里每一层都来自上面列出的官方博客:C++ 编辑器内核与渲染器整体编译成 WASM,部分界面用 TypeScript + React;渲染器发出的图形调用经 Emscripten 的绑定落到浏览器的 JS 图形 API 上——WASM 本身不能直接调用 WebGL/WebGPU,每一次图形调用都要跨一次 WASM→JS 边界。

这正是 2025 年那篇 WebGPU 博客里接口改造的着眼点:Figma 把图形接口改成显式传参(context->draw(vertexBuffer, framebuffer, {texture}, material, …)),并把"所有已编码 draw call 的 uniform 数据一次性上传到同一个 buffer",而不是像 WebGL 那样逐个设置 uniform。把许多小调用攒成一次批量提交,是 WASM 渲染器减少边界开销的通用手法——下面的命令缓冲区示例就是这个思路。

延伸:如果把这样的 C++ 引擎换成 Rust

以下是推演,不对应任何公司的公开决策。一个 C++/WASM 团队考虑换 Rust 时,真正能拿到的收益集中在两处。

内存安全。要先纠正一个常见误解:C++ 在 WASM 里写越了自己数组的边界,通常不会 trap。WASM 的边界检查只管"访问是否落在整块线性内存之内",而 C++ 的堆、栈、全局数据都在这同一块线性内存里——越界写只会悄悄改掉相邻的数据,与原生程序的内存破坏一样难查;只有访问越过线性内存的当前大小才会 trap(连空指针解引用都不会 trap——地址 0 在线性内存里是合法地址)。Rust 的做法是在运行时对切片与数组下标做边界检查,越界就 panic(在 WASM 里表现为 unreachable trap,第 18 章讲过如何用 panic::set_hook 抢救出 panic 信息),再加上借用检查在编译期排除悬垂指针与数据竞争——代价是一次检查,换来的是"错误在第一现场暴露"而不是"几帧之后在别处崩"。

静态分发。两种语言编译到的是同一套 WASM 指令集,差异不来自"语言快慢",而来自各自更自然的写法。下表只给方向性判断:

操作 通常哪边占优 原因
场景图遍历 Rust 略优 enum + match 是静态分发,编译器能把各分支内联进调用点;虚函数则因为不知道目标而丧失这些优化机会
GPU 命令编码 C++ 略优 成熟 C++ 代码里往往已手写 SIMD intrinsics,这条链路的调优时间也更长
增量渲染 Rust 略优 所有权模型让"不必要的深拷贝"在编译期就暴露出来
完整渲染 大致持平 语言不是决定因素,不足以单独构成迁移理由

这张表最该记住的是最后一行:语言之间的差距远小于"算法与数据布局"带来的差距,指望换语言拿到数量级收益基本不现实。

真正值得展开的是场景图遍历。一个成熟设计工具的场景图节点往往有二十多种类型(矩形、椭圆、文本、容器...)。C++ 用虚函数做动态分发——每次 node->render() 要先取对象头里的 vtable 指针、再从表里取函数指针,然后间接跳转。但这两次解引用本身不是主要成本,主要成本是编译器不知道会跳到哪里,因而无法内联——一旦不能内联,常量折叠、死代码消除、向量化这些后续优化机会全部丧失(这个口径与《Rust 编译器与运行时揭秘》专栏第 8 章一致,分支预测失败只是次要因素)。Rust 用 enum Node { Rect(...), Ellipse(...), Text(...), ... } + match 做静态分发时,每个分支的函数体在编译期就是确定的,可以直接内联进遍历循环。当然 C++ 也能用 std::variant + std::visit 写出同样的静态分发,只是在 Rust 里这是默认写法。

// Rust 的 enum + match 模式——场景图遍历的天然表达
enum SceneNode {
    Rect { x: f32, y: f32, w: f32, h: f32, fill: Color },
    Ellipse { cx: f32, cy: f32, rx: f32, ry: f32, fill: Color },
    Text { content: String, font: FontId, size: f32 },
    Container { children: Vec<NodeId>, clip: bool },
    // ... 更多类型
}

fn render_node(scene: &[SceneNode], node: &SceneNode, cmds: &mut CommandBuffer) {
    match node {
        SceneNode::Rect { x, y, w, h, fill } => {
            cmds.push_draw_rect(*x, *y, *w, *h, fill);
        }
        SceneNode::Ellipse { cx, cy, rx, ry, fill } => {
            cmds.push_draw_ellipse(*cx, *cy, *rx, *ry, fill);
        }
        SceneNode::Text { content, font, size } => {
            cmds.push_draw_text(content, *font, *size);
        }
        SceneNode::Container { children, clip } => {
            if *clip { cmds.push_clip(); }
            for &child in children {
                render_node(scene, &scene[child as usize], cmds); // 递归遍历
            }
            if *clip { cmds.pop_clip(); }
        }
    }
}

至于界面层,Figma 的选择本身就是答案:界面用 TypeScript + React,WASM 承担编辑器内核与渲染。这与第 16 章的判断一致——"计算引擎"是 WASM 在浏览器里最常见、最成熟的定位。换语言也不会改变这条分工,更稳妥的做法是逐模块替换、每一步都以"性能不退化 + 像素级输出一致"为回滚判据。

共享命令缓冲区的实现

把多次图形调用攒进线性内存里的一块缓冲区,一次交给 JS 侧执行,是减少 WASM→JS 边界次数的常用做法(本专栏的示意实现,不是 Figma 的代码):

/// GPU 命令缓冲区——预分配在线性内存中
pub struct CommandBuffer {
    data: Vec<u8>,
    write_offset: usize,
}

impl CommandBuffer {
    pub fn new(capacity: usize) -> Self {
        CommandBuffer {
            data: vec![0u8; capacity],
            write_offset: 0,
        }
    }

    pub fn push_draw_rect(&mut self, x: f32, y: f32, w: f32, h: f32, fill: &Color) {
        // 按固定布局直接写入线性内存——JS 侧按同一份布局从偏移量读取。
        // 这里不能用 `transmute` 之类的手段把 Rust 结构体整块拍进去:
        // 布局要由两侧共同约定,而不是由编译器决定。
        self.push_bytes(&CMD_RECT.to_le_bytes());
        for v in [x, y, w, h] {
            self.push_bytes(&v.to_le_bytes());
        }
        self.push_bytes(&fill.to_rgba8());
    }

    fn push_bytes(&mut self, bytes: &[u8]) {
        let end = self.write_offset + bytes.len();
        self.data[self.write_offset..end].copy_from_slice(bytes);
        self.write_offset = end;
    }

    /// 返回缓冲区的指针和长度——JS 侧用这个指针读取命令
    pub fn as_ptr_and_len(&self) -> (*const u8, usize) {
        (self.data.as_ptr(), self.write_offset)
    }
}

JS 侧通过 memory.buffer 上的视图直接读取命令缓冲区——不经过 wasm-bindgen 的复制。这是第 11 章讨论的"共享缓冲区"策略:一帧只跨一次边界,而不是每个 draw call 跨一次。注意两点:视图要在每次调用后重建(memory.grow 会让旧视图 detach);单线程下 WASM 和 JS 是轮流执行的,"同时读写"只有在 SharedArrayBuffer + Worker 的多线程配置下才会发生,那时就需要第 11.7 节讲的 Atomics 同步。

19.2 案例二:图像处理管道的四步优化

电商类站点常常要在浏览器里实时处理商品图片——缩放、裁剪、水印、格式转换。这一节取其中最简单的一环灰度转换,按"JS 计算 → WASM 计算"的常见优化路径走四步,每一步都实测。

数据口径:本节数字全部是本专栏的实测,环境与第 11 章相同——Rust 1.96.0 --release(opt-level = 3)、wasm-bindgen 0.2.118 CLI 生成的 --target nodejs 胶水、node v24.20.0,Intel Xeon Gold 6240(KVM 虚拟机);图像是 4K(3840×2160 RGBA,33,177,600 字节)随机像素,每项预热 3 次后取 12 次的中位数。Node 与 Chrome 用的是同一个 V8,但这不是浏览器实测,其他引擎、其他 CPU 上的绝对值与相对关系都可能不同——请在自己的目标环境上重测。

问题

原来的方案是 JS Canvas API:

// JS 方案:用 Canvas API 做图像处理
function grayscale(ctx, width, height) {
    const imageData = ctx.getImageData(0, 0, width, height);
    const data = imageData.data;
    for (let i = 0; i < data.length; i += 4) {
        const gray = data[i] * 0.299 + data[i+1] * 0.587 + data[i+2] * 0.114;
        data[i] = data[i+1] = data[i+2] = gray;
    }
    ctx.putImageData(imageData, 0, 0);
}

只计循环本身(getImageData/putImageData 不计),这段代码在上述环境里处理一张 4K 图约 35ms,首次调用约 38ms。这个数字先要记住:data 是 Uint8ClampedArray,元素类型固定,V8 的优化编译器能为这个循环生成紧凑的机器码,类型守卫的开销远没有"动态类型很慢"这类直觉以为的那么大。

第一步:WASM 标量实现

#[wasm_bindgen]
pub fn grayscale_scalar(data: &mut [u8]) {
    for pixel in data.chunks_exact_mut(4) {
        let gray = (pixel[0] as f32 * 0.299
                  + pixel[1] as f32 * 0.587
                  + pixel[2] as f32 * 0.114) as u8;
        pixel[0] = gray;
        pixel[1] = gray;
        pixel[2] = gray;
        // pixel[3] 是 alpha,不变
    }
}

JS 侧的调用(--target web 产物):

import init, { grayscale_scalar } from './pkg/image_processor.js';

async function process(imageData) {
    await init();
    // 传 Uint8Array 视图;胶水会把结果写回这块内存
    grayscale_scalar(new Uint8Array(imageData.data.buffer));
}

实测:约 55ms,首次调用约 108ms——比 JS 还慢。慢在两处:一是 wasm-bindgen 对 &mut [u8] 参数的处理是"__wbindgen_malloc 一块线性内存 → 把 33MB 复制进去 → 调用 → 再复制回原数组"(0.2.118 生成的胶水里就是 passArray8ToWasm0 加 __wbindgen_copy_to_typed_array),首次调用还要为这 33MB 做 memory.grow;二是灰度计算本身在 WASM 里并不比 V8 优化后的 JS 快(见第三步的纯计算耗时)。还有一个容易忽略的语义差:Uint8ClampedArray 赋值时就近取整,Rust 的 as u8 是截断,两边的输出会有 1 的偏差——做一致性回归时要先统一取整方式。

第二步:f32 替代 f64

常见的建议是"JS 的 number 是 64 位浮点,WASM 里改用 f32 会更快"。上面的 grayscale_scalar 已经全程是 f32;为了验证这条建议,另写一个把 f32 全部换成 f64 的版本对照,同时把权重提成常量:

const R_WEIGHT: f32 = 0.299;
const G_WEIGHT: f32 = 0.587;
const B_WEIGHT: f32 = 0.114;

#[wasm_bindgen]
pub fn grayscale_f32(data: &mut [u8]) {
    for pixel in data.chunks_exact_mut(4) {
        let gray = (pixel[0] as f32 * R_WEIGHT
                  + pixel[1] as f32 * G_WEIGHT
                  + pixel[2] as f32 * B_WEIGHT) as u8;
        pixel[0] = gray;
        pixel[1] = gray;
        pixel[2] = gray;
    }
}

结果:f64 版约 51ms,grayscale_f32 约 55ms,与第一步没有可分辨的差别。把字面量提成 const 不改变生成的代码——用 wasm-opt --print 看产物,grayscale_scalar 与 grayscale_f32 两个导出指向的是同一个函数,链接时已被合并;在 x86-64 上 f32 与 f64 的乘加同样是单条指令,这个负载里也没有需要压缩内存带宽的浮点数组——f32 在这里不是优化。它在"大块浮点数据要来回搬"或 SIMD 一次能多装一倍 lane 的场景才有意义。

第三步:数据留在线性内存里

第一步的复制是 wasm-bindgen 对 &mut [u8] 的固定行为——无论传进来的是 JS 数组还是指向线性内存的视图,都会先 malloc 一块新内存再复制(把线性内存的视图传进去还有额外风险:malloc 触发的 memory.grow 会让这个视图 detach)。想省掉复制,要让数据本来就住在 WASM 侧、由 WASM 侧的方法直接处理:

#[wasm_bindgen]
pub struct Processor {
    width: u32,
    height: u32,
    buffer: Vec<u8>,
}

#[wasm_bindgen]
impl Processor {
    // 没有 constructor 属性时,wasm-bindgen 会把 new 导出成静态方法
    // Processor.new(...),而不是 JS 的 `new Processor(...)`
    #[wasm_bindgen(constructor)]
    pub fn new(width: u32, height: u32) -> Self {
        let buffer = vec![0u8; (width * height * 4) as usize];
        Processor { width, height, buffer }
    }

    /// 返回 buffer 在线性内存中的起始地址——JS 侧据此构造视图,零复制
    pub fn data_ptr(&mut self) -> *mut u8 {
        self.buffer.as_mut_ptr()
    }

    pub fn buffer_len(&self) -> usize {
        self.buffer.len()
    }

    /// 就地处理自己的 buffer——参数里没有切片,胶水不会复制任何数据
    pub fn grayscale(&mut self) {
        grayscale_f32(&mut self.buffer);
    }
}
import init, { Processor } from './pkg/image_processor.js';

const { memory } = await init();              // init() 的返回值里有导出的 memory
const processor = new Processor(width, height);
// 用 ptr + len 在 WASM 线性内存上开一个视图——视图本身不复制数据
let view = new Uint8Array(memory.buffer, processor.data_ptr(), processor.buffer_len());
view.set(imageData.data);                     // 一次复制:JS → WASM
processor.grayscale();                        // 不触发复制
view = new Uint8Array(memory.buffer, processor.data_ptr(), processor.buffer_len());
imageData.data.set(view);                     // 一次复制:WASM → JS

这里有个必须记住的陷阱:memory.buffer 一旦因为 memory.grow 换了底层 ArrayBuffer,之前构造的视图就会 detach 成长度为 0 的视图。所以视图不能长期缓存——每次可能触发增长的调用之后都要重新构造(上面在 grayscale() 之后重建了一次)。

结果:processor.grayscale() 本身约 44ms,33MB 的一次 set 约 5ms。和第 11 章 §11.3.4 的结论一样:复制确实省掉了一部分,但计算才是大头,而这个计算本身仍比 JS 那 35ms 慢。

第四步:WASM SIMD

WASM SIMD 一次处理 16 个字节(128 位寄存器)。灰度转换的 SIMD 实现需要处理 RGB 解交织——这是最难的部分,因为 RGBA 的内存布局是 [R0,G0,B0,A0,R1,G1,B1,A1,...],而 SIMD 需要分别处理 R、G、B 通道。

#[cfg(target_feature = "simd128")]
use core::arch::wasm32::*;

// 必须用 RUSTFLAGS="-C target-feature=+simd128" 构建,否则这个函数不会被编译
#[cfg(target_feature = "simd128")]
#[wasm_bindgen]
pub fn grayscale_simd(data: &mut [u8]) {
    let simd_len = data.len() / 16 * 16; // 一次 16 字节 = 4 个像素

    for chunk_start in (0..simd_len).step_by(16) {
        // 一次加载 16 字节(R0G0B0A0 R1G1B1A1 R2G2B2A2 R3G3B3A3)
        let pixels = unsafe { v128_load(data[chunk_start..].as_ptr() as *const v128) };

        // u8 → u16:低 8 个通道是像素 0/1,高 8 个通道是像素 2/3
        let lo16 = i16x8_extend_low_u8x16(pixels);
        let hi16 = i16x8_extend_high_u8x16(pixels);

        // u16 → u32 → f32:每个 f32x4 恰好是一个像素的 R/G/B/A 四个通道
        let px = [
            f32x4_convert_u32x4(i32x4_extend_low_u16x8(lo16)),
            f32x4_convert_u32x4(i32x4_extend_high_u16x8(lo16)),
            f32x4_convert_u32x4(i32x4_extend_low_u16x8(hi16)),
            f32x4_convert_u32x4(i32x4_extend_high_u16x8(hi16)),
        ];
        // 权重向量:alpha 通道权重为 0,乘完自然被排除
        let w = f32x4(0.299, 0.587, 0.114, 0.0);

        for (i, &p) in px.iter().enumerate() {
            let prod = f32x4_mul(p, w);
            // 横向求和:R*0.299 + G*0.587 + B*0.114
            let gray = (f32x4_extract_lane::<0>(prod)
                      + f32x4_extract_lane::<1>(prod)
                      + f32x4_extract_lane::<2>(prod)) as u8;
            let base = chunk_start + i * 4;
            data[base] = gray;
            data[base + 1] = gray;
            data[base + 2] = gray;
            // data[base + 3] 是 alpha,不动
        }
    }

    // 处理尾部不足 16 字节的部分——退回标量实现
    for pixel in data[simd_len..].chunks_exact_mut(4) {
        let gray = (pixel[0] as f32 * 0.299
                  + pixel[1] as f32 * 0.587
                  + pixel[2] as f32 * 0.114) as u8;
        pixel[0] = gray;
        pixel[1] = gray;
        pixel[2] = gray;
    }
}

这里用的都是 core::arch::wasm32 里真实存在的 intrinsic——它们的定义在 rustc 自带的 stdarch 里,即 library/stdarch/crates/core_arch/src/wasm32/simd128.rs(v128_load 在 163 行、f32x4 构造器在 793 行、i16x8_extend_low_u8x16 在 2704 行、i32x4_extend_low_u16x8 在 3160 行、f32x4_convert_u32x4 在 4158 行)。这一组"逐级加宽"函数的命名规律是目标 lane 类型在前、源 lane 类型在后(i32x4_extend_low_u16x8 = 把 u16x8 的低 4 个 lane 零扩展成 i32x4),写错方向是这类代码最常见的编译错误。这些函数都带 #[target_feature(enable = "simd128")],所以整个 crate 要开 simd128 构建;v128_load 是 1 字节对齐的加载,指针不需要 16 字节对齐。

上面这版为了可读性用 f32x4_extract_lane 做横向求和,等于每个像素退回一次标量——真正的产线实现会把 4 个像素的同名通道 shuffle 到一起(R0R1R2R3 / G0G1G2G3 / B0B1B2B3),一次算完 4 个灰度值再交织回 RGBA。那段解交织 + 交织的 shuffle 代码才是 SIMD 灰度转换里最难写、也最容易写错的部分。

实测:把这版放进 Processor 就地调用,约 39.5ms,比第三步的标量版只快一成左右,仍比 JS 慢——横向求和把并行度吃掉了,"用了 SIMD intrinsic"不等于"向量化了"。

优化路径总览

graph LR
  A["JS 循环<br/>约 35ms"] --> B["WASM 标量<br/>经 wasm-bindgen 复制<br/>约 55ms"]
  B --> C["f32 / f64 互换<br/>无可分辨差别"]
  C --> D["数据留在线性内存<br/>计算约 44ms<br/>单向复制约 5ms"]
  D --> E["SIMD(逐像素横向求和)<br/>约 39.5ms"]
  style A fill:#10b981,color:#333

这一组实测的结论与"四步逐级提速"的直觉正好相反:在这个每字节只做几次乘加的负载里,V8 优化后的 JS 已经很快,WASM 标量不占优,复制开销是真实存在但不是主因,而写得不对的 SIMD 几乎没有收益。

这个案例的教训

教训一:先测量,再迁移。"WASM 一定比 JS 快"是本节最该打破的直觉。对 TypedArray 上的简单逐元素循环,JIT 已经把类型守卫优化得很便宜;WASM 的优势出现在更重的计算上——复杂的数值内核、需要 64 位整数运算的算法、可以复用的成熟 C/C++/Rust 库(§19.4 的 TF.js 用 XNNPACK 就是这一类)。先用基准证明热点确实在计算、确实能从 WASM 得益,再动手。

教训二:先标量再 SIMD,并验证 SIMD 真的向量化了。SIMD 代码的复杂度是标量的好几倍——先确保标量版本正确,再用 SIMD 版本替换并验证结果一致性,最后用 benchmark 确认加速。上面这版 SIMD 的教训是:只要循环体里还有逐 lane 提取、逐像素写回,吞吐就被卡在标量水平。

教训三:数据传递要算进总账。wasm-bindgen 对切片参数的复制是固定开销,不随计算变快而变小——计算越快,它的占比越高。数据在 JS 与 WASM 之间来回搬的场景(每帧处理一张图),要么让数据常驻线性内存(第三步的 Processor 模式),要么把整条管道(解码 → 处理 → 编码)都放进 WASM,只在两端各跨一次边界。

教训四:渐进式迁移。先用 WASM 替换一个函数,确认收益后再扩大范围(裁剪、水印、格式转换),每步都配独立的性能测试和输出一致性回归。本节的实测恰好说明了这样做的价值:如果一开始就把整条管道重写成 WASM,就无从知道"慢"是出在哪一步。

19.3 案例三:密码学模块(以 1Password 扩展为原型)

1Password 的官方博客公开过它在浏览器扩展里用 Rust + WASM 的事实(2026-09-24 核对):

  • 2019-12-04 的 1Password X 1.17 发布说明:"we've completely rewritten the brain in Rust and WebAssembly"——这里的 brain 是分析网页、决定自动填充什么的"填充引擎";同时扩展里的 Markdown 解析与 TOTP 生成也改由 Rust 库实现。
  • 2023-08-28 的 passkey 库开源说明:"We use Rust at the core of all our client applications, including the browser extension through WASM",扩展的 TypeScript 把请求交给"the Rust core compiled to WASM",并且"We already bundle ring and other RustCrypto libraries"。

也就是说,公开可核实的是"扩展里跑着编译成 WASM 的 Rust 核心,其中打包了 ring 与 RustCrypto 系列库"。1Password 没有公开扩展里密码学模块的内部结构、哪些算法走 WASM、哪些走 Web Crypto,也没有公布相应基准。所以本节下面的架构、句柄设计与混合策略,是本专栏以这类产品为原型给出的参考设计,不是 1Password 的实现描述;这是 WASM 在安全敏感场景的典型用法:性能只是其中一个考量,更重要的是安全属性。

为什么不用 JS 做密码学

原因一:侧信道风险。JS 的 GC 可能在内存中保留密钥副本——即使代码已经 delete 了变量,GC 可能还没回收。攻击者通过内存转储可能获取到密钥材料。Rust 侧则可以做到确定性清零——但要注意,drop 本身不会清零内存,靠的是在 Drop 里显式调用 zeroize(下文措施一),而且 Vec 扩容时留在旧分配里的副本也要提前用 with_capacity 避免。确定性(deterministic)是关键词——在密码学中,"可能清零"和"一定清零"有本质区别。JS 的 delete 操作只是解除变量名与值的绑定,不保证内存被清零——GC 可能在未来的某个不确定时刻才回收这块内存。

原因二:性能。PBKDF2(Password-Based Key Derivation Function 2)需要数万次 SHA-256 迭代——这是故意慢的,为了增加暴力破解的成本。但"故意慢"是对攻击者而言的——对合法用户,一次解锁的等待要控制在可接受的范围内。纯 JS 手写的 PBKDF2 循环通常明显慢于 WASM 实现;不过浏览器的 Web Crypto 本身就提供 PBKDF2(crypto.subtle.deriveBits),原生实现往往比 WASM 更快。所以 WASM 在这里的价值主要不是"比 JS 快",而是让扩展与桌面端、移动端用同一份 Rust 实现(原因三),以及覆盖 Web Crypto 不提供的算法。(具体快慢取决于所用的库和引擎版本,这里不给固定数字。)

原因三:算法一致性。同一个 Rust 密码学库(如 ring 或 RustCrypto)可以编译到浏览器(WASM)和服务器(原生),保证两端使用完全相同的算法实现——不需要担心 JS 库和 Rust 库的细微差异。密码学实现最怕"同一种算法的两种实现有微妙差异"——这种差异可能导致"能加密不能解密"的灾难,或者更隐蔽的"解密结果偶尔有一个 bit 翻转"。

架构

flowchart TD
  subgraph "JS 扩展逻辑层"
    A[用户输入主密码] --> B[扩展 popup 脚本]
    B --> C[background service worker]
  end
  subgraph "WASM 密码学模块"
    D[SHA-256/512 哈希] --> E[HKDF / PBKDF2 密钥派生]
    E --> F[AES-256-GCM 加密]
    F --> G[ChaCha20-Poly1305 加密]
  end
  subgraph "Web Crypto API 浏览器原生"
    H[AES-GCM 硬件加速<br/>AES-NI]
  end
  C -->|Uint8Array 密钥句柄| D
  D --> E
  E --> F
  E --> G
  I{"AES-GCM?"} -->|"使用 Web Crypto<br/>硬件加速更快"| H
  I -->|"ChaCha20 / 其他"| G
  style D fill:#6366f1,color:#fff
  style H fill:#10b981,color:#333

关键设计:AES-GCM 走 Web Crypto API,其他走 WASM——混合策略。原因见下文的性能对比。

安全措施详解

措施一:密钥材料零化。所有临时密钥缓冲区在 drop 时用 zeroize crate 清零——确保密钥不会残留在内存中。zeroize 的核心是用 ptr::write_volatile 写入零——这防止编译器把"清零操作"优化掉(编译器可能认为"这块内存已经不用了,清零是死代码")。在 WASM 中,volatile write 不会被 LLVM 优化掉——这是确定性清零的保证:

use zeroize::{Zeroize, Zeroizing};

/// 安全密钥容器——drop 时确定性清零
struct SecureKey {
    data: Vec<u8>,
    algorithm: Algorithm,
}

impl Drop for SecureKey {
    fn drop(&mut self) {
        self.data.zeroize(); // volatile write — 编译器不会优化掉
    }
}

/// 密钥派生函数——临时材料在作用域结束时自动清零
fn derive_key(password: &[u8], salt: &[u8]) -> SecureKey {
    // 临时材料用 Zeroizing 包一层——普通 Vec<u8> 被 drop 时并不会清零
    let tmp = Zeroizing::new(pbkdf2_hmac_sha256(password, salt, 100_000));
    let key = hkdf_expand(&tmp, b"encryption-key", 32);
    // tmp 在这里 drop——Zeroizing 的 Drop 先清零再释放
    // key 返回给调用者——由调用者决定何时 drop
    SecureKey { data: key, algorithm: Algorithm::Aes256Gcm }
}

措施二:避免 JS 侧泄漏。密钥材料只在 WASM 线性内存中存在——不通过 wasm-bindgen 传给 JS。JS 只传递密钥的句柄(i32 索引),WASM 内部持有密钥的实际数据。这种"句柄模式"在第 20 章会作为 API 设计原则详细讨论——这里先看它的安全含义:

use std::collections::HashMap;

/// 密钥存储——密钥数据只存在于 WASM 内存中
#[wasm_bindgen]
pub struct KeyStore {
    keys: HashMap<u32, SecureKey>,
    next_id: u32,
}

#[wasm_bindgen]
impl KeyStore {
    // 没有 constructor 属性,JS 侧就不能 `new KeyStore()`
    #[wasm_bindgen(constructor)]
    pub fn new() -> KeyStore {
        KeyStore { keys: HashMap::new(), next_id: 0 }
    }

    /// 派生密钥——返回句柄而非密钥数据
    pub fn derive_key(&mut self, password: &[u8], salt: &[u8]) -> u32 {
        let id = self.next_id;
        self.next_id += 1;
        let key = derive_key_inner(password, salt);
        self.keys.insert(id, key);
        id // JS 拿到的只是一个 u32 索引
    }

    /// 用密钥加密——传入句柄而非密钥数据
    pub fn encrypt(&self, key_handle: u32, plaintext: &[u8]) -> Result<Vec<u8>, JsValue> {
        let key = self.keys.get(&key_handle)
            .ok_or_else(|| JsValue::from_str("invalid key handle"))?;
        encrypt_inner(&key.data, plaintext)
            .map_err(|e| JsValue::from_str(&e.to_string()))
    }

    /// 销毁密钥——从 WASM 内存中删除并清零
    pub fn destroy_key(&mut self, key_handle: u32) {
        // SecureKey 的 Drop impl 会调用 zeroize
        self.keys.remove(&key_handle);
    }
}

句柄模式保证的是:密钥不会因为 API 设计而被复制进 JS 堆,JS 侧的日志、序列化、意外泄漏拿到的只是一个整数索引。它的边界也要说清楚:同一个 JS 环境里的代码仍然能通过导出的 memory 读到整块线性内存,也能拿着句柄调用 encrypt 把 KeyStore 当加密预言机用——所以它缩小的是意外泄漏面,并不能抵御已经拿到页面执行权的 XSS。另外,derive_key 的 password 参数是胶水 malloc 进线性内存的副本,调用结束后被释放但不会被清零;要连这份副本一起清掉,得自己在线性内存里分配缓冲区、用完后在 Rust 侧 zeroize。

措施三:常量时间实现。ring 和 RustCrypto 的密码学原语使用常量时间实现——避免时序侧信道攻击。所谓"常量时间"是指操作时间不依赖于输入数据——攻击者无法通过测量执行时间推断密钥内容。但需要注意:常量时间是源码与编译器层面的写法约束,WASM 模块之后还要被浏览器引擎再编译一次成机器码,WebAssembly 规范并不承诺保持这种性质;执行仍然受 CPU 缓存等微架构因素影响。所以 WASM 里的常量时间实现是"尽力而为",比 JS 里手写(数值表示会随 JIT 的类型反馈变化)更可控,但不能与经过审计的原生实现等同。

性能对比与混合策略

下表给的是方向,不是可移植的基准——决定每一行的是"浏览器有没有原生实现、有没有硬件指令支持",而不是"WASM 还是 JS":

操作 更优选择 原因
SHA-256 看数据量 Web Crypto 的 digest 是原生实现,大块数据通常更快;大量小消息时它每次都要走一次 Promise,WASM 同步调用反而省事
PBKDF2(十万次量级迭代) Web Crypto 或 WASM Web Crypto 的 deriveBits 是原生实现;选 WASM 通常是为了与其他平台共用同一份实现
AES-256-GCM Web Crypto 浏览器底层走 AES-NI 硬件指令,WASM 目前拿不到这条路径
ChaCha20-Poly1305 WASM Web Crypto 标准算法集里没有 ChaCha20,只能自己实现

AES-GCM 走 Web Crypto 更快的原因:Web Crypto API 底层调用浏览器的原生 AES-NI 硬件加速——这是 CPU 指令级的加速,而 WASM 目前无法直接使用 AES-NI(WASM 的 SIMD 提案里不含 AES 指令)。因此采用混合策略:

class CryptoEngine {
  constructor(wasmModule) {
    this.wasm = wasmModule;
    this.keyStore = new wasmModule.KeyStore();
  }

  async encrypt(keyHandle, plaintext) {
    // getKeyInfo / exportKeyOnce 是示意接口,上面的 Rust 片段没有列出
    const key = this.keyStore.getKeyInfo(keyHandle);

    if (key.algorithm === 'AES-256-GCM') {
      // AES-GCM: 使用 Web Crypto API(硬件加速)
      const cryptoKey = await crypto.subtle.importKey(
        'raw', key.rawKey, { name: 'AES-GCM' }, false, ['encrypt']
      );
      const iv = crypto.getRandomValues(new Uint8Array(12));
      const encrypted = await crypto.subtle.encrypt(
        { name: 'AES-GCM', iv }, cryptoKey, plaintext
      );
      return { iv, ciphertext: new Uint8Array(encrypted) };
    } else {
      // ChaCha20 / 其他: 使用 WASM
      return this.keyStore.encrypt(keyHandle, plaintext);
    }
  }
}

混合策略的安全权衡:AES-GCM 的密钥材料需要传给 crypto.subtle.importKey——这意味着密钥会短暂出现在 JS 堆中。参考设计的应对是"在密钥传输到 Web Crypto 后立即清零 JS 侧的副本":

// 安全地传输密钥到 Web Crypto
const rawKey = this.keyStore.exportKeyOnce(keyHandle); // 一次性导出
try {
  const cryptoKey = await crypto.subtle.importKey(...);
  // ... 加密操作 ...
} finally {
  rawKey.fill(0); // 立即清零 JS 侧的密钥副本
  // 注意:JS 的 GC 可能已经复制了这块内存
  // 但这是在不支持 AES-NI 的 WASM 和 JS 侧短暂暴露之间的最佳权衡
}

这个权衡反映了一个更深层的原则:安全不是绝对的——而是风险和收益的平衡。硬件加速对用户体验有实际提升,而密钥在 JS 堆中短暂暴露的风险可以通过"立即清零 + 最小化暴露时间"降到可接受的水平。要注意的是,这个权衡成立的前提是"暴露窗口足够短"——如果 JS 侧还要拿着密钥做别的事,结论就反过来了。

19.4 案例四:TensorFlow.js 的 WASM 后端

TensorFlow.js 是浏览器端的机器学习框架,2020 年 3 月新增了 WASM 后端(Introducing the WebAssembly backend for TensorFlow.js,2020-03-11),同年 9 月又为它加上了 SIMD 与多线程(Supercharging the TensorFlow.js WebAssembly backend with SIMD and multi-threading,2020-09-02;两文均于 2026-09-24 核对)。这个案例展示 WASM 在数值计算领域的真实收益与边界。

架构

TensorFlow.js 在浏览器里有四个执行后端:CPU(纯 JS)、WebGL、WebGPU、WASM。每个后端包在注册时声明一个优先级,运行时在已引入、且能初始化成功的后端里选优先级最高的;tfjs 最新发布版 tfjs-v4.22.0(2024-10-21)源码里的值是 webgpu 3、webgl 2、wasm 2、cpu 1(2020 年的官方博客写的是"WebGL has the highest priority, followed by Wasm, then the vanilla JS backend",该文没有提 WebGPU)。想用 WASM 就显式调用 tf.setBackend('wasm'):

graph TD
  A["tf.js 模型推理"] --> B{"backend 选择<br/>按注册优先级"}
  B -->|3| C["WebGPU 后端"]
  B -->|2| D["WebGL 后端"]
  B -->|2,需显式选择| E["WASM 后端"]
  B -->|1| F["CPU/JS 后端<br/>兜底"]
  C --> G["GPU 显存"]
  D --> G
  E --> H["WASM 线性内存<br/>+ XNNPACK 内核"]
  F --> I["JS 堆"]
  style C fill:#10b981,color:#333
  style E fill:#6366f1,color:#fff
  style F fill:#dc2626,color:#fff

WASM 后端用 C++ 实现、经 Emscripten 编译,许多算子借助 XNNPACK(Google 的神经网络算子库)加速,XNNPACK 为卷积、矩阵乘等算子提供了针对 WASM SIMD 优化的内核。

核心设计:算子级 dispatch

TensorFlow.js 的每个算子(kernel)在 JS 侧注册,在 WASM 侧有对应的 C++ 实现。以 _FusedMatMul 为例(tfjs 仓库 tfjs-backend-wasm 包里的 _FusedMatMul.ts 与 _FusedMatMul.cc,tfjs-v4.22.0,节选并简化):

// JS 侧:setupFunc 里用 Emscripten 的 cwrap 取到 C++ 导出函数
function setup(backend) {
  wasmFusedMatMul = backend.wasm.cwrap(_FusedMatMul, null /* void */, [
    'number', 'array', 'number',   // a 的张量 id、shape、shape 长度
    'number', 'array', 'number',   // b 的张量 id、shape、shape 长度
    // ... transpose、activation、bias 等参数
  ]);
}

export const _fusedMatMulConfig = {
  kernelName: _FusedMatMul,
  backendName: 'wasm',
  setupFunc: setup,
  kernelFunc: fusedBatchMatMul,
};
// WASM 侧:EMSCRIPTEN_KEEPALIVE 导出,内部创建并运行 XNNPACK 算子
EMSCRIPTEN_KEEPALIVE
void _FusedMatMul(const size_t a_id, const size_t* a_shape_ptr, /* ... */) {
    // batch_mat_mul_impl.cc:xnn_create_fully_connected_nc_f32(...)
    //                       xnn_setup_fully_connected_nc_f32(...)
    //                       xnn_run_operator(...)
}

注意 JS 与 WASM 之间传的是张量 id,不是张量数据:张量常驻线性内存,由后端按 id 管理,跨边界的只有几个整数和 shape 数组——这正是第 11 章"数据留在线性内存里"的做法。JS 侧的 kernel 注册可以按模型裁剪(只注册用到的算子),但 .wasm 二进制本身是整体编译的一个文件。

性能数据

官方博客给出的数字(各自的测量环境见原文):

对比 官方数字 出处
WASM vs 纯 JS(CPU)后端 在他们的模型上快 10–30 倍 2020-03 博客
WASM vs WebGL,中等模型(MobileNet、PoseNet) WASM 慢 2–4 倍 2020-03 博客
WASM vs WebGL,超轻量模型(BlazeFace、FaceMesh) 相当或 WASM 更快——GPU 并行的收益抵不过固定开销 2020-03 博客
开 SIMD 后 vs 普通 WASM 快 1.7–4.5 倍 2020-09 博客
再开多线程 在 SIMD 基础上再快 1.8–2.9 倍 2020-09 博客

这些是 2020 年的数字,此后引擎、XNNPACK 与 WebGPU 后端都变了,用来看位置关系而不是基线:WASM 后端远快于纯 JS 后端;中等规模的模型上 GPU 后端仍明显占优,轻量模型上 WASM 可以持平甚至反超。

教训:WASM 不是终点,而是后端之一

TensorFlow.js 的设计揭示了一个常被忽视的事实:对中等以上规模的模型,WASM 不是性能上限——更多是 GPU 不可用时的退路;只有超轻量模型上它才可能是首选。生产代码必须支持多后端 fallback,而不是只押注 WASM。

具体的工程模式:

async function chooseOptimalBackend() {
    // 优先 WebGPU(更新的浏览器)
    if (await tf.setBackend('webgpu')) return 'webgpu';
    // 次选 WebGL(绝大多数浏览器)
    if (await tf.setBackend('webgl')) return 'webgl';
    // WASM SIMD(无 GPU 时的最快 CPU 路径)
    if (await tf.setBackend('wasm')) return 'wasm';
    // 兜底纯 JS
    return tf.setBackend('cpu');
}

这个 fallback 链为什么必要:总有一部分用户跑在没有可用 WebGL/WebGPU 的环境里(虚拟机、企业 GPU 黑名单、老旧硬件、驱动被浏览器拉黑)——具体占比随产品的用户构成差异很大,必须自己埋点统计,但只要这个比例不是零,只支持 WebGL 就意味着这部分用户直接不可用。WASM 后端在这里不是性能优势——是覆盖率保险。

19.5 案例五:Cloudflare Workers 的 WASM 工作负载

Cloudflare Workers 是边缘计算平台,原生支持 WASM 工作负载——图像处理、加密、协议解析等场景都能在边缘跑。(Cloudflare 没有公开过"多少比例的 Workers 调用涉及 WASM"这类统计,本节不引用这类数字。)这个案例展示服务器端 WASM的工程实践。

架构

graph LR
  subgraph "Cloudflare Edge"
    A["HTTP 请求"] --> B["V8 Isolate"]
    B --> C["JS Worker"]
    C --> D{"调用 WASM?"}
    D -->|是| E["WASM 模块"]
    E --> F["返回结果"]
    D -->|否| F
    F --> G["HTTP 响应"]
  end
  style E fill:#10b981,color:#333

Cloudflare 选择 V8 isolate 而非容器:据 How Workers works,一个 runtime 进程可以同时运行成百上千个 isolate,"any given isolate can start around a hundred times faster than a Node process on a container or virtual machine"。每个 Worker 脚本加载进一个 isolate 后会被多个请求复用,WASM 模块也在这个 isolate 内编译执行。

关键约束:执行时间预算

据 Workers 的 Limits 文档(2026-09-24 核对),Workers 限制的是 CPU 时间而不是墙钟时间——"Waiting on network requests (such as fetch() calls, KV reads, or database queries) does not count toward CPU time"。当前数值是:

限制 免费档 付费档
每个 HTTP 请求的 CPU 时间 10 ms 默认 30 秒,最高可配到 5 分钟
每个 isolate 的内存 128 MB(JS 堆与 WASM 分配合计) 同左
脚本包大小(未压缩) 64 MiB 同左
顶层代码的解析与执行 1 秒 同左

这几条对 WASM 的含义很直接:模块要在脚本加载时编译和实例化一次(放在顶层,且要赶在 1 秒启动预算之内),而不是每个请求都做;每个请求里真正花在 WASM 计算上的 CPU 时间,在免费档只有 10ms,任何"每次请求处理几 MB 数据"的计算都要先实测、再决定能否放在免费档;WASM 的线性内存与 JS 堆共用那 128MB,memory.grow 不能无节制。

案例:图像 CDN 的实时处理

一个典型的 Workers + WASM 用法:图像 CDN 的实时尺寸调整。请求 https://cdn.example.com/image.jpg?w=400 时,Worker 拉取原图、用 WASM 调整尺寸、返回结果(本专栏的示意代码,resize_image 假定是用 wasm-bindgen --target web 导出的 Rust 函数):

// Worker 入口(节选)
// Workers 里 `import x from './x.wasm'` 得到的是已编译的 WebAssembly.Module
import wasmModule from './pkg/image_wasm_bg.wasm';
import { initSync, resize_image } from './pkg/image_wasm.js';

// 顶层实例化一次——同一 isolate 处理后续请求时直接复用
initSync({ module: wasmModule });

export default {
    async fetch(req) {
        const url = new URL(req.url);
        const w = Number.parseInt(url.searchParams.get('w') ?? '', 10);
        if (!Number.isInteger(w) || w <= 0 || w > 4096) {
            return new Response('bad width', { status: 400 });
        }

        // Workers 里的 fetch 需要绝对 URL;源站地址按实际情况替换
        const original = await fetch(new URL(url.pathname, 'https://origin.example.com'));
        const buf = new Uint8Array(await original.arrayBuffer());
        const resized = resize_image(buf, w);  // WASM 调用

        return new Response(resized, {
            headers: { 'content-type': 'image/jpeg', 'cache-control': 'public, max-age=86400' }
        });
    }
};

一次请求的耗时由三步构成(定性关系,具体数字取决于图片与参数,请自己实测):

步骤 占比特征
拉取原图 等待网络不计 CPU 时间;命中缓存时很快,未命中时会主导整个请求的墙钟时间
WASM 解码 + resize 真正吃 CPU 预算的一步,与原图像素数成正比
编码 JPEG 输出 与输出尺寸、质量参数强相关

关键不是总耗时几毫秒,而是这三步都在同一次边缘调用里完成,没有额外的跨机器往返。

教训:服务器端 WASM 的真实价值

边缘计算 + WASM 的组合优势主要不是性能。拆开来看:

  1. 冷启动:优势来自 isolate 模型本身——JS Worker 同样享有;WASM 要做的是别拖后腿:模块在脚本加载时编译一次,体积越大,启动越慢
  2. 多语言:Rust/Go/C 写边缘逻辑,或直接复用已有的原生库(官方文档的 WebAssembly 页面 专门给出了"Write an entire Cloudflare Worker in Rust"这条路线)
  3. 内存隔离:WASM 代码只能访问自己的线性内存,出错不会破坏 JS 堆;租户之间的隔离仍然由 isolate 负责,而不是 WASM

也要注意 WASM 在边缘的反优势:调试链路更长(console.log 可以通过 wrangler tail 或 Workers Logs 看到,但 WASM 里的 panic 要靠第 18 章那套 hook 才有可读信息);对体积敏感(64 MiB 包上限、1 秒启动预算、128MB 内存都要与 JS 共享);WASM 自身没有 I/O,出站 HTTP、KV、TCP connect() 等能力都要经 JS 绑定调用。这些约束决定了不是所有服务都适合搬到边缘 + WASM——"短计算 + HTTP 入口"的场景最受益。

19.6 案例六:StackBlitz WebContainer 在浏览器中运行 Node.js

WebContainer 是 StackBlitz 的核心技术——让 Node.js 工具链直接在浏览器标签页里运行。StackBlitz 在 2021-05-20 的发布文章 Introducing WebContainers(2026-09-24 核对)里说,WebAssembly 与新的浏览器能力 API 让他们得以"write a WebAssembly-based operating system powerful enough to run Node.js, entirely inside your browser"。用户在 stackblitz.com 上看到的终端与 dev server 不是连到远端服务器,而是跑在浏览器安全沙箱里。这个案例展示 WASM 能承载的复杂度上限。

19.6.1 架构

graph TB
  subgraph "浏览器标签页"
    A["终端 UI / 编辑器"] --> B["基于 WASM 的'操作系统'层<br/>进程、文件系统等"]
    B --> C["Node.js 与 npm 工具链"]
    C --> J["用户的 JS 代码<br/>由浏览器自己的 JS 引擎执行"]
    C --> F["dev server"]
    E["虚拟 TCP 网络栈<br/>映射到 Service Worker"] --> F
  end
  G["预览 iframe 的请求"] -.经 Service Worker.-> E
  style B fill:#6366f1,color:#fff
  style J fill:#10b981,color:#333
  style E fill:#f59e0b,color:#333

对照官方材料,可以确认的是三点:

  1. 用 WASM 实现的"操作系统"层:发布文章的原话是"a WebAssembly-based operating system"。这里有个常见误解需要澄清——WebContainer 并不是把 V8 编译进 WASM:官方的 浏览器支持说明 解释,Node.js 依赖 V8 的一些非标准特性,所以同样跑在 V8 上的 Chromium 系浏览器兼容性最好——这说明用户代码是由浏览器自己的 JS 引擎执行的
  2. 虚拟网络栈:"WebContainers include a virtualized TCP network stack that's mapped to your browser's ServiceWorker API"——dev server 监听的端口由这套虚拟网络栈承接,预览页面的请求经 Service Worker 送达
  3. 沙箱内执行:"100% of code execution occurs in the browser security sandbox",断网后环境仍可继续工作

19.6.2 关键工程决策

决策一:不把 JS 引擎搬进 WASM。浏览器里已经有一个高度优化的 JS 引擎了,再编译一个进去既浪费体积又浪费性能。复用宿主引擎跑 JS、只把"Node.js 之下的系统层"用 WASM 实现,代价是兼容性受宿主引擎影响(上面说的 Chromium 系最好),依赖原生扩展的包也无法运行。

决策二:依赖 SharedArrayBuffer。官方的 Configuring Headers 写明:"WebContainer requires SharedArrayBuffer, which, in turn, requires your website to be cross-origin isolated"——嵌入 WebContainer 的页面必须带 Cross-Origin-Embedder-Policy: require-corp(或部分浏览器支持的 credentialless)与 Cross-Origin-Opener-Policy: same-origin。这意味着页面加载的所有第三方资源(统计脚本、第三方 SDK、跨域图片)都要满足 CORP/CORS 要求,接入成本主要花在这里。

决策三:Service Worker 充当网络栈的出口。WASM 没有 socket API——但开发者需要 dev server 的端口能在预览窗口里访问。虚拟 TCP 网络栈映射到 Service Worker,预览页发出的请求被 Service Worker 截获、交给 WebContainer 内的服务器处理,再把响应转回浏览器,让浏览器内的 Node.js dev server "看起来像"真实服务器。

19.6.3 性能数据

官方发布文章给出的数字是:构建"up to 20% faster",包安装"≥ 5x faster than yarn/npm"(对比对象是本地环境,测量条件见原文)。下表是本专栏给的定性对比,用来看差距来自哪里:

指标 浏览器内 远端容器 差距的来源
启动环境 快 慢 远端要调度、拉镜像、起容器
dev server 首响应 快 慢 少一整轮到远端的网络往返
文件读取 快 慢 本地内存里的文件系统 vs 远端文件访问

远端容器方案的劣势主要来自网络往返,而不是 WASM 算得更快。代价也在这里:纯 CPU 密集的任务在 WASM 系统层上会比原生环境慢一截。

19.6.4 适用边界

WebContainer 不适合所有场景:

适合 不适合
教学/演示项目 生产部署(依然需要真实服务器)
快速原型验证 依赖体量很大的应用
离线开发 需要原生 binary(如 Python C 扩展)
共享代码示例 需要 GPU/CUDA

适用边界揭示一个重要原则:WASM 让"客户端运行原本的服务端任务"成为可能,但不会取代服务端。WebContainer 擅长的是开发环境、教学演示与即开即用的交互——生产部署仍然需要真实服务器。

19.6.5 教训:WASM 重构传统软件的边界

WebContainer 揭示 WASM 的边界——理论上可以承载任何不依赖原生硬件的软件,但实际上有三个硬性约束:

  1. 体积:一个系统层级别的 WASM 模块首次加载会明显拖慢首屏,把它压到"能接受的首屏体积"本身就是一项持续的工程投入。
  2. 能力依赖:原生软件依赖的能力(GPU、文件系统、socket)在 WASM 中要么没有要么受限。WebContainer 用 Service Worker 等浏览器 API 模拟网络,但语义不可能与原生完全一致。
  3. 生态兼容:用户期待"一切照常工作",但浏览器环境的某些行为与原生细微不同(比如为缓解 Spectre 类攻击,performance.now() 的分辨率被钳到微秒量级,而不是原生的纳秒),依赖高精度计时或原生扩展的脚本可能直接失败。

这三个约束决定了"哪些传统软件适合 WASM 化"——以纯计算/纯 IO 为主的最适合(如浏览器里的图像编辑器),重度依赖原生能力的最不适合(如需要 GPU 硬件编解码的视频编辑器)。

19.7 案例七:AutoCAD Web 的 CAD 引擎移植

AutoCAD 是工程领域的代表性桌面应用。Autodesk 软件架构师 Kevin Cheung 在 QCon New York 2018 的分享 AutoCAD & WebAssembly: Moving a 30 Year Code Base to the Web(2026-09-24 核对)的摘要里交代了背景:AutoCAD 1982 年首发,代码库"massive, complex, and constantly changing",用 JavaScript 全部重写不现实,于是借助 Emscripten 把遗留代码编译到 WebAssembly。同年 AutoCAD Web 在 Google I/O 2018 上作为 WebAssembly 案例展示(见 Autodesk 官方博客 Roundup: The AutoCAD Web App at Google I/O 2018)。这是 WASM 承载大规模遗留 C++ 代码的标志性案例。

先说清口径:Autodesk 公开的是"为什么这么做、难点在哪",没有公开代码量、WASM 体积、加载时间、模块拆分方式等数字与细节。所以本节只把摘要里写明的内容当作 AutoCAD 的事实,其余是大型 C++ 代码库移植到 WASM 的一般工程路径。

19.7.1 官方披露的挑战

graph TD
  A["三十多年的 C++ 桌面代码库"] --> D["Emscripten 编译"]
  D --> E["浏览器里的 AutoCAD Web"]
  F["桌面与 Web 的范式错位"] --> F1["同步阻塞调用<br/>vs 浏览器主线程不能阻塞"]
  F --> F2["共享内存多线程<br/>vs Web Worker + SharedArrayBuffer"]
  F --> F3["构建时间与运行性能<br/>随代码规模放大"]
  style E fill:#10b981,color:#333
  style F1 fill:#dc2626,color:#fff
  style F2 fill:#dc2626,color:#fff

摘要点名的难点是桌面与 Web 编程范式的错位——"the use of synchronous blocking calls and shared memory on the desktop"——以及移植这样大的代码库带来的构建时间与性能影响。前者是所有桌面代码 Web 化都绕不开的:桌面代码里随处可见"读文件、等结果、继续"的同步写法,而浏览器主线程不允许阻塞;桌面代码默认的共享内存多线程,在浏览器里要落到 Web Worker + SharedArrayBuffer 上,还要求页面跨源隔离(§16.10.3)。

19.7.2 关键工程决策

决策一:Emscripten + WASM,不是重写。这是摘要明确写的理由:代码库庞大、复杂、持续变化,用 JavaScript 重写不现实——换成 Rust 重写同理。Emscripten 把 C/C++ 编译到 WASM,并提供基于 musl 的 libc 与一层 POSIX 模拟,让大部分既有代码不必改写就能编译;改写的工作量集中在上面那些范式错位的地方。

决策二:用 Web API 化解范式错位。摘要说这场分享的第一个目标是"在现有 Web API 的框架内"探讨上述挑战的解法。对照 Emscripten 的能力,常见手段是:同步阻塞调用要么改写成异步回调,要么借助 Asyncify 把同步的 C++ 调用栈挂起、等异步结果回来再恢复;线程则映射到 Emscripten 的 pthreads 支持(底层是 Web Worker + SharedArrayBuffer)。AutoCAD 具体用了哪几种,公开材料没有细说。

19.7.3 大型 C++ 代码库的体积与加载

以下是这类项目的一般路径,不是 Autodesk 公布的数据。一个大型 C++ 代码库第一次"编译通过、能跑"时,通常是一个巨大的单体 WASM 模块,首屏加载不可接受;之后的优化沿两个方向走:

  1. 编译期瘦身:--gc-sections + LTO 删除未使用的函数,收敛 C++ 模板实例化,嵌入的资源改为压缩存放、运行时解压(第 9 章的手段大多适用)
  2. 拆分与按需加载:核心模块首屏必加载,其余功能模块按需加载(第 20 章 §20.15 讨论的模块粒度)

经验上,真正把首屏拉回可用区间的往往是拆分,而不是编译期瘦身——后者能砍掉一部分体积,但改变不了"首屏要下完整个单体"的结构。

19.7.4 性能差距与权衡

与原生桌面版相比,Web 版的慢来自三层:

  • WebGL/WebGPU 这层抽象比原生图形 API 多一层开销,且每次图形调用都要跨 WASM→JS 边界
  • 浏览器的 WASM 编译器要兼顾编译速度,优化比离线 AOT 保守
  • 界面交互每次都要跨 JS-WASM 边界

但用户感知的"打开速度"未必更慢——Web 版省掉了下载、安装这一步。具体差距取决于图纸复杂度与硬件,Autodesk 没有公布对比数字。

19.7.5 教训:大规模遗留代码的 WASM 化

flowchart TD
  A["大规模 C++ 移植 WASM"] --> B["1. 先评估可行性<br/>哪些代码必须改写"]
  B --> C["2. 分阶段交付<br/>不追求 1:1 替代"]
  C --> D["3. 体积是首要约束<br/>不是性能"]
  D --> E["4. 模块化加载<br/>避免冷启动惩罚"]
  E --> F["5. UI 用 JS<br/>核心逻辑用 WASM"]
  style A fill:#6366f1,color:#fff

最关键的认知:WASM 化不是"把桌面版搬上 Web"——而是"用 Web 的约束重新设计产品"。试图 1:1 移植大型桌面应用,体积、加载与交互体验都会碰壁;更可行的方式是定义"Web 友好的功能子集",让 WASM 只承载必要的核心逻辑。

Photoshop 的 Web 版是另一个可以对照的公开案例:据 Adobe 与 Google 工程师在 web.dev 合写的 Photoshop's journey to the web(2021,2026-09-24 核对),它同样靠 Emscripten 把既有 C++ 代码库带上 Web,界面则用基于 Lit 的 Web Components(Spectrum Web Components)实现——核心计算 WASM、界面 JS,与本节的分工一致。

19.8 案例八:单人维护的大型 Web 应用(Photopea)

Photopea 是一个能打开和编辑 PSD 文件的浏览器图像编辑器,作者 Ivan Kuckir 在官方博客 Creating Photopea 里自述是它唯一的开发者。这个案例的独特之处不在技术栈,而在一人团队撑起一个大型 Web 应用这件事本身。

需要说明的是:Photopea 的内部实现(哪些部分是 JS、哪些编译成 WASM、代码量多少)没有权威的公开披露,作者本人在不同场合的说法也随版本演进。所以本节讨论的是这类单人大型 Web 应用的工程模式,涉及具体实现的部分按"一般做法"来读,不要当作 Photopea 的实现事实。

19.8.1 项目特征

graph TD
  A["单人维护的大型 Web 应用"] --> B["规模特征"]
  A --> C["技术栈特征"]
  A --> D["商业特征"]
  B --> B1["代码量以十万行计"]
  B --> B2["前端产物以 MB 计"]
  B --> B3["用户规模远超团队规模"]
  C --> C1["计算密集部分下沉<br/>(原生编译到 WASM)"]
  C --> C2["UI 用 JS + Canvas"]
  C --> C3["刻意不依赖框架"]
  D --> D1["免费使用、免安装"]
  D --> D2["单人维护"]
  style B3 fill:#10b981,color:#333
  style D2 fill:#f59e0b,color:#333

一个人维护十万行量级的代码、服务远超团队规模的用户量——这在传统软件工程视角下几乎不可思议。做到这件事的方法论,比它用了什么技术栈更值得借鉴。

19.8.2 单人维护的工程模式

单人维护的项目普遍收敛到同一组决策:

决策一:避免框架与抽象。不用 React、Vue、Angular,纯 vanilla JS。理由:框架大版本升级是单人维护者扛不动的负担,不引入框架就不用维护版本兼容。

决策二:少依赖。计算模块几乎不依赖第三方库——核心算法都自己实现。这避免了依赖升级的连锁反应。

决策三:可读性优于优雅。代码风格倾向"长函数 + 清晰注释",而不是"短函数 + 复杂抽象"。一人维护时,重读自己代码的时间多于写新代码——优化重读体验。

graph LR
  A["传统团队工程"] --> B["框架优先"]
  A --> C["抽象重用"]
  A --> D["依赖外部"]
  E["单人项目工程"] --> F["框架敬而远之"]
  E --> G["内联代码"]
  E --> H["自己实现"]
  style A fill:#6366f1,color:#fff
  style E fill:#10b981,color:#333

这套哲学和大型团队完全相反——但对单人项目是最优解。

19.8.3 计算与 UI 的分工

这类应用的通行分工是:计算密集任务下沉到 WASM,UI 留在 JS:

模块 该放哪边 理由
图像滤镜 WASM 计算密集,能吃到 SIMD
颜色空间转换 WASM 大量数学,性能敏感
PSD 格式解析 WASM 复杂二进制格式,Rust/C++ 的解析表达力更好
图层混合 WASM 像素级批处理
UI 渲染 JS + Canvas 直接调浏览器 API 更简单
工具栏 / 菜单 JS 业务逻辑,不需要性能
文件 IO JS(Fetch + IndexedDB) 浏览器 API 友好

这张表和 §19.4-§19.7 其他案例的分层是一致的——单人项目的特殊之处在于:所有边界都由一个人决定和维护,因此可以完全内化"哪些放 WASM、哪些放 JS"的设计逻辑,不需要跨团队对齐。

19.8.4 体积管理:MB 量级产物的取舍

这类工具的前端产物通常是 MB 量级——比"理想的首屏体积"(几百 KB)大一个量级。可行的折衷有三条:

  • 保留"完整功能":用户对 Photoshop 类工具的期望就是功能完整,缩水版没人用
  • 接受首次加载慢:MB 量级在移动网络下是几秒——工具型用户能接受("打开一个工具等几秒" vs "下载安装桌面版等几十分钟")
  • 强 cache 复用:产物文件名带内容 hash,浏览器缓存几乎永久——回访用户零加载

这种取舍只在用户有明确"工具型期待"时成立——用户是来"做图"的,不是来"快速浏览"的。换成内容型站点,同样的体积就是灾难。

19.8.5 教训:单人项目的 WASM 化

flowchart TD
  A["单人项目用 WASM 的考虑"] --> B["1. 必要性<br/>真的需要 WASM 吗?"]
  A --> C["2. 维护成本<br/>能长期维护吗?"]
  A --> D["3. 用户期待<br/>用户对加载时间的接受度?"]
  A --> E["4. 工具链稳定<br/>避免追新提案"]
  style A fill:#6366f1,color:#fff

每条都对单人开发者非常关键:

  • 必要性:性能不是瓶颈就别用 WASM——增加复杂度
  • 维护成本:单人维护 WASM 需要懂 Rust/C++ + WASM 工具链——双倍学习曲线
  • 用户期待:工具型用户能接受 5 秒加载,浏览型用户不行
  • 工具链稳定:选成熟稳定的工具链,避免每月跟着改 RUSTFLAGS

单人项目能长期活下来,通常是以上四点都做对了。换个项目(例如做内容浏览站),同样的技术栈选择就是错的。

19.8.6 对大团队的启示

单人项目的纪律不只适用于单人——它给大团队的启示同样成立:

启示 大团队的应用
框架敬而远之 核心引擎用纯 Rust/C++,UI 团队选自己的
内联清晰代码 性能敏感模块允许"长函数",可读性优先
完全内化设计 平台团队应该深度理解所有 WASM 边界,不只是写文档
接受用户期待 工具型产品的"加载体验"标准与内容型不同,不要套用

把"一人项目"的工程纪律应用到团队中——通常能让代码更稳定、更易维护。

19.9 案例九:Polkadot 智能合约的 WASM 路线

区块链是 WASM 的另一大应用领域——NEAR、CosmWasm 等平台把 WASM 作为智能合约的执行格式。Polkadot 的情况更有代表性,也更能说明技术路线会变:它的链上 runtime 一直是 WASM,合约层则从 WASM 转向了 RISC-V(见 §19.9.2)。这个案例展示 WASM 在零信任 + 确定性场景的应用与边界。

19.9.1 区块链对智能合约语言的需求

graph TD
  A["智能合约语言要求"] --> B["确定性"]
  A --> C["可审计"]
  A --> D["资源限制"]
  A --> E["跨平台"]
  A --> F["多语言"]
  B --> B1["相同输入产出相同输出<br/>共识必备"]
  C --> C1["代码可静态分析"]
  D --> D1["gas 限制防止滥用"]
  E --> E1["不依赖具体硬件"]
  F --> F1["开发者可用熟悉语言"]
  style A fill:#10b981,color:#333

WASM 满足所有这些需求:

  • 确定性:WASM 规范只在少数明确列出的地方允许非确定性(如浮点运算产生的 NaN 位模式、资源耗尽),链上执行环境再禁用浮点等指令,就能让各节点对同一输入得出同一结果
  • 可审计:二进制可被静态分析(验证器 + wasm-tools)
  • 资源限制:可以在字节码层面插桩做指令计量,或由引擎在编译时插入计数(Wasmtime 里对应的机制叫 fuel),让 gas 消耗精确可控
  • 跨平台:wasm32 与硬件无关
  • 多语言:Rust、C/C++、AssemblyScript 等都能编译到 WASM

19.9.2 Polkadot 的 WASM 设计

graph TB
  subgraph "Polkadot 架构"
    A["Relay Chain<br/>WASM Runtime"]
    A --> B["Parachain 1<br/>独立 WASM Runtime"]
    A --> C["Parachain 2<br/>独立 WASM Runtime"]
    A --> D["Parachain N..."]
    B --> B1["业务逻辑(用 Substrate 框架)"]
    B --> B2["智能合约(ink!)"]
  end
  style A fill:#6366f1,color:#fff
  style B1 fill:#10b981,color:#333
  style B2 fill:#f59e0b,color:#333

Polkadot 的特殊之处:整个链的 runtime 本身就是 WASM。据 Polkadot Wiki 的 Runtime Upgrades(2026-09-24 核对),节点里不内置业务逻辑,而是带一个 WebAssembly 执行宿主;runtime 是存放在链上状态里的"Wasm blob",升级时把新的 WASM 部署上链、各节点在约定的区块高度自动切换——不需要硬分叉。中继链与每条 parachain 都各有自己的 WASM runtime。

合约层的 ink! 是一套基于 Rust 的合约 DSL。ink! 5.x 编译到 WASM,跑在 pallet-contracts 里、由 wasmi(一个 WASM 解释器)执行;下面是 ink! 5 风格的节选(省略了必需的 #[ink(constructor)]、Error 枚举与 Result 类型别名):

#[ink::contract]
mod my_token {
    use ink::storage::Mapping;

    #[ink(storage)]
    pub struct MyToken {
        total_supply: Balance,
        balances: Mapping<AccountId, Balance>,
    }

    // #[ink(message)] 必须写在 impl 块里的方法上,不能直接挂在 mod 下
    impl MyToken {
        #[ink(message)]
        pub fn transfer(&mut self, to: AccountId, value: Balance) -> Result<()> {
            let from = self.env().caller();
            let from_balance = self.balances.get(from).unwrap_or(0);
            if from_balance < value {
                return Err(Error::InsufficientBalance);
            }
            // ...
        }
    }
}

合约层的路线已经变了。ink! 官方的 v5 → v6 迁移说明 写明:"ink! v5 used pallet-contracts + WebAssembly (executed in wasmi); v6 uses pallet-revive + RISC-V (executed in PolkaVM)",pallet-revive 同时支持 Solidity(经 Parity 的 revive 编译器)。而 ink! 仓库的 README 在 2026-09-24 核对时写着"Since January 2026, we are unfortunately unable to actively maintain or develop ink! further"——最后的正式版是 v5.1.1(2024-12),v6 停在 beta。"链上 runtime 用 WASM"与"合约字节码用 WASM"是两件事:前者至今成立,后者在这个生态里已经让位给 RISC-V。下文讨论的工程约束(确定性、计量、可升级)不随字节码格式改变,WASM 合约的具体做法以 ink! 5 与 pallet-contracts 为准。

19.9.3 性能与 gas 模型

链上 WASM 执行有严格的 gas 计费——每条指令都要计量。各链的权重表不同且会随基准测试调整,下表只给相对关系(不是任何一条链的实际权重):

操作 相对成本
i32.add 等纯算术 基准,最便宜
i32.load 略高于算术(含边界检查)
call 高于 load
call_indirect 明显高于 call(含查表与类型检查)
存储读取 比纯计算高若干数量级
存储写入 比存储读取还高一个量级

要记住的是这张表的跨度:存储操作比纯计算贵几个数量级,这是智能合约设计的核心约束——"多算几遍换少读一次存储"几乎总是划算的。WASM 的指令粒度让这种细粒度计费成为可能。

19.9.4 安全模型

graph TD
  A["Polkadot 智能合约安全"] --> B["WASM 沙箱"]
  A --> C["类型安全(Rust)"]
  A --> D["资源限制(gas)"]
  A --> E["合约升级机制"]
  B --> B1["合约不能访问其他合约的内存"]
  C --> C1["编译期捕获大量 bug"]
  D --> D1["DoS 防护"]
  E --> E1["治理上链升级"]
  style A fill:#10b981,color:#333

WASM 沙箱与 Rust 类型系统各自挡住一类问题:前者保证合约碰不到彼此和宿主的内存,后者在编译期拦下一部分逻辑错误。但智能合约的主要风险——重入、权限检查遗漏、经济模型漏洞——是业务逻辑层面的,换语言、换字节码都消除不了。

19.9.5 与 EVM 的对比

维度 EVM (Ethereum) WASM 合约(ink! 5 + pallet-contracts)
字节码格式 EVM 专用字节码,256 位字宽的栈机 WASM 标准,32/64 位字宽
主要语言 Solidity、Vyper Rust(ink!)
执行方式 各客户端以解释执行为主 wasmi 解释执行
工具链 以 Foundry、Hardhat 等专用工具为主 复用 Rust 与 WASM 工具链(cargo-contract)

执行方式那一行值得多看一眼:链上合约是不可信代码,JIT 编译本身就是攻击面(构造让编译器耗时爆炸的输入,即所谓 JIT bomb),所以 pallet-contracts 选的是解释器(wasmi 的 README 把"Compiler/JIT bomb resisting translation"列为特性)——WASM"可以 JIT"的性能优势在这个场景里并没有兑现。Polkadot 后来转向 RISC-V/PolkaVM、并在 pallet-revive 上兼容 Solidity,也说明决定合约平台走向的除了字节码格式,还有生态与开发者迁移成本。

19.9.6 工程实践经验

flowchart TD
  A["Polkadot WASM 合约工程经验"] --> B["1. 用 ink! 而非手写 Rust"]
  A --> C["2. 严格控制存储读写"]
  A --> D["3. 单元测试覆盖率 100%"]
  A --> E["4. 形式化验证关键合约"]
  A --> F["5. 多次安全审计"]
  A --> G["6. 部署前测试网验证"]
  style A fill:#6366f1,color:#fff

每条都是区块链开发的标配,与字节码格式无关。

19.9.7 跨链互操作

Polkadot 的关键创新是跨链消息——不同 parachain 的合约可以通过消息互调用:

跨链调用的形态大致是"构造一条目的地 + 指令序列的消息,交给 XCM 执行器发送"——伪代码示意(XCM 的实际 API 随版本变化较大,请以当前 SDK 文档为准):

消息 = { 目的地: Parachain(2000), 指令: [WithdrawAsset, BuyExecution, Transact(编码后的调用)] }
XCM 执行器.send(消息)

XCM(Cross-Consensus Message)本身是一套指令格式加上确定性的编码规则——真正保证跨链一致的是"编码 + 指令语义"的确定性,而不是 WASM 本身。

19.9.8 教训:区块链 WASM 的特殊性

graph TD
  A["区块链 WASM 特殊性"] --> B["1. 确定性是硬约束"]
  A --> C["2. 性能优化空间小(gas 限制)"]
  A --> D["3. 可升级性依赖治理"]
  A --> E["4. 安全压倒一切"]
  B --> B1["不能用浮点(可能不确定)"]
  C --> C1["每个 fuel 都珍贵"]
  D --> D1["不能像 npm 包那样随便发版"]
  E --> E1["bug = 资金损失"]
  style A fill:#dc2626,color:#fff

每条特殊性让区块链 WASM 项目与传统 WASM 项目工程模式不同——开发节奏更慢、测试更严格、依赖更保守。

19.9.9 对其他领域的启示

flowchart LR
  A["区块链 WASM 的启示"] --> B["1. 确定性思维"]
  A --> C["2. 资源精确计费"]
  A --> D["3. 形式化验证可行"]
  A --> E["4. 治理驱动升级"]
  B --> F["金融、监管场景"]
  C --> G["FaaS 计费"]
  D --> H["关键基础设施"]
  E --> I["大型企业架构"]
  style A fill:#10b981,color:#333

区块链把确定性、资源计量、可升级性这几条要求推到了极端,这些做法(如 fuel 计量、禁用非确定性指令)在多租户 FaaS、插件系统等场景同样用得上。

19.10 案例十:VS Code Web 的 WASM 应用

VS Code Web(vscode.dev)把 VS Code 搬进了浏览器。其核心挑战:让桌面版依赖本地进程的功能(语言服务、Git、终端)在浏览器沙箱里尽量可用——WASM 是其中的关键手段之一。

19.10.1 项目规模与挑战

graph TD
  A["VS Code Web 挑战"] --> B["规模"]
  A --> C["功能"]
  A --> D["性能"]
  B --> B1["庞大的 TypeScript 代码库<br/>+ 庞大扩展生态"]
  C --> C1["语法高亮 / LSP / 调试 / Git"]
  D --> D1["接近桌面级响应"]
  style A fill:#f59e0b,color:#333

把这些功能搬到浏览器是巨大工程——WASM 是其中一个关键组件,但远不是全部:大部分工作量其实在"哪些能力在 Web 沙箱里根本不存在,得怎么补"。

19.10.2 WASM 在 VS Code Web 中的角色

graph LR
  A["浏览器版编辑器里<br/>WASM 出现的位置"] --> G["TextMate 语法高亮<br/>的正则引擎 Oniguruma"]
  A --> B["语法解析<br/>tree-sitter"]
  A --> C["部分语言服务器"]
  A --> E["命令行工具<br/>WASI"]
  A -.不是 WASM.-> F["扩展隔离<br/>= Web Worker 宿主"]
  style A fill:#10b981,color:#333
  style F fill:#f59e0b,color:#333

这几处的实现方式并不相同,值得逐条区分(出处均为微软官方文档与仓库,2026-09-24 核对):

  • Oniguruma:VS Code 的语法高亮基于 TextMate 语法,其中的正则引擎 Oniguruma 是 C 库,由 vscode-oniguruma 用 Emscripten 编译成 WASM(README:"This library is used in VS Code")——这是 WASM 在编辑器里最基础的一处
  • tree-sitter:C 实现的增量语法分析器,编译到 WASM。Web 扩展指南 举的例子就是 vscode-anycode 扩展用了"tree-sitter, which is C/C++ code compiled to WebAssembly";微软另有 vscode-tree-sitter-wasm 仓库构建"the Tree-Sitter and Tree-Sitter grammar WebAssembly files used by VS Code"
  • LSP:Run WebAssemblies in VS Code for the Web(2023-06-05)把"把语言服务器编译到 wasm32-wasi、在 VS Code for the Web 里运行"列为方向;没有这类服务器的语言,在 Web 版里往往只能靠 anycode 这类基于 tree-sitter 的近似实现
  • 终端 / 命令行工具:同一篇博客介绍了一个遵循 WASI 规范的宿主实现,可以在 vscode.dev 的终端里直接运行编译成 WASM 的 Python 解释器与命令行工具,WASM 代码跑在单独的 worker 线程里
  • 扩展:这里要纠正一个常见误解——VS Code Web 的扩展运行在"web extension host in a Browser WebWorker environment"里,是 Worker 隔离而不是 WASM 沙箱;浏览器里的扩展只能执行 JS 和 WASM,其他语言的库要交叉编译,WASM 是扩展可以选用的一种能力,不是扩展隔离机制本身

19.10.3 tree-sitter 的 WASM 之路

tree-sitter 最早由 GitHub 开发,本是 C 写的增量语法分析器;官方的 web-tree-sitter 包用 Emscripten 把它编译成 WASM,供大纲、符号导航这类需要语法树的功能使用:

flowchart LR
  A["tree-sitter C 源码"] --> B["Emscripten 编译"]
  B --> C["tree-sitter.wasm<br/>百 KB 量级"]
  C --> D["语言 grammar"]
  D --> E[".wasm 文件"]
  style C fill:#10b981,color:#333
  style E fill:#6366f1,color:#fff

每种编程语言的 grammar 是独立 .wasm 文件——按需加载,避免一次性下载所有语言支持。

19.10.4 LSP(语言服务器协议)的 WASM 化

把一个语言服务器搬进浏览器,接口形态通常收敛成"喂一条 JSON-RPC 请求、拿回一条响应"——这正是 §20.10 原则一"最小化跨边界调用"的体现:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct WasmLspServer {
    // 分析器状态整个留在 WASM 线性内存里,JS 只持有这个对象的句柄
    analyzer: Analyzer,
}

#[wasm_bindgen]
impl WasmLspServer {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Self {
        WasmLspServer { analyzer: Analyzer::new() }
    }

    /// 一次跨边界调用传一整条 JSON-RPC 消息,而不是逐个字段来回取
    pub fn handle_request(&mut self, request: &str) -> String {
        self.analyzer.handle(request)
    }
}

把语言服务器编译到 WASM,浏览器内就能获得补全、跳转、诊断——但这条路目前只对部分语言走通了,不是所有语言在 Web 版都有等同于桌面版的语言服务。

19.10.5 性能与体积取舍

这类编辑器的资源构成大致如下(除 tree-sitter 一行是实测外,都是量级示意,随版本和已装扩展变化很大):

资源 量级 加载时机
核心 JS bundle MB 级 首屏
tree-sitter 核心 约 200 KB(web-tree-sitter 0.27.0 的 .wasm 未压缩 209,613 字节) 一次加载,之后走缓存
每语言 grammar 几十到几百 KB 按需
单个语言服务器 百 KB 到 MB 级 用到时才加载

装满各种语言支持后总量不小——但用户实际下载的从来不是总量,而是"首屏 + 我今天用到的那几种语言"。这正是分模块按需加载的价值所在。

19.10.6 扩展系统的沙箱

VS Code 的杀手锏是扩展生态——但扩展是不可信代码。Web 版的隔离靠的是扩展宿主进程模型(在浏览器里落地为 Web Worker),扩展只能通过受限的 host API 操作编辑器;WASM 在这里是扩展可以选用的一种执行方式(典型场景是把命令行工具带进来),而不是隔离层本身:

flowchart TD
  A["扩展"] --> C["VS Code Web 加载"]
  C --> D["扩展宿主 Web Worker<br/>无 DOM、无直接浏览器 API"]
  D --> E["通过受限 host API 操作编辑器"]
  D -.可选.-> F["WASM/WASI 模块<br/>跑原生编译的工具"]
  style D fill:#10b981,color:#333
  style F fill:#6366f1,color:#fff

这个区分很重要:"跑在 Worker 里"提供的是隔离,"编译成 WASM"提供的是可移植性与性能——两者解决的不是同一个问题,混为一谈会让人高估 WASM 在安全模型里的角色。

19.10.7 与桌面版的差异

graph TD
  A["VS Code Web vs 桌面"] --> B["相同"]
  A --> C["差异"]
  B --> B1["UI / 键盘快捷键"]
  B --> B2["核心编辑功能"]
  B --> B3["大部分扩展"]
  C --> C1["不能装本地编译器(语言服务受限)"]
  C --> C2["不能跑 native binary 扩展"]
  C --> C3["性能略差<br/>(工具型场景内可接受)"]
  style A fill:#6366f1,color:#fff

绝大部分编辑功能是等价的——差异集中在"需要本地进程"的那一类能力上,这是 Web 沙箱的天然限制,不是实现没做完。

19.10.8 工程教训

graph TD
  A["VS Code Web 教训"] --> B["1. 增量加载"]
  A --> C["2. 沙箱保安全"]
  A --> D["3. C/C++ 生态可复用"]
  A --> E["4. 小幅性能差距可接受"]
  A --> F["5. 扩展生态是关键"]
  style A fill:#10b981,color:#333

每条都启示其他大型 Web 应用:

  • 增量加载:总量再大,用户仍能接受——因为实际下载的只是首屏加上今天真正用到的那几块
  • 沙箱:扩展跑在 Worker 里的扩展宿主中、只能走受限的 host API——隔离靠的是进程/Worker 模型,WASM 负责的是可移植性
  • C/C++ 复用:Oniguruma、tree-sitter 这类成熟 C 库编译到 WASM,省掉了用 JS 重写的工作量
  • 性能可接受:用户对工具型应用的性能容忍度比内容型高——差一点不致命,差一个数量级才致命
  • 生态关键:单纯做 IDE 不够——需要扩展生态才能成为平台

19.10.9 对类似项目的启示

flowchart LR
  A["类似项目的启示"] --> B["IDE 类<br/>StackBlitz/CodeSandbox"]
  A --> C["设计工具<br/>Figma/Photopea"]
  A --> D["数据分析<br/>JupyterLite"]
  A --> E["游戏引擎<br/>Unity Web"]
  style A fill:#6366f1,color:#fff

VS Code Web 的模式(增量加载 + Worker 隔离的扩展 + C/C++ 生态复用)对其他大型 Web 应用同样适用。

19.10.10 给 WASM 工程师的启发

VS Code Web 的主体仍然是 TypeScript,WASM 出现在"没有它就做不到"的地方:复用 C 写的正则引擎与解析器、在浏览器里跑本来只能在本地进程里跑的命令行工具和语言服务器。把 WASM 只当作"性能优化"是低估它——它更重要的角色是让原本离不开本地进程的能力在 Web 上成为可能。

19.11 十个案例的共同教训

下表取前五个案例作为样本(后五个的结论落在同样的格子里,不再重复列举):

教训 渲染引擎 图像管道 密码学模块 TF.js CF Workers
先测量再优化 WebGPU 出问题就回退 WebGL 实测推翻"逐级提速"的直觉 对比每个算法 后端 fallback 链 CPU 预算分配
计算用 WASM,I/O 用 JS 内核与渲染 WASM,图形 API 经 JS 调用 处理 WASM,Canvas JS 密码学 WASM,AES 走 Web Crypto 推理 WASM,调度与 DOM 在 JS WASM 计算,fetch 在 JS
渐进式迁移 WebGL 与 WebGPU 两套实现并存 逐函数 JS→WASM 逐算法 多后端共存 按场景接入
数据传递要算进总账 批量提交 uniform / 命令缓冲区 数据常驻线性内存 密钥句柄 只传张量 id 整块 buffer 一次传入
安全性是独立维度 不适用 不适用 密钥零化 不适用 线性内存隔离,租户隔离靠 isolate
WASM 不是终点 同一份 C++ 也编成原生做服务器端渲染 简单循环上 JS 并不输 Web Crypto 更快 中等模型上 GPU > WASM 边缘 + WASM 组合

每个教训的深入分析

先测量再优化——这是工程常识,但在 WASM 场景中格外重要,因为"直觉"经常错误。例如:很多人直觉认为"WASM 一定比 JS 快",但 §19.3 表明 AES-GCM 走浏览器的 Web Crypto 反而更快,因为它能吃到 AES-NI。§19.2 更直接:同一台机器上,4K 灰度转换的 JS 循环约 35ms,WASM 标量版就地计算约 44ms,写得不对的 SIMD 版约 39.5ms——"换成 WASM 就快""用了 SIMD 就快"两条直觉都被实测推翻。不做测量就做决策,等于用假设代替数据。

计算用 WASM,I/O 用 JS——这不是偷懒,而是对 WASM 能力边界的清醒认知。WASM 没有文件系统(除非有 WASI)、没有网络栈(除非有 wasi:http)、没有 DOM API——所有 I/O 操作最终都要经过宿主。与其在 WASM 中封装 I/O(增加跨边界调用开销),不如直接在宿主侧做 I/O,只把计算密集的部分交给 WASM。

渐进式迁移——多数案例都没有"停下来全量重写":Figma 让 WebGPU 与 WebGL 两套实现并存、出问题随时回退,图像管道逐函数 JS → WASM,密码学模块逐算法迁移,AutoCAD 选择编译既有 C++ 而不是重写。全量重写的问题不仅是风险高——更是无法建立因果链:重写后性能提升了,是因为换了语言,还是因为重写时顺便优化了算法?渐进式迁移让每步的效果可测量,让"为什么更快"有明确答案。

数据传递要算进总账——第 11 章详细分析了这个问题的理论基础,案例从实践层面验证:批量提交把许多次图形调用合成一次跨边界,TF.js 跨边界只传张量 id,密钥句柄省掉了密钥数据在 JS ↔ WASM 之间的来回传递。§19.2 的实测则提醒另一面:在计算占大头的负载里,省掉复制只是省下一小块(33MB 单向复制约 5ms),真正决定快慢的仍是计算本身。设计 WASM API 时,先量出复制与计算各占多少,再决定优化哪一头。

安全性是独立维度——前两个案例关注性能,§19.3 引入了安全性维度。在密码学场景中,性能提升不能以牺牲安全属性为代价——密钥零化、常量时间实现、JS 侧隔离都是不可妥协的。安全不是"功能完成后的加固",而是从设计阶段就要进预算的核心约束。

下一章是本专栏的收官——设计模式与架构决策,把前 19 章的知识凝练为 12 条可操作的工程决策。