Rust + WebAssembly 全链路解析

第16章 浏览器中的 WASM:与 JS 框架协作

作者 杨艺韬 · 16,850 字

第16章 浏览器中的 WASM:与 JS 框架协作

“The best way to have a good idea is to have lots of ideas.” — Linus Pauling

16.1 WASM 在浏览器中的三种定位

WASM 在浏览器中的角色不是一个固定答案——它取决于团队的技能构成、项目阶段和性能需求。从当前生态来看,存在三种截然不同的定位模型,每种模型对应不同的架构决策和技术选型。

graph TD
  A[WASM 在浏览器中的定位] --> B[计算引擎]
  A --> C[UI 框架内核]
  A --> D[全栈框架]

  B --> B1["纯计算:图像处理、密码学、解析器<br/>JS 负责所有 UI 和状态管理<br/>WASM 通过 wasm-bindgen 暴露函数"]
  C --> C1["框架内核:Yew/Leptos<br/>Rust 负责组件化 + 状态管理 + diff<br/>JS 只提供 DOM API 入口"]
  D --> D1["全栈:同一份 Rust<br/>浏览器端 WASM + 服务器端原生<br/>SSR + Hydration"]

  style B fill:#10b981,color:#fff
  style C fill:#6366f1,color:#fff
  style D fill:#f59e0b,color:#fff

定位一:计算引擎(最常见、最成熟)

JS 框架负责 UI,WASM 只做计算密集的部分。这是目前最成熟的模式——Shopify 的图像处理、Figma 的渲染引擎、1Password 的密码学、Google Earth 的 3D 渲染,都采用这种架构。

核心特征:WASM 模块不触碰 DOM、不管理路由、不持有组件状态——它只是一个高性能的纯函数集合。JS 框架把 WASM 当成一个”用 npm 安装的计算库”——调用方式和一个纯 JS 库没有区别。

优势:不需要改变现有的前端技术栈——在 React/Vue/Svelte 项目中引入一个 WASM npm 包即可。团队不需要学 Rust,只需要学会调用几个 API。

劣势:WASM 和 JS 之间的数据传递有开销——第 6 章测量的结果是,字符串跨边界传递约 180ns,长字符串(1KB)约 2.5us。如果接口设计不当,这个开销会吃掉 WASM 的性能优势。

定位二:UI 框架内核(Yew / Leptos)

整个前端应用用 Rust 编写,编译到 WASM 在浏览器中运行。Yew 和 Leptos 是两个主流框架——它们在 WASM 中实现组件化、状态管理、虚拟 DOM diff 或 Signal 追踪,最终调用 JS 的 DOM API 更新页面。

核心特征:应用的全部逻辑(包括 UI 逻辑)都在 WASM 中运行。JS 只充当”DOM API 的薄代理”——WASM 通过 web-sys 调用 document.createElement 等方法,JS 引擎执行这些调用,但 JS 代码不包含任何业务逻辑。

优势:类型安全的全栈开发(Rust 的类型系统贯穿整个应用)、消除 JS 依赖(不依赖 npm 生态)、WASM 的计算性能适用于复杂的 UI 逻辑(如大型表格的 diff 算法)。

劣势:Rust 前端生态远不如 React/Vue(缺少 UI 组件库、缺少第三方服务 SDK)、WASM 文件体积大(一个 Leptos 应用约 200-500KB gzip 后 50-100KB,而同等功能的 React 应用约 100KB gzip 后 30KB)、调试困难(WASM 的 stack trace 不如 JS 清晰)。

定位三:全栈框架(Leptos 全栈模式)

同一份 Rust 代码编译到 WASM(浏览器)和原生代码(服务器),服务器端渲染 HTML,客户端 hydration 后接管交互。Leptos 的 SSR 模式就是这种架构。

核心特征:代码只有一份,通过 conditional compilation(#[cfg(feature = "ssr")] / #[cfg(feature = "hydrate")])区分服务器端和客户端的行为。服务器端渲染完整的 HTML,客户端只执行 hydration 逻辑——绑定事件处理器和 Signal。

优势:SSR 开箱即用(不需要 Next.js 那样的额外框架)、SEO 友好(服务器渲染的 HTML 对爬虫可见)、首屏渲染快(服务器直接返回 HTML,不需要等 WASM 下载和初始化)。

劣势:部署复杂(需要 Rust 服务器运行 SSR)、Hydration 的正确性难以保证(服务器和客户端的状态必须一致)、开发体验不如 Next.js/Nuxt.js(缺少 HMR、文件路由等便捷功能)。

三种定位的决策矩阵

选择哪种定位不是一次性决策——项目可能从计算引擎模式起步,逐步演进到 UI 框架内核模式。以下决策矩阵帮助评估不同场景下的最佳选择:

评估维度计算引擎UI 框架内核全栈框架
团队 Rust 经验不需要中等需要高度需要
现有前端代码量大量 JS 代码全新项目全新项目
性能瓶颈位置计算密集模块渲染性能首屏渲染 + 计算
需要第三方 JS SDK是(地图、支付等)
需要服务器端渲染不相关
交付时间压力低(局部替换)中等
长期维护成本中等中等

实际项目中最常见的路径是:先用计算引擎模式验证 WASM 的价值(在现有项目中引入一个高性能计算模块),验证成功后再考虑是否用 Rust 重写更多模块。这种渐进式采用策略风险最低——如果 WASM 在某个场景下表现不如预期,可以无缝回退到纯 JS 实现,不影响其他模块。

16.2 计算引擎模式:WASM 作为 React/Vue 的计算后端

计算引擎模式是目前最广泛采用的方案——本节深入拆解它与主流 JS 框架的集成模式和性能特征。

架构

flowchart TD
  subgraph "JS 框架层(React/Vue/Svelte)"
    A[组件] --> B[状态管理<br/>useState / reactive / stores]
    B --> C[渲染<br/>Virtual DOM / Template]
  end

  subgraph "WASM 计算层"
    D[数据处理<br/>图像/音频/加密]
    E[算法计算<br/>排序/搜索/优化]
    F[编解码<br/>JSON/Protobuf/CSV]
  end

  subgraph "wasm-bindgen 桥接层"
    G[导出函数<br/>类型转换<br/>内存管理]
  end

  C -->|用户触发计算| G
  G --> D
  G --> E
  G --> F
  D -->|返回结果| G
  E -->|返回结果| G
  F -->|返回结果| G
  G -->|更新状态| B

  style A fill:#3b82f6,color:#fff
  style D fill:#6366f1,color:#fff
  style G fill:#f59e0b,color:#fff

关键设计原则:WASM 模块不持有应用状态。所有状态都由 JS 框架管理(React 的 useState、Vue 的 reactive、Svelte 的 store),WASM 只提供纯计算函数。这样避免了 WASM 和 JS 之间的状态同步问题——WASM 是无副作用的计算管道。

React 集成示例

import { useRef, useState, useEffect } from 'react';
import init, { ImageProcessor } from 'image-processor-wasm';

function ImageEditor() {
    const [result, setResult] = useState(null);
    const processorRef = useRef(null);

    useEffect(() => {
        init().then(() => {
            processorRef.current = new ImageProcessor();
        });
    }, []);

    const handleProcess = async (file) => {
        if (!processorRef.current) return;

        const imageData = await file.arrayBuffer();
        const input = new Uint8Array(imageData);

        // 调用 WASM 处理
        const output = processorRef.current.grayscale(input);
        setResult(URL.createObjectURL(new Blob([output], { type: 'image/png' })));
    };

    return (
        <div>
            <input type="file" onChange={e => handleProcess(e.target.files[0])} />
            {result && <img src={result} />}
        </div>
    );
}

React 集成的关键细节:

  1. 初始化时机init() 是异步的(下载和编译 .wasm 文件),必须在 useEffect 中调用,不能在渲染函数中调用。首次渲染时 WASM 尚未就绪,需要处理”加载中”状态。

  2. 实例生命周期ImageProcessor 实例存储在 useRef 中——不在 useState 中,因为它不需要触发重渲染。实例的生命周期和组件绑定——组件卸载时应该调用 processor.free() 释放 WASM 内存(否则会泄漏)。

  3. 数据格式:二进制数据用 Uint8Array 传递——避免字符串编码/解码开销。ArrayBufferUint8Array → WASM 线性内存,这条路径最短。

性能陷阱:序列化瓶颈

React 组件和 WASM 之间传数据的方式直接影响性能。跨边界调用的开销不在”调用”本身(i32 参数约 8ns),而在”数据传递”(字符串约 180ns,大数组约 us 级别)。

graph LR
  subgraph "反模式:逐个调用"
    A1["JS 循环 100 次"] --> B1["100 次跨边界调用<br/>100 × 180ns = 18us"]
  end

  subgraph "正确模式:批量调用"
    A2["JS 传数组指针"] --> B2["1 次跨边界调用<br/>WASM 内部循环<br/>1 × 180ns + 100 × 1ns ≈ 0.3us"]
  end

  style B1 fill:#ef4444,color:#fff
  style B2 fill:#10b981,color:#fff

常见的反模式:

// ❌ 每帧调用 WASM——100 次跨边界调用
function AnimationFrame({ data }) {
    const processed = data.map(item => wasm.transform(item)); // 100 次调用
    return <Canvas data={processed} />;
}

// ✅ 批量调用——1 次跨边界调用
function AnimationFrame({ data }) {
    const ptr = wasm.allocate_buffer(data.length);
    const view = new Uint8Array(wasm.memory.buffer, ptr, data.length);
    view.set(data);
    const resultPtr = wasm.transform_batch(ptr, data.length);
    const resultView = new Uint8Array(wasm.memory.buffer, resultPtr, data.length * 4);
    // ... 使用 resultView 渲染
}

批量调用模式的关键:把循环移入 WASM 内部,减少跨边界调用次数。WASM 内部的循环比 JS 快 2-10 倍(取决于操作类型),而且避免了每次迭代的跨边界开销。

Vue 集成的特殊考虑

Vue 的响应式系统(Proxy + effect tracking)和 WASM 的交互需要额外注意——这不是一个显而易见的陷阱,但它会导致”数据更新了但 UI 没刷新”的 bug。

flowchart TD
  subgraph "Vue 响应式追踪"
    A[Proxy get 陷阱] --> B[effect 记录依赖]
    A2[Proxy set 陷阱] --> C[effect 触发更新]
  end

  subgraph "WASM 线性内存"
    D["Uint8Array 视图<br/>直接操作 ArrayBuffer"]
  end

  D -->|"写入内存"| E["ArrayBuffer 内容改变"]
  E -->|"无 Proxy set 陷阱"| F["Vue 不知道数据变了"]
  F --> G["UI 不更新"]

  style F fill:#ef4444,color:#fff
  style G fill:#ef4444,color:#fff

Vue 3 的 reactive() 使用 ES6 Proxy 追踪属性访问——当组件读取 state.data 时,Proxy 的 get 陷阱记录依赖;当 state.data = newValue 时,Proxy 的 set 陷阱触发更新。

但 WASM 的线性内存不是 Proxy 对象——对 Uint8Array 视图的写入直接操作底层的 ArrayBuffer,不经过任何 Proxy 陷阱。这意味着:即使 WASM 修改了数据,Vue 的响应式系统也不会感知到变化

// Vue 3 的 reactive() 不会追踪 WASM 内存的变化
const state = reactive({
    processedData: null,
});

async function processData(input) {
    // ✅ 正确:在 JS 侧保存结果,触发 Vue 响应式更新
    const result = wasm.process(input);
    state.processedData = Array.from(result); // 创建 JS 数组,触发 Proxy set → Vue 更新
}

// ❌ 错误:直接引用 WASM 内存——Vue 无法追踪变化
state.processedData = new Uint8Array(wasm.memory.buffer, ptr, len);
// WASM 内部修改内存后,Vue 不会检测到变化——UI 不更新!

解决方案的核心:在 JS 侧创建一个 Proxy 可追踪的值,而不是直接引用 WASM 内存。Array.from(result) 创建一个新的 JS 数组——赋值给 state.processedData 时触发 Proxy 的 set 陷阱,Vue 知道数据变了,触发组件重渲染。

跨书关联:与《Vue 3 设计与实现》第 4 章(响应式系统)的关系——Vue 的 reactive() 追踪的是 JS Proxy 的属性访问,WASM 线性内存的写入不会触发 Proxy 的 set 陷阱。理解 Proxy 的工作原理才能理解为什么 Array.from() 是必要的——它不是”性能浪费”,而是响应式系统的契约要求。

Svelte 集成

Svelte 的响应式模型和 Vue 不同——它使用编译时分析(而非运行时 Proxy)追踪依赖。这对 WASM 集成意味着:

// Svelte 的响应式声明
let processedData = [];

$: {
    if (inputData) {
        // ✅ 赋值触发 Svelte 的编译时依赖追踪
        processedData = Array.from(wasm.process(inputData));
    }
}

Svelte 的编译时响应式通过检测赋值语句触发更新——processedData = ... 会被编译器识别为”状态变更”。和 Vue 类似,直接修改 WASM 内存不会触发更新——必须在 JS 侧创建新值并赋值。

WASM 模块的加载策略

无论使用哪个 JS 框架,WASM 模块的加载都是一个必须面对的工程问题。浏览器下载和编译 .wasm 文件需要时间——一个 2MB 的 .wasm 文件在 4G 网络下下载约 500ms,编译约 200ms。如果 WASM 模块在关键渲染路径上,用户会看到明显的白屏。

flowchart TD
  A[页面加载] --> B{WASM 在关键路径?}
  B -->|是: 计算引擎模式| C[预加载: link rel=preload]
  B -->|否: 非关键功能| D[懒加载: 动态 import]

  C --> E["<link rel='preload'<br/>as='fetch'<br/>href='module.wasm'>"]
  E --> F[浏览器提前下载<br/>不阻塞渲染]

  D --> G["const wasm = await<br/>import('./module.wasm')"]
  G --> H[用户触发时才加载<br/>减少首屏时间]

  style C fill:#10b981,color:#fff
  style D fill:#f59e0b,color:#fff

预加载和懒加载的选择取决于 WASM 模块的用途:

  • 核心功能(如图片编辑器的图像处理模块)→ 预加载,在页面渲染的同时并行下载和编译
  • 可选功能(如导出为 PDF、高级滤镜)→ 懒加载,用户点击按钮时才下载

wasm-pack 生成的 npm 包默认支持懒加载——init() 函数是异步的,返回一个 Promise。React 的 React.lazy 和 Vue 的 defineAsyncComponent 可以直接包装 WASM 模块的加载:

// React 懒加载 WASM 模块
const ImageEditor = React.lazy(() =>
    import('image-processor-wasm').then(module => ({
        default: () => {
            const [ready, setReady] = useState(false);
            useEffect(() => { module.init().then(() => setReady(true)); }, []);
            return ready ? <ImageEditorInner wasm={module} /> : <Spinner />;
        }
    }))
);

Web Worker 中的 WASM

长时间运行的 WASM 计算会阻塞主线程——导致 UI 无响应。解决方案是把 WASM 模块放在 Web Worker 中执行,通过 postMessage 和主线程通信。

flowchart LR
  subgraph "主线程"
    A[UI 组件] --> B["postMessage({type: 'process', data})"]
    D["onMessage(result)"] --> E[更新 UI]
  end

  subgraph "Web Worker"
    C["WASM 模块<br/>长时间计算"] --> F["postMessage({type: 'result', data})"]
  end

  B --> C
  F --> D

  style C fill:#6366f1,color:#fff
  style A fill:#10b981,color:#fff

Web Worker 中的 WASM 不受主线程的 16ms 帧预算限制——可以执行 100ms 甚至 1s 的计算而不会导致 UI 卡顿。但 Worker 和主线程之间的数据传递有开销——postMessageArrayBuffer 做结构化克隆(structured clone),对于大数组(>1MB)需要约 1-5ms。

优化方案:使用 Transferable 对象(ArrayBufferImageBitmap)——它们的所有权从发送方转移到接收方,不需要复制。发送方在转移后不能再访问该 ArrayBuffer——这和 Rust 的移动语义一致。

// 主线程:发送数据到 Worker(零拷贝转移)
const buffer = new ArrayBuffer(10 * 1024 * 1024); // 10MB
worker.postMessage({ type: 'process', data: buffer }, [buffer]);
// 此后 buffer 变为 0 字节——所有权转移给 Worker

// Worker:返回结果(零拷贝转移)
self.onmessage = (e) => {
    const result = wasm.process(e.data.data);
    self.postMessage({ type: 'result', data: result }, [result]);
};

跨书关联:与《Vite 构建工具源码精讲》第 7 章(Worker 打包)的关系——Vite 对 Web Worker 的打包提供了开箱即用的支持,new Worker(new URL('./worker.ts', import.meta.url)) 语法会被 Vite 自动处理。WASM 模块在 Worker 中的加载可以通过 Vite 的 Worker 打包机制简化——不需要手动管理 Worker 的创建和消息传递。

16.3 Yew:Rust 前端框架

Yew 是 Rust 生态最成熟的浏览器端 UI 框架,设计灵感来自 React——组件化、虚拟 DOM、消息驱动的状态更新。

核心架构

graph TD
  A["Component trait<br/>定义组件行为"] --> B["update 方法<br/>处理 Msg 枚举"]
  B --> C["view 宏<br/>html! {} 生成虚拟 DOM"]
  C --> D["diff 算法<br/>比较新旧虚拟 DOM"]
  D --> E["DOM API 调用<br/>通过 web-sys"]
  E --> F["浏览器渲染"]

  F -->|"用户交互<br/>onclick / oninput"| G["事件处理器<br/>发送 Msg"]
  G --> B

  style A fill:#6366f1,color:#fff
  style E fill:#10b981,color:#fff
  style G fill:#f59e0b,color:#fff

Yew 的架构和 React 几乎同构:Component trait 对应 React 的 Component 类/函数,Msg 枚举对应 React 的 dispatch/action,view 宏对应 JSX,diff 算法对应 React 的 reconciler。关键区别在于执行环境——Yew 的 diff 在 WASM 中运行,React 的 diff 在 JS 引擎中运行。

组件示例

use yew::prelude::*;

struct Counter {
    count: i32,
}

enum Msg {
    Increment,
    Decrement,
    Reset,
}

impl Component for Counter {
    type Message = Msg;
    type Properties = ();

    fn create(_ctx: &Context<Self>) -> Self {
        Counter { count: 0 }
    }

    fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
        match msg {
            Msg::Increment => { self.count += 1; true }
            Msg::Decrement => { self.count -= 1; true }
            Msg::Reset => { self.count = 0; true }
        }
    }

    fn view(&self, ctx: &Context<Self>) -> Html {
        let link = ctx.link();
        html! {
            <div class="counter">
                <h2>{ format!("Count: {}", self.count) }</h2>
                <button onclick={link.callback(|_| Msg::Increment)}>{"+"}</button>
                <button onclick={link.callback(|_| Msg::Decrement)}>{"-"}</button>
                <button onclick={link.callback(|_| Msg::Reset)}>{"Reset"}</button>
            </div>
        }
    }
}

Yew 的消息驱动模型:用户点击按钮 → 事件处理器发送 Msg::Incrementupdate 方法修改 self.count 并返回 true(表示需要重渲染)→ view 重新生成虚拟 DOM → diff 计算最小变更集 → 调用 DOM API 更新页面。这个循环和 React 的 setState → render → reconciliation → commit 完全同构。

Yew 的 Properties 和组件组合

Yew 的组件通过 Properties trait 实现参数传递——类似于 React 的 props:

#[derive(Properties, PartialEq)]
pub struct TodoItemProps {
    pub id: u64,
    pub title: String,
    pub completed: bool,
    pub on_toggle: Callback<u64>,
    pub on_delete: Callback<u64>,
}

#[function_component(TodoItem)]
fn todo_item(props: &TodoItemProps) -> Html {
    let on_toggle = props.on_toggle.reform(move |_| props.id);
    let on_delete = props.on_delete.reform(move |_| props.id);

    html! {
        <li class={if props.completed { "completed" } else { "" }}>
            <span onclick={on_toggle}>{ &props.title }</span>
            <button onclick={on_delete}>{"Delete"}</button>
        </li>
    }
}

Yew 的 Callback<T> 类似于 React 的 (T) => void——它是一个可以从 JS 事件处理器中触发的回调。Callback::reform 方法把回调的输入类型从一种映射为另一种——上面的例子把 MouseEvent 映射为 u64(待办项的 ID),这样事件处理器不需要知道具体的 ID 值。

Yew 的函数组件(#[function_component])和 struct 组件的区别:函数组件更简洁(不需要手动实现 Component trait),但不支持内部状态——状态必须通过 use_state/use_ref 等 hook 管理。这和 React 的函数组件 + Hooks 模式完全一致。

Yew 的性能特征

Yew 的虚拟 DOM diff 在 WASM 中执行——比 JS 的虚拟 DOM diff 快约 2-3 倍(WASM 的整数操作更快,且 WASM 不受 JS 引擎的 JIT 去优化影响)。但最终的 DOM 更新必须调用 JS 的 document.createElement/element.setAttribute——这些跨边界调用的开销在 Yew 中比在 React 中更高。

graph LR
  subgraph "React 路径"
    A1["JS diff<br/>~5ms"] --> B1["JS DOM 操作<br/>~3ms"]
    B1 --> C1["总计 ~8ms"]
  end

  subgraph "Yew 路径"
    A2["WASM diff<br/>~2ms"] --> B2["web-sys 调用<br/>每个操作 ~200ns<br/>~40 个操作 × 200ns<br/>~8ms"]
    B2 --> C2["总计 ~10ms"]
  end

  style C1 fill:#10b981,color:#fff
  style C2 fill:#f59e0b,color:#fff

实测:一个 1000 个列表项的渲染,Yew 的 diff 阶段约 2ms(React 约 5ms),但 DOM 更新阶段约 8ms(React 约 3ms)。总计 Yew 约 10ms,React 约 8ms——Yew 并不总是更快。

根本原因:Yew 每次属性变更都是一次 web-sys 调用(WASM → JS 的跨边界调用),而 React 直接用 JS 操作 DOM(JS 引擎内部调用,无跨边界开销)。当 DOM 操作密集时,跨边界调用的累积开销会超过 WASM diff 的速度优势。

Yew 的局限

  1. 无 Fiber:Yew 的 diff 是同步的——大组件树的 diff 会阻塞主线程。React 的 Fiber 架构(可中断 diff)允许大组件树的更新分多帧完成。Yew 没有 Fiber,因为 WASM 的执行不能被 JS 的 requestIdleCallback 中断。

  2. 无 SSR:Yew 不支持服务器端渲染——所有渲染都在客户端 WASM 中完成。这意味着首屏加载时用户看到的是空白页面,直到 WASM 下载和初始化完成。

  3. 生态有限:Yew 的第三方组件库远不如 React 丰富——没有 Ant Design、Material UI 这样的成熟 UI 库。开发者需要自己实现大部分 UI 组件。

16.4 Leptos:基于 Signal 的 Rust 前端框架

Leptos 是 Yew 之后的下一代 Rust 前端框架,核心区别是用 Signal(细粒度响应式)替代虚拟 DOM。这个设计选择和 SolidJS 相对于 React 的区别是相同的——放弃虚拟 DOM diff,改为精确追踪哪些 DOM 节点依赖哪些状态值,状态变化时只更新受影响的 DOM 节点。

架构差异

graph TD
  subgraph "Yew / React: 虚拟 DOM"
    A1[状态变更] --> B1[重绘整个组件视图]
    B1 --> C1["diff 新旧虚拟 DOM<br/>O(n) 树遍历"]
    C1 --> D1["最小化 DOM 更新<br/>批量 commit"]
  end

  subgraph "Leptos / SolidJS: Signal"
    A2[Signal 变更] --> B2[追踪依赖的 effect]
    B2 --> C2["精确更新受影响的 DOM 节点<br/>O(affected) 直接更新"]
  end

  style C1 fill:#f59e0b,color:#fff
  style C2 fill:#10b981,color:#fff

虚拟 DOM 的问题是:即使只有一个 count 值变了,也要重绘整个组件的视图、diff 新旧两棵树。Signal 的优势是:count 的 effect 只绑定了 <p>{count}</p> 这一个 DOM 节点——count 变了,只更新这一个节点的 textContent,不做 diff。

组件示例

use leptos::prelude::*;

#[component]
fn Counter() -> impl IntoView {
    let (count, set_count) = signal(0i32);
    let double = move || count.get() * 2;

    view! {
        <div>
            <p>"Count: " {move || count.get()}</p>
            <p>"Double: " {double}</p>
            <button on:click=move |_| set_count.update(|n| *n += 1)>{"+"}</button>
            <button on:click=move |_| set_count.update(|n| *n -= 1)>{"-"}</button>
        </div>
    }
}

signal(0i32) 创建一对 (read, write)——count 是读取端(Signal),set_count 是写入端。move || count.get() 创建一个派生计算(derived computation),它追踪 count 的值。当 set_count 被调用时,Leptos 的运行时只重新执行依赖 count 的 effect——不重绘整个组件。

Leptos 的 Signal 模式和 Vue 3 的 ref()/computed() 几乎等价——都基于细粒度依赖追踪。区别在于 Leptos 的 Signal 在 WASM 中运行,依赖追踪的开销更低(WASM 的整数操作比 JS 的 Proxy 快)。

Signal 的内部实现

flowchart TD
  A["set_count.update(|n| *n += 1)"] --> B["Signal 值从 0 变为 1"]
  B --> C["标记所有依赖此 Signal 的 effect 为 dirty"]
  C --> D["微任务队列执行 dirty effect"]
  D --> E["effect: {count.get()} → 更新 p.textContent"]
  D --> F["effect: {double} → 重新计算 1*2=2 → 更新 p.textContent"]

  style B fill:#f59e0b,color:#fff
  style D fill:#6366f1,color:#fff

Signal 的依赖追踪基于一个全局的”当前 effect”指针——当 count.get() 在某个 effect 内被调用时,Signal 把这个 effect 注册为自己的依赖。之后 Signal 值变化时,只通知注册的 effect。这个机制和 Vue 3 的 effect + track/trigger 系统同构,跨书关联可参考《Vue 3 设计与实现》第 5 章的深度分析。

SSR + Hydration

Leptos 的独特优势:同一份代码编译到服务器(Axum)和浏览器(WASM),服务器渲染 HTML,客户端 hydration 后接管交互:

#[component]
fn App() -> impl IntoView {
    let (count, set_count) = signal(0i32);
    view! {
        <h1>"Counter"</h1>
        <p>{move || count.get()}</p>
        <button on:click=move |_| set_count.update(|n| *n += 1)>{"+"}</button>
    }
}

// 服务器端:Axum handler
async fn server_render() -> impl IntoResponse {
    let app = view! { <App/> };
    let html = leptos_axum::render_to_string(app);
    Html(html)
}

// 客户端:WASM 入口
fn main() {
    leptos::mount_to_body(|| view! { <App/> });
}

服务器渲染的 HTML 包含 data-leptos 属性标记,客户端 WASM 加载后根据这些标记进行 hydration——跳过初始渲染,直接绑定事件处理器和 Signal。

hydration 的核心挑战:服务器和客户端的 Signal 初始值必须一致——否则客户端的 hydration 会产生”闪烁”(服务器渲染的 HTML 和客户端重新计算的值不一致)。Leptos 的解决方案是:服务器在渲染时把 Signal 的值序列化到 HTML 中(作为 data-leptos 属性),客户端 hydration 时从 HTML 反序列化——保证初始值一致。

跨书关联:与《Vite 构建工具源码精讲》第 10 章(SSR)的关系——Vite 的 SSR 机制和 Leptos 的 SSR 面对同一个问题:如何在服务器和客户端之间共享模块状态。Vite 的方案是 ssrLoadModule + import.meta.env.SSR 条件导入,Leptos 的方案是 #[cfg(feature = "ssr")] 条件编译。两者都要求开发者显式区分服务器代码和客户端代码——这是 SSR 的根本约束,不是工具可以消除的。

16.5 web-sys:Rust 的浏览器 API 绑定

web-syswasm-bindgen 团队维护的浏览器 Web API 的 Rust 绑定——覆盖了 DOM、Canvas、WebSocket、Fetch、WebGL、WebGPU 等 500+ 个 API。Yew 和 Leptos 内部都依赖 web-sys 调用 DOM API。

按需引入

web-sys 的完整绑定代码约 5MB——不能全部引入。Rust 的 feature gate 机制实现了按需引入:

[dependencies.web-sys]
version = "0.3"
features = [
    "Window",
    "Document",
    "Element",
    "HtmlCanvasElement",
    "WebGlRenderingContext",
    "WebGlProgram",
    "WebGlShader",
]

每个 feature 对应一组 Web API。只有启用的 feature 才会被编译——未启用的 API 不生成绑定代码,不影响 .wasm 体积。这个设计非常重要——如果全量引入,.wasm 文件会增加约 2MB。按需引入后,实际用到的 web-sys 代码通常只有 50-200KB(取决于启用了多少 API)。

feature 的粒度是”接口”级别——WebGlRenderingContext feature 包含整个 WebGL1 渲染上下文的所有方法和类型,但不包含 WebGL2(需要 WebGl2RenderingContext feature)。这个粒度比”每个方法一个 feature”粗——但比”全量引入”细得多。

Canvas 绑定示例

use wasm_bindgen::JsCast;
use web_sys::{window, HtmlCanvasElement, WebGlRenderingContext};

fn setup_canvas() -> WebGlRenderingContext {
    let document = window().unwrap().document().unwrap();
    let canvas = document.get_element_by_id("canvas")
        .unwrap()
        .dyn_into::<HtmlCanvasElement>()
        .unwrap();

    let gl = canvas.get_context("webgl")
        .unwrap()
        .unwrap()
        .dyn_into::<WebGlRenderingContext>()
        .unwrap();

    gl.clear_color(0.0, 0.0, 0.0, 1.0);
    gl.clear(WebGlRenderingContext::COLOR_BUFFER_BIT);
    gl
}

dyn_into::<T>()wasm-bindgen 提供的 JS 类型转换方法——它调用 JS 的 instanceof 检查运行时类型,不匹配则 panic。这比 wasm-bindgenJsValue::as_f64() 更类型安全——编译时已知目标类型。

直接使用 web-sys 的场景

不用 Yew/Leptos,直接用 web-sys 操作 DOM 的场景适合:

  1. 计算密集 + 极少 UI:一个 Canvas 应用,90% 的逻辑是 WebGL 渲染,只有 10% 需要操作 DOM。引入 Yew/Leptos 是过度工程——直接用 web-sys 操作几个按钮和 canvas 元素就够了。

  2. 嵌入到现有 JS 应用中:WASM 模块需要操作特定的 DOM 元素(如把计算结果绘制到 canvas),但不负责整个 UI。此时 web-sys 提供了足够的 DOM 访问能力,不需要框架。

  3. 极致体积控制:Yew 的运行时约 30KB gzip 后,Leptos 约 20KB——直接用 web-sys 可以做到 5KB 以下(只引入需要的 API)。

web-sys 的性能开销

web-sys 的每次调用都是一次 WASM → JS 的跨边界调用。在高频调用场景(如每帧调用 1000 次 gl.bindVertexArray()),这个开销会累积:

graph LR
  subgraph "直接 JS WebGL"
    A1["gl.bindVertexArray(vao)<br/>JS 引擎内部调用<br/>~5ns"]
  end

  subgraph "web-sys 调用"
    A2["gl.bindVertexArray(vao)<br/>WASM → JS 跨边界<br/>~50ns"]
  end

  style A1 fill:#10b981,color:#fff
  style A2 fill:#f59e0b,color:#fff

单次调用差 10 倍。1000 次调用:JS 约 5us,web-sys 约 50us——差距 45us。对于 60fps 的动画(每帧 16.6ms),45us 约占 0.3%——影响不大。但对于 10000 次调用的复杂场景(如大量 draw call),开销可能达到 500us = 0.5ms——开始有感知了。

解决方案和第 6 章的批量调用模式相同——减少跨边界调用次数。WebGL 的状态缓存是一个常用模式:在 WASM 侧记录当前绑定的 VAO/texture/program,只在状态真正变化时调用 web-sys

16.6 WASM 与 JS 的 SharedArrayBuffer 协作

前面讨论的 WASM-JS 通信都基于”数据复制”——JS 和 WASM 各自拥有数据的独立副本。但在某些高性能场景中,复制开销不可接受——此时可以使用 SharedArrayBuffer 实现零拷贝共享内存。

SharedArrayBuffer 的工作原理

SharedArrayBuffer 是一种特殊的 ArrayBuffer——它可以被多个 Worker(和主线程)同时访问,不需要复制。WASM 的线性内存本身就是一个 ArrayBuffer——如果把它配置为 SharedArrayBuffer,JS 和 WASM 就可以零拷贝地共享数据。

flowchart TD
  subgraph "主线程"
    A[JS 代码] --> B["SharedArrayBuffer<br/>WASM 线性内存"]
  end

  subgraph "Web Worker"
    C[WASM 模块] --> B
  end

  subgraph "Web Worker 2"
    D[WASM 模块 2] --> B
  end

  style B fill:#f59e0b,color:#fff

Rust 侧:使用 SharedArrayBuffer

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn process_shared(shared_memory: js_sys::SharedArrayBuffer) -> Result<(), JsValue> {
    // 把 SharedArrayBuffer 视图映射到 WASM 线性内存
    let view = js_sys::Uint8Array::new(&shared_memory);
    let len = view.length() as usize;

    // 直接操作共享内存——零拷贝
    for i in 0..len {
        let byte = view.get_index(i as u32);
        view.set_index(i as u32, byte.wrapping_add(1));
    }

    Ok(())
}

SharedArrayBuffer 的安全限制

浏览器出于安全考虑(Spectre 攻击),对 SharedArrayBuffer 有严格的启用条件——页面必须设置以下 HTTP 响应头:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

这两个头部启用了”跨域隔离”(Cross-Origin Isolation)——页面中的所有跨域资源必须显式授权(Cross-Origin-Resource-Policy: cross-origin),否则无法加载。这个限制意味着:如果页面内嵌了第三方脚本(如 Google Analytics、广告 SDK),除非这些脚本也设置了正确的头部,否则无法启用 SharedArrayBuffer

这是一个现实世界的工程约束——很多项目因为第三方脚本无法满足跨域隔离要求,而放弃了 SharedArrayBuffer。在这些项目中,只能使用传统的 postMessage + Transferable 方案——虽然不是真正的零拷贝共享内存,但转移所有权的开销比复制低得多。

跨书关联:与《React 19 内核探秘》第 6 章(Concurrent Mode 的优先级调度)的关系——React 的 Concurrent Mode 依赖主线程的 16ms 帧预算。如果 WASM 计算放在主线程上运行,会挤占 React 的调度空间——大组件的渲染可能因 WASM 计算占时过长而被延迟。把 WASM 放到 Web Worker 中是正确的架构选择——主线程只负责 React 渲染和 UI 更新,WASM 在 Worker 中做计算,通过 postMessageSharedArrayBuffer 传递结果。

16.7 Yew vs Leptos 的深度对比

两个框架的差异不只是”虚拟 DOM vs Signal”——它们在状态管理、SSR 支持、生态成熟度等方面都有显著区别。

维度YewLeptos
更新模型虚拟 DOM diffSignal 精确更新
状态管理Component self + Msgsignal() + store()
SSR不支持原生支持(Axum 集成)
Hydration不支持支持
社区规模较大(2020 年起)较小但增长快(2022 年起)
UI 组件库yewstrap, yew-tuicssleptos-uuid, 少量
TypeScript 互操作通过 wasm-bindgen通过 wasm-bindgen
学习曲线较低(React 开发者熟悉)中等(Signal 概念需要适应)
编译速度较慢(宏展开量大)中等(宏展开量适中)
.wasm 体积30-50KB gzip20-40KB gzip

选择建议:

  • 现有 Yew 项目 → 继续用 Yew(迁移成本不值得)
  • 新项目 + 需要 SSR → Leptos(唯一选择)
  • 新项目 + 纯 CSR → Leptos(Signal 模型更新效率更高)
  • 新项目 + React 团队转型 → Yew(模型更接近 React,学习曲线低)

16.8 选择指南:什么时候用什么方案

graph TD
  A{项目类型?} -->|现有 JS 项目| B{性能瓶颈在哪?}
  A -->|全新 Rust 项目| C{需要 SSR?}
  A -->|全栈项目| D[Leptos + Axum]

  B -->|计算密集| E[wasm-bindgen 计算引擎模式]
  B -->|渲染瓶颈| F[优化 JS 渲染<br/>WASM 帮不上忙]
  B -->|无明显瓶颈| G[不需要 WASM]

  C -->|是| D
  C -->|否| H{UI 复杂度?}
  H -->|高: 大量表单/表格| I[Leptos<br/>Signal 精确更新]
  H -->|中| J[Yew 或 Leptos<br/>个人偏好]
  H -->|低: 主要是 Canvas| K[web-sys 直接操作 DOM]

  style E fill:#10b981,color:#fff
  style D fill:#6366f1,color:#fff
  style I fill:#6366f1,color:#fff
  style K fill:#f59e0b,color:#fff

核心原则:不要用 WASM 做 WASM 能做的事,用 WASM 做 JS 做不好的事。如果 React/Vue 项目中只有 5% 的代码是性能热点,只把那 5% 编译到 WASM——不要为了”纯 Rust”而重写整个前端。

更具体的选择原则:

  1. 计算引擎模式适用于:已有成熟的 JS 前端项目,只需要在特定环节(图像处理、加密、数据解析)引入 WASM 提速。这是最低风险、最高回报的 WASM 集成方式。

  2. Yew/Leptos 全 Rust 前端适用于:团队全栈 Rust 开发者、对类型安全有极高要求、需要和 Rust 后端共享类型定义。不适合:团队主要是 JS 开发者、项目依赖大量 JS-only 的第三方 SDK(如地图 SDK、支付 SDK)。

  3. web-sys 直接操作适用于:Canvas/WebGL 重度应用、嵌入式场景(WASM 模块嵌入到宿主页面中操作特定 DOM 元素)。不适合:需要完整 UI 框架能力(路由、表单验证、国际化)的应用。

16.9 PWA 与 Service Worker 中的 WASM

PWA(Progressive Web App)让 Web 应用具备类原生体验——离线可用、可安装、推送通知。WASM 在 PWA 中扮演两个角色:作为业务计算引擎(同 §16.1 计算引擎模式),以及通过 Service Worker 实现离线优先的加载策略。

16.9.1 Service Worker 缓存 WASM 的策略

flowchart TD
  A["浏览器请求 .wasm"] --> B["Service Worker fetch 拦截"]
  B --> C{"缓存命中?"}
  C -->|是| D{"过期检查"}
  C -->|否| E["从网络下载"]

  D -->|未过期| F["返回缓存"]
  D -->|过期| G["后台更新缓存<br/>同时返回旧版"]

  E --> H["写入缓存<br/>+ 返回响应"]

  style F fill:#10b981,color:#fff
  style G fill:#6366f1,color:#fff
  style H fill:#f59e0b,color:#fff

四种 SW 缓存策略对应四种业务场景:

策略行为适用
Cache First命中即返回,不再请求网络静态、版本化的 WASM(推荐)
Network First优先网络,失败 fallback 到缓存频繁更新但需离线兜底
Stale While Revalidate立即返回缓存,后台更新最佳用户体验
Network Only不缓存高安全性场景

16.9.2 实现:版本化 WASM 的离线加载

WASM 文件路径中通常带 hash(my-lib-a3f2b1c.wasm)——这天然适合 Cache First 策略。Service Worker 实现:

// service-worker.js
const CACHE_NAME = 'wasm-v1';

self.addEventListener('install', (event) => {
    event.waitUntil(
        caches.open(CACHE_NAME).then(cache => cache.addAll([
            '/',
            '/index.html',
            '/app.js',
            '/wasm/my-lib-a3f2b1c.wasm',  // 预缓存关键 WASM
        ]))
    );
});

self.addEventListener('fetch', (event) => {
    const url = new URL(event.request.url);

    // .wasm 文件用 Cache First
    if (url.pathname.endsWith('.wasm')) {
        event.respondWith(
            caches.match(event.request).then(response => {
                return response || fetch(event.request).then(networkResp => {
                    return caches.open(CACHE_NAME).then(cache => {
                        cache.put(event.request, networkResp.clone());
                        return networkResp;
                    });
                });
            })
        );
    }
});

效果:用户首次访问下载 WASM(200-500ms),后续访问从缓存加载(< 5ms)——离线可用。

16.9.3 Service Worker 内执行 WASM

Service Worker 也可以加载并执行 WASM——这是边缘计算和后台任务的关键能力:

// service-worker.js
let wasmModule = null;

async function ensureWasm() {
    if (!wasmModule) {
        const cache = await caches.open(CACHE_NAME);
        const response = await cache.match('/wasm/processor.wasm');
        wasmModule = await WebAssembly.instantiateStreaming(response);
    }
    return wasmModule;
}

self.addEventListener('fetch', async (event) => {
    const url = new URL(event.request.url);
    if (url.pathname === '/api/process') {
        event.respondWith((async () => {
            const wasm = await ensureWasm();
            const body = await event.request.arrayBuffer();
            const result = wasm.instance.exports.process(new Uint8Array(body));
            return new Response(result, { headers: { 'content-type': 'application/octet-stream' } });
        })());
    }
});

这种模式让一些请求在客户端 SW 完成,不必触达服务器——降低网络延迟和服务器压力。但 SW 的执行有限制:CPU 密集任务可能超时(浏览器对 SW 的执行时间通常限制 30s)。

16.9.4 离线优先架构的取舍

PWA + WASM 的离线优先架构看起来理想——但有现实成本:

优势代价
离线可用缓存管理复杂,更新策略要谨慎
首次加载后极快首次加载实际更慢(要预缓存额外资源)
服务器压力下降SW 调试困难(多 tab 共享 SW,状态难复现)
用户感知”快”缓存更新延迟可能让用户看到旧版本几秒到几分钟

什么时候应该上离线优先:用户有”反复使用同一个工具”的模式(图像编辑器、PDF 阅读器、笔记应用)。什么时候不应该:内容型站点(每次访问看不同内容)、依赖实时数据的应用(股票、聊天)。

16.10 部署:CSP / CORS / 缓存策略

WASM 在生产部署中的常见踩坑——CSP 配置错误导致 WASM 无法加载、CORS 缺失导致 streaming compilation 失败、缓存策略不当导致用户卡在旧版本。

16.10.1 CSP 与 wasm-unsafe-eval

Content-Security-Policy(CSP)默认禁止 eval()new Function()——WASM 的实例化在某些 CSP 设置下也被视为类似 eval 的行为。要让 WASM 在严格 CSP 下工作:

Content-Security-Policy:
  script-src 'self' 'wasm-unsafe-eval';
  worker-src 'self';

wasm-unsafe-eval 是 W3C 标准化的 CSP source(Chrome 95+, Firefox 100+, Safari 15.6+)——它允许 WASM 编译,不允许其他 eval 行为。这是比 'unsafe-eval' 安全得多的选择。

旧浏览器的兼容做法:

Content-Security-Policy:
  script-src 'self' 'unsafe-eval';  # 兜底——更宽松

'unsafe-eval' 同时打开了 JavaScript eval 的口子——破坏 CSP 的核心防御。生产中应优先 wasm-unsafe-eval,仅对必须支持的旧浏览器降级。

16.10.2 CORS 与 streaming compilation

WebAssembly.instantiateStreaming 要求 .wasm 资源带 Content-Type: application/wasm。如果是跨域加载,还需要 CORS 头:

# 跨域服务器响应 .wasm 时
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/wasm

常见错误:CDN 返回 Content-Type: application/octet-stream——streaming 失败,回退到非流式(多 50-200ms 加载时间)。修复 CDN 配置:

# Nginx
location ~ \.wasm$ {
    types { application/wasm wasm; }
    add_header Cache-Control "public, max-age=31536000, immutable";
    add_header Access-Control-Allow-Origin *;
}

16.10.3 多线程 WASM 的 COOP/COEP

启用 SharedArrayBuffer(多线程 WASM 必需)需要 Cross-Origin Isolation:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

启用 COEP 后所有跨域资源(图片、脚本、iframe)必须带 Cross-Origin-Resource-Policy 或 CORS——这通常意味着大量第三方资源失效。生产部署的现实选择:

flowchart TD
  A["需要 SharedArrayBuffer?"] --> B{"是"}
  B --> C{"第三方资源多?"}
  C -->|是| D["放弃多线程<br/>用单线程 WASM"]
  C -->|否| E["全站启用 COOP/COEP"]
  C -->|"部分页面"| F["仅特定路由启用<br/>多线程功能"]

  style D fill:#f59e0b,color:#fff
  style E fill:#10b981,color:#fff
  style F fill:#6366f1,color:#fff

最实用的方案是 F:图像处理这种重计算页面单独路由启用 COOP/COEP,主站不变。

16.10.4 CDN 缓存策略

WASM 文件名带 hash 时,可以用最强的不变缓存:

# 文件名带 hash(my-lib-a3f2b1c.wasm)
Cache-Control: public, max-age=31536000, immutable

immutable 让浏览器永不重新验证缓存——即使用户按 F5 也直接用本地副本。配合 hash 的不可变文件名,永远不会有缓存陈旧的问题。

不带 hash 的入口文件(index.htmlapp.js)要相反:

Cache-Control: no-cache
# 或
Cache-Control: max-age=0, must-revalidate

确保入口文件总是最新——它包含对带 hash 资源的引用。这套两层策略(入口短缓存 + hash 资源永久缓存)是 SPA 的最佳实践,对 WASM 同样适用。

16.10.5 部署清单

flowchart LR
  A["WASM 部署"] --> B["1. CSP: wasm-unsafe-eval"]
  A --> C["2. CORS 头 + Content-Type: application/wasm"]
  A --> D["3. 文件名带 hash"]
  A --> E["4. immutable 缓存"]
  A --> F["5. 多线程? COOP/COEP"]
  A --> G["6. 监控 RUM 加载耗时"]

  style A fill:#6366f1,color:#fff

任何一项遗漏都可能在生产中暴露:CSP 错配让 WASM 无法加载、CORS 缺失增加 200ms 延迟、缓存错配让用户停在旧版本。部署清单嵌入 CI/CD——每次发版前自动检查响应头,避免回归。

16.11 浏览器调试与开发体验

WASM 调试曾经是开发者的痛点——浏览器 DevTools 只能看到字节码、变量名丢失、调用栈混杂。2023-2026 年这部分快速改善,理解当前最佳工具和工作流是日常效率的关键。

16.11.1 Chrome DevTools 的 WASM 调试能力

flowchart TD
  A["Chrome DevTools<br/>2026 状态"] --> B["源码级调试"]
  A --> C["变量观察"]
  A --> D["调用栈"]
  A --> E["性能分析"]

  B --> B1["DWARF 调试信息<br/>Rust 源码 + 行号"]
  C --> C1["Rust 类型显示<br/>Vec/HashMap 自然展示"]
  D --> D1["JS + WASM 混合栈<br/>跨边界跳转"]
  E --> E1["Performance 面板<br/>WASM 函数采样"]

  style A fill:#10b981,color:#fff

启用完整调试需要构建配置:

# 启用 DWARF 调试信息
RUSTFLAGS="-C debuginfo=2" wasm-pack build --dev

# 或者 wasm-pack 的 profiling 模式
wasm-pack build --profiling

DevTools 中安装 “C/C++ DevTools Support (DWARF)” 扩展(虽然名字带 C/C++,对 Rust 一样工作)——之后 .wasm 旁的 .wasm.debug 文件被自动识别,断点可以打在 .rs 源文件上。

16.11.2 调试工作流

sequenceDiagram
    participant D as 开发者
    participant DT as DevTools
    participant J as JS 代码
    participant W as WASM 代码

    D->>DT: 在 Sources 面板打开 .rs 源文件
    D->>DT: 点击行号设置断点
    D->>J: 触发功能
    J->>W: 调用 WASM 函数
    W->>DT: 命中断点
    DT->>D: 显示局部变量、调用栈
    D->>DT: Step over / Step into
    DT->>W: 单步执行
    W->>J: 函数返回
    J->>D: 完成

实际操作:

  1. 打开 DevTools → Sources 面板
  2. 在文件树中找到 wasm-bindgen 加载的 .rs 源文件(在 wasm:// 协议下)
  3. 点击行号设置断点
  4. 在 Console 中触发 WASM 调用
  5. 命中后查看 Scope 面板——Rust 局部变量已经按类型展示

16.11.3 console_error_panic_hook:调试的第一行

任何 wasm-bindgen 项目都应该启用 console_error_panic_hook——否则 panic 显示为 RuntimeError: unreachable executed,没有任何上下文:

#[wasm_bindgen(start)]
pub fn main() {
    console_error_panic_hook::set_once();
    // ... 业务初始化 ...
}

启用后 panic 会在 console 显示完整 Rust 调用栈:

panicked at src/lib.rs:42:5:
attempt to subtract with overflow
Stack:
  Error
      at imports.wbg.__wbg_new_abda76e883ba8a5f
      at process_data (src/lib.rs:42:5)
      ...

16.11.4 Performance 面板的 WASM 分析

Chrome DevTools 的 Performance 面板录制后,WASM 函数显示为绿色块(与 JS 的紫色区分):

gantt
    title Performance 录制的时间轴示意
    dateFormat X
    axisFormat %s

    section 主线程
    JS 调用栈 :a, 0, 50
    WASM 函数 :b, 30, 35
    JS 回调 :c, 70, 90

    section Worker
    WASM 计算 :d, 20, 60

启用 --profiling 模式构建后,WASM 函数名保留——性能瓶颈直接看到 Rust 函数名而不是 wasm-function[42]

16.11.5 IDE 集成:rust-analyzer + 浏览器联调

成熟的开发体验需要 IDE 和浏览器协同:

工具功能
rust-analyzerRust 端的智能提示、错误检查、重构
TypeScript LSPTS 端的类型检查(消费 wasm-pack 生成的 .d.ts)
Chrome DevTools运行时调试、性能分析
wasm-pack watch文件变化自动重建
Vite HMR浏览器自动刷新

完整开发循环:

  1. 在 VSCode 编辑 .rs 文件——rust-analyzer 实时检查
  2. 保存触发 wasm-pack build --dev(约 5-8 秒)
  3. Vite 检测 .wasm 变化,触发浏览器 HMR
  4. 浏览器自动刷新(保留页面状态)
  5. 在 DevTools Sources 设断点验证

整套循环时间 5-15 秒,已经接近纯 JS 开发的体验。

16.11.6 调试反模式

graph TD
  A["调试反模式"] --> B["1. 没启 console_error_panic_hook<br/>panic 看不到任何信息"]
  A --> C["2. 只构 release<br/>没有 debug 符号"]
  A --> D["3. 在 console.log 调试<br/>跨边界开销大"]
  A --> E["4. 不用 DevTools Performance<br/>盲目优化"]
  A --> F["5. 不监控 RUM<br/>开发机没问题=生产没问题"]

  style A fill:#ef4444,color:#fff

每条都是真实踩过的坑。修复:

  • panic_hook 是 lib 顶层 5 行代码,永远启用
  • 开发 dev profile,CI 跑 release(§8.12 介绍)
  • 用 DevTools 断点,不要在 Rust 中疯狂插 web_sys::console::log_1
  • Performance 面板是性能分析的标准——直觉优化经常错
  • 开发机环境不代表用户环境(§18.11 RUM)

16.11.7 持续提升的开发体验

WASM 的开发体验在 2024-2026 年快速改善——但仍有空间:

已成熟(2026)仍缺失
源码级断点调试时间旅行调试(rr-style)
Rust 类型显示wasm-bindgen 自动 hot reload
Performance 采样浏览器内置 wasm-tools
Console panic 栈wasm-bindgen-test 浏览器内运行

这些缺失项在社区讨论中——可以预期 2027-2028 年继续改善。当前阶段,掌握现有工具的用法就足以让 WASM 开发体验和纯 JS 接近。

16.12 WASM 与浏览器存储:IndexedDB 与 OPFS

WASM 模块经常需要持久化数据——保存用户编辑、缓存计算结果、同步会话状态。浏览器提供了多种存储 API,每种的语义和性能不同。WASM 与这些 API 的集成有专门的模式。

16.12.1 浏览器存储 API 全景

graph TD
  A["浏览器存储"] --> B["简单 KV"]
  A --> C["结构化"]
  A --> D["文件系统"]
  A --> E["缓存"]

  B --> B1["localStorage<br/>5-10MB 同步"]
  B --> B2["sessionStorage<br/>临时同步"]
  C --> C1["IndexedDB<br/>大容量 + 异步"]
  D --> D1["OPFS<br/>真实文件系统"]
  E --> E1["Cache API<br/>HTTP 缓存"]

  style B1 fill:#ef4444,color:#fff
  style C1 fill:#10b981,color:#fff
  style D1 fill:#6366f1,color:#fff
  style E1 fill:#f59e0b,color:#fff

各 API 容量与性能特征:

API容量同步/异步适用
localStorage5-10 MB同步配置 / 小状态
sessionStorage5-10 MB同步临时数据
IndexedDB数百 MB-GB异步主要持久化
OPFSGB 级同/异步混合大文件 / 数据库
Cache API视配额异步HTTP 资源缓存
WebSQL已废弃-不要用

16.12.2 WASM 与 IndexedDB 的集成

IndexedDB 是异步 KV 数据库——适合 WASM 应用持久化结构化数据。Rust 侧用 idb crate:

use idb::{Database, ObjectStore, TransactionMode};
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub async fn save_document(id: &str, content: &str) -> Result<(), JsValue> {
    let db = open_db().await?;
    let txn = db.transaction(&["docs"], TransactionMode::ReadWrite)
        .map_err(|e| JsValue::from_str(&e.to_string()))?;

    let store = txn.object_store("docs")
        .map_err(|e| JsValue::from_str(&e.to_string()))?;

    let value = serde_wasm_bindgen::to_value(&Document {
        id: id.to_string(),
        content: content.to_string(),
    })?;

    store.put(&value, Some(&JsValue::from_str(id)))
        .map_err(|e| JsValue::from_str(&e.to_string()))?;
    txn.commit().await
        .map_err(|e| JsValue::from_str(&e.to_string()))?;
    Ok(())
}

注意:IndexedDB 是异步的——WASM 的 async fn 通过 wasm-bindgen-futures 自然映射。同步 WASM 函数不能用 IndexedDB,必须改异步签名。

16.12.3 OPFS:浏览器中的真实文件系统

OPFS(Origin Private File System,2023 年广泛支持)让浏览器有了”本地文件系统”——比 IndexedDB 性能好 10x,且支持真正的随机访问:

// JS 侧获取 OPFS 根目录
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle('data.bin', { create: true });

// 同步访问(仅 Worker 内)
const accessHandle = await fileHandle.createSyncAccessHandle();
accessHandle.write(new Uint8Array([1, 2, 3]), { at: 0 });
accessHandle.close();

OPFS 的关键优势:

  • 真同步 API:在 Worker 中支持同步读写,不需要 Promise 链
  • 大文件支持:GB 级文件无问题
  • POSIX-like 语义:close/seek/truncate 等都有

WASM 与 OPFS 集成的典型场景:

graph LR
  A["WASM 模块<br/>(Worker 内)"] --> B["OPFS sync access"]
  B --> C["磁盘文件"]

  D["主线程"] --> E["IndexedDB"]
  E --> F["元数据 + 小数据"]

  C -.大数据.-> A
  F -.索引.-> A

  style B fill:#10b981,color:#fff
  style E fill:#6366f1,color:#fff

混合架构:OPFS 存大数据(图像、PDF、视频),IndexedDB 存元数据(文件列表、用户配置)。这是大型 Web 应用(如 Photopea、Excalidraw)的标准模式。

16.12.4 sql.js 与 OPFS:浏览器内的 SQLite

OPFS 让 SQLite 真正在浏览器内可用——sqlite-wasm 项目提供完整的 SQLite + OPFS 集成:

import { default as sqlite3InitModule } from '@sqlite.org/sqlite-wasm';

const sqlite3 = await sqlite3InitModule();

// 在 OPFS 上创建持久化数据库
const db = new sqlite3.oo1.OpfsDb('mydb.sqlite');

// 标准 SQL
db.exec("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)");
db.exec("INSERT INTO users (name) VALUES (?)", { bind: ['Alice'] });

const rows = db.exec("SELECT * FROM users", { rowMode: 'object' });

性能数据:

操作sql.js(IndexedDB 后端)sql.js(OPFS 后端)加速比
1000 INSERT850 ms95 ms9x
10000 SELECT2100 ms220 ms10x
1MB BLOB 写入600 ms50 ms12x

OPFS 后端让浏览器内 SQLite 接近原生性能——这是 WASM + OPFS 的杀手级应用之一。

16.12.5 配额管理与持久化

浏览器存储有配额——不同 API 共享同一池:

const estimate = await navigator.storage.estimate();
console.log(`使用:${estimate.usage} / ${estimate.quota}`);

默认配额是磁盘的 20-60%——但浏览器在磁盘紧张时会清理。请求”持久化”避免被清:

const isPersisted = await navigator.storage.persist();
if (isPersisted) {
    console.log('数据将持久保留');
}

persist() 提示用户授权——授权后浏览器不会主动清理这个 origin 的存储。重要业务数据必须请求持久化。

16.12.6 存储选型决策

flowchart TD
  A["WASM 需要持久化数据"] --> B{"数据类型?"}
  B --> C["小状态 < 5MB"]
  B --> D["结构化数据"]
  B --> E["大文件 / 二进制"]

  C --> F["localStorage<br/>同步简单"]
  D --> G{"查询复杂?"}
  G -->|简单 KV| H["IndexedDB"]
  G -->|SQL 查询| I["sqlite-wasm + OPFS"]
  E --> J["OPFS<br/>直接文件操作"]

  style F fill:#ef4444,color:#fff
  style H fill:#10b981,color:#fff
  style I fill:#6366f1,color:#fff
  style J fill:#10b981,color:#fff

90% 的 WASM 项目应该在 IndexedDB 或 OPFS 上选——localStorage 的同步阻塞和 5MB 上限都让它不适合现代应用。

16.12.7 工程实践

  • 数据迁移:localStorage → IndexedDB → OPFS 的演进路径,写迁移脚本
  • 离线优先:所有写操作先入 OPFS,后台同步到服务器
  • 加密存储:敏感数据用 SubtleCrypto 加密后再存(OPFS 数据可被同源代码读取)
  • 存储监控:监控配额使用率,超过 80% 主动清理或提示用户

这些模式让 WASM 应用能完整工作在离线状态——这是 PWA 时代 WASM 应用的标配能力。

16.13 WASM 与 Web Components:跨框架封装

Web Components(自定义元素 + Shadow DOM)是浏览器原生的组件标准——独立于 React/Vue/Angular 之外。把 WASM 功能封装为 Web Component,能跨任何框架使用,是 SDK / 嵌入式工具的最佳分发方式。

16.13.1 为什么用 Web Components 包装 WASM

graph TD
  A["WASM 功能分发"] --> B["纯 npm 包"]
  A --> C["框架特定组件"]
  A --> D["Web Component"]

  B --> B1["✓ 直接 API<br/>✗ 用户要写 UI"]
  C --> C1["✓ 框架友好<br/>✗ 一框架一份"]
  D --> D1["✓ 自带 UI + 跨框架<br/>需要 Shadow DOM 隔离"]

  style D fill:#10b981,color:#fff

Web Component 的优势:

  • 跨框架:React、Vue、Angular、纯 HTML 都能用
  • 样式隔离:Shadow DOM 防止 CSS 冲突
  • 生命周期清晰connectedCallback/disconnectedCallback
  • 属性双向绑定observedAttributes

16.13.2 实战:WASM 图像滤镜组件

import init, { apply_filter } from './my_wasm_lib';

class ImageFilter extends HTMLElement {
    static get observedAttributes() {
        return ['filter', 'src'];
    }

    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = `
            <style>canvas { max-width: 100%; }</style>
            <canvas></canvas>
        `;
        this.canvas = this.shadowRoot.querySelector('canvas');
        this.ctx = this.canvas.getContext('2d');
    }

    async connectedCallback() {
        if (!ImageFilter.wasmReady) {
            await init();
            ImageFilter.wasmReady = true;
        }
        await this.render();
    }

    async attributeChangedCallback(name, oldVal, newVal) {
        if (this.isConnected && oldVal !== newVal) {
            await this.render();
        }
    }

    async render() {
        const src = this.getAttribute('src');
        const filterName = this.getAttribute('filter') || 'identity';
        if (!src) return;

        // 加载图像
        const img = await this.loadImage(src);
        this.canvas.width = img.width;
        this.canvas.height = img.height;
        this.ctx.drawImage(img, 0, 0);

        // 应用 WASM 滤镜
        const imageData = this.ctx.getImageData(0, 0, img.width, img.height);
        const filtered = apply_filter(imageData.data, img.width, img.height, filterName);
        this.ctx.putImageData(new ImageData(new Uint8ClampedArray(filtered), img.width, img.height), 0, 0);
    }

    loadImage(src) {
        return new Promise(resolve => {
            const img = new Image();
            img.onload = () => resolve(img);
            img.src = src;
        });
    }
}

customElements.define('image-filter', ImageFilter);

使用:

<image-filter src="photo.jpg" filter="grayscale"></image-filter>
<image-filter src="photo.jpg" filter="blur"></image-filter>

任何 HTML 页面都能用——不依赖 React/Vue 等框架。

16.13.3 跨框架使用示例

graph LR
  A["Web Component<br/><image-filter>"] --> B["React 应用"]
  A --> C["Vue 应用"]
  A --> D["Angular 应用"]
  A --> E["纯 HTML"]

  style A fill:#10b981,color:#fff

每个框架的使用方式:

// React
function Gallery() {
    return <image-filter src="photo.jpg" filter="sepia" />;
}

// Vue 3
<template>
    <image-filter src="photo.jpg" :filter="currentFilter" />
</template>

// Angular(需要 CUSTOM_ELEMENTS_SCHEMA)
<image-filter [attr.src]="src" [attr.filter]="filter"></image-filter>

// 纯 HTML
<image-filter src="photo.jpg" filter="invert"></image-filter>

16.13.4 WASM Web Component 的设计要点

flowchart TD
  A["Web Component + WASM 设计"] --> B["1. WASM 单例加载"]
  A --> C["2. 属性而非 prop"]
  A --> D["3. 事件分发"]
  A --> E["4. Shadow DOM 隔离"]
  A --> F["5. 资源清理"]

  style A fill:#6366f1,color:#fff

每个要点的工程实现:

  • WASM 单例:所有组件实例共享一份 .wasm,避免重复加载
  • 属性而非 prop:Web Component 通过 HTML 属性配置(字符串),复杂数据用 property
  • 事件分发dispatchEvent(new CustomEvent('filtered', { detail: {...} })) 通知外部
  • Shadow DOM:CSS 完全隔离,避免与宿主样式冲突
  • 资源清理disconnectedCallbackwasmObj.free(),防泄漏

16.13.5 性能与生命周期

sequenceDiagram
    participant H as HTML 解析器
    participant E as 自定义元素
    participant W as WASM 单例

    H->>E: 创建 <image-filter>
    E->>E: constructor() (Shadow DOM)
    H->>E: connectedCallback()
    E->>W: ensureWasm()
    W-->>E: 单例返回(首次 200ms 后续 0ms)
    E->>E: render()

    H->>E: 属性变更
    E->>E: attributeChangedCallback()
    E->>W: 重新 render

    H->>E: 节点移除
    E->>E: disconnectedCallback()
    E->>W: free 资源

首次加载:200-500ms(含 WASM 编译)。后续实例:< 10ms(共享 WASM,仅做 DOM 操作)。

16.13.6 Web Component 的 SSR 挑战

Web Component 在 SSR(服务端渲染)场景有限制:

graph TD
  A["Web Component + SSR"] --> B{"挑战?"}
  B --> C["Shadow DOM 不能 SSR"]
  B --> D["WASM 不能在 Node.js 渲染"]
  B --> E["首屏 hydration 慢"]

  C --> F["用 Light DOM 退化"]
  D --> G["fallback 占位图"]
  E --> H["延迟加载非首屏组件"]

  style C fill:#ef4444,color:#fff

应对策略:

  • 关键 SEO 内容用普通 HTML,组件做交互增强
  • 占位符 + 渐进增强 + 客户端 hydration

16.13.7 Web Component 与 WASM 的工程价值

graph LR
  A["WASM Web Component 价值"] --> B["跨框架分发"]
  A --> C["独立升级"]
  A --> D["供应链清晰"]

  B --> B1["1 份代码 多框架可用"]
  C --> C1["升级组件不影响宿主"]
  D --> D1["WASM 文件 + JS 胶水<br/>明确边界"]

  style A fill:#10b981,color:#fff

业务价值:

  • 降低集成成本:发布 SDK 时不必为每个框架写一份
  • 降低升级风险:组件版本独立于业务代码升级
  • 明确权责:组件作者负责 WASM 实现 + 默认 UI,业务方负责使用

这种封装方式特别适合:地图组件、图表组件、视频播放器、付款表单——任何”复杂功能 + 跨框架使用”的场景。

16.14 移动浏览器的特殊考虑

桌面浏览器是 WASM 主要测试场景——但移动浏览器的约束完全不同。如果不专门考虑移动端,桌面调试 OK 的应用可能在 iPhone/Android 上崩溃或卡顿。

16.14.1 移动浏览器的核心约束

graph TD
  A["移动浏览器约束"] --> B["内存"]
  A --> C["CPU"]
  A --> D["电池"]
  A --> E["网络"]
  A --> F["平台限制"]

  B --> B1["iOS Safari ~1GB 进程上限<br/>低端 Android 256-512MB 可用"]
  C --> C1["移动 CPU 慢 3-10x 桌面"]
  D --> D1["持续 100% CPU 触发热降频"]
  E --> E1["4G/5G 不稳定<br/>大文件下载易失败"]
  F --> F1["iOS 限制 SharedArrayBuffer<br/>WebGPU 支持不全"]

  style A fill:#ef4444,color:#fff

每个约束都需要专门设计。

16.14.2 内存约束的应对

// 反模式:分配大缓冲区不释放
#[wasm_bindgen]
pub fn process_image(data: Vec<u8>) -> Vec<u8> {
    let mut buf = vec![0u8; data.len() * 4];  // 4x 内存
    // 处理...
    buf
}

// 推荐:原地处理,限制峰值
#[wasm_bindgen]
pub fn process_image_in_place(data: &mut [u8]) {
    // 原地处理,不额外分配
}

iOS Safari 的内存压力策略很严——超过 ~1GB 直接 kill 标签页。WASM 应用必须主动控制内存峰值。

16.14.3 性能约束的应对

flowchart TD
  A["移动 CPU 慢 3-10x"] --> B{"如何应对?"}

  B --> C["1. SIMD 仍然加速明显"]
  B --> D["2. 避免单帧 > 16ms 操作"]
  B --> E["3. 用 Web Worker 卸载主线程"]
  B --> F["4. 分帧处理大数据"]

  style A fill:#f59e0b,color:#fff

移动端的”卡顿”主要来自主线程阻塞——WASM 长任务挡住 UI。把 WASM 放进 Worker 是最有效的应对。

16.14.4 iOS 的特殊限制

graph TD
  A["iOS Safari 特殊限制"] --> B["1. SharedArrayBuffer 默认禁用"]
  A --> C["2. WebGPU Safari 17+ 才支持"]
  A --> D["3. Memory.maximum 限制更严"]
  A --> E["4. JIT 限制"]

  B --> B1["多线程 WASM 几乎不可用"]
  C --> C1["GPU 加速兼容性差"]
  D --> D1["maxMemory 必须显式声明"]
  E --> E1["TurboFan 等优化更保守"]

  style A fill:#ef4444,color:#fff

iOS 的限制让”为最好的 Android 优化”和”为 iPhone 兼容”成为不同任务。务实的工程选择:

  • 必备:所有 WASM 在单线程也能工作
  • 可选:多线程作为 progressive enhancement
  • 避免:依赖 SharedArrayBuffer 或 WebGPU 的核心功能

16.14.5 低端 Android 的应对

graph LR
  A["低端 Android"] --> B["RAM 2-4GB"]
  A --> C["CPU 4 核 1.5GHz"]
  A --> D["旧浏览器 Chrome 80+"]

  B --> E["内存峰值控制"]
  C --> F["分帧处理 + 降级"]
  D --> G["target-feature 谨慎"]

  style A fill:#f59e0b,color:#fff

低端 Android 的实际性能:

维度桌面低端 Android
WASM SIMD95ms480ms(5x 慢)
实例化时间5ms50ms
内存压力充足紧张

业务需要降级方案——例如图像处理在低端 Android 上用更小尺寸、更简单算法。

16.14.6 移动端测试策略

flowchart TD
  A["移动端 WASM 测试"] --> B["1. 真机测试(必备)"]
  A --> C["2. Chrome DevTools 设备模拟"]
  A --> D["3. 性能 throttle"]
  A --> E["4. 内存 throttle"]
  A --> F["5. 弱网测试"]

  style B fill:#10b981,color:#fff

模拟器只能反映部分情况——真机测试是必需的。最低必备:

  • iPhone 12 / 14(覆盖 90% iOS 流量)
  • Android 中端机(如 Pixel 6)
  • Android 低端机(如 99 元手机)

每次发布前必须在这 3 类设备上跑一遍。

16.14.7 移动端用户体验设计

graph TD
  A["移动端 UX"] --> B["加载提示"]
  A --> C["分阶段展示"]
  A --> D["可中断"]
  A --> E["降级方案"]

  B --> B1["WASM 加载有进度条<br/>不让用户等空白屏"]
  C --> C1["先展示能展示的<br/>WASM 异步增强"]
  D --> D1["长任务能用户取消"]
  E --> E1["WASM 加载失败<br/>fallback 到 JS 或服务端"]

  style A fill:#10b981,color:#fff

移动用户耐心比桌面用户低——3 秒加载白屏直接流失。WASM 应用必须从一开始就有占位 UI 和加载提示。

16.14.8 RUM 监控的移动端聚焦

// 按设备分类的性能监控
function reportPerf(metrics) {
    const ua = navigator.userAgent;
    const memory = navigator.deviceMemory;
    const connection = navigator.connection?.effectiveType;

    let category;
    if (/iPhone/.test(ua)) {
        category = 'mobile-ios';
    } else if (/Android/.test(ua)) {
        category = memory < 4 ? 'mobile-android-low' : 'mobile-android-mid';
    } else {
        category = 'desktop';
    }

    metrics.tags = { category, connection };
    sendToRUM(metrics);
}

按设备类别分聚 P95 数据——发现”低端 Android 用户加载慢 5 倍”才能针对性优化。

16.14.9 移动端工程清单

flowchart TD
  A["移动端 WASM 工程"] --> B["1. 内存峰值 < 200MB"]
  A --> C["2. 单帧任务 < 16ms"]
  A --> D["3. 不依赖 SharedArrayBuffer"]
  A --> E["4. 真机测试覆盖(iPhone+Android 多档)"]
  A --> F["5. 降级方案明确"]
  A --> G["6. 加载 UX 优化"]
  A --> H["7. RUM 按设备分类"]

  style A fill:#6366f1,color:#fff

每条都对应移动端的真实约束——遵循这套清单,WASM 应用才能在移动端真正落地。

16.14.10 桌面优先 vs 移动优先的取舍

flowchart TD
  A["WASM 应用定位"] --> B["桌面优先"]
  A --> C["移动优先"]
  A --> D["双端兼顾"]

  B --> B1["Figma / AutoCAD<br/>桌面体验为主"]
  C --> C1["TikTok 类<br/>移动为主"]
  D --> D1["内容工具类<br/>双端必须好用"]

  style D fill:#10b981,color:#fff

明确定位才能做正确的取舍——不要既想桌面满血又想移动顺滑,但实际只能优化其一。明确决定后,工程投入有针对性。

16.15 WASM 在浏览器扩展(MV3)中的开发

浏览器扩展(Chrome / Firefox / Edge)是 WASM 的一个独特应用场景——但 Manifest V3 的安全限制让 WASM 集成有特殊挑战。理解这些限制是开发扩展的前提。

16.15.1 浏览器扩展的 WASM 约束

graph TD
  A["MV3 扩展中的 WASM"] --> B["CSP 严格"]
  A --> C["无 eval / new Function"]
  A --> D["Service Worker 限制"]
  A --> E["权限明确"]

  B --> B1["默认 self 限制"]
  C --> C1["WASM 加载需 wasm-unsafe-eval"]
  D --> D1["MV3 用 Service Worker 替代背景页"]
  E --> E1["host_permissions 必须声明"]

  style A fill:#f59e0b,color:#fff

每条都是 MV3 的限制——MV2(旧版)更宽松但已被 Chrome 弃用。

16.15.2 manifest.json 配置

{
  "manifest_version": 3,
  "name": "My WASM Extension",
  "version": "1.0",
  "permissions": ["activeTab", "storage"],
  "host_permissions": ["https://*/*"],
  "content_security_policy": {
    "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
  },
  "background": {
    "service_worker": "background.js",
    "type": "module"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"]
    }
  ]
}

关键:wasm-unsafe-eval 必须在 CSP 中声明——否则 WASM 加载失败。

16.15.3 加载 WASM 的位置

graph TD
  A["扩展中加载 WASM 的位置"] --> B["background script"]
  A --> C["content script"]
  A --> D["popup / options 页"]

  B --> B1["Service Worker<br/>WASM 可加载"]
  C --> C1["注入到页面<br/>受页面 CSP 限制"]
  D --> D1["扩展页<br/>受扩展 CSP 限制"]

  style B fill:#10b981,color:#fff
  style C fill:#ef4444,color:#fff
  • background:最适合 WASM——独立进程,CSP 自己控制
  • content script:受目标页面 CSP 限制——可能加载失败
  • popup:扩展页面,受扩展 CSP 控制

90% 场景把 WASM 放在 background script 中。

16.15.4 实战:内容拦截扩展

// background.js
import init, { matchPattern } from './my_wasm/my_wasm.js';

let wasmReady = false;
init().then(() => { wasmReady = true; });

chrome.webRequest.onBeforeRequest.addListener(
    (details) => {
        if (!wasmReady) return;

        // 用 WASM 检查 URL 是否匹配规则
        const blocked = matchPattern(details.url);
        if (blocked) {
            return { cancel: true };
        }
    },
    { urls: ["<all_urls>"] },
    ["blocking"]
);

WASM 在 background 处理高性能模式匹配——比纯 JS 快 5-10x。

16.15.5 Service Worker 的特殊性

MV3 的 background 是 Service Worker——有特殊行为:

graph TD
  A["Service Worker 行为"] --> B["可被浏览器随时杀死"]
  A --> C["重启后状态丢失"]
  A --> D["不能用 setInterval"]

  B --> B1["WASM 实例丢失"]
  C --> C1["每次都要重新 init"]
  D --> D1["用 chrome.alarms 替代"]

  style A fill:#f59e0b,color:#fff

应对:

  • WASM init 用懒加载 + 单例模式
  • 状态外置到 chrome.storage
  • 定时任务用 chrome.alarms

16.15.6 与 content script 的通信

// content.js(注入到页面)
chrome.runtime.sendMessage(
    { action: 'process', data: pageData },
    (response) => console.log('WASM result:', response)
);

// background.js
chrome.runtime.onMessage.addListener(async (msg, sender, sendResponse) => {
    if (msg.action === 'process') {
        const wasm = await getWasmInstance();
        const result = wasm.process(msg.data);
        sendResponse(result);
        return true;  // 异步响应
    }
});

content script 不能直接访问 WASM——通过 message passing 调用 background 中的 WASM。

16.15.7 性能优化

flowchart TD
  A["扩展性能优化"] --> B["1. WASM 单例"]
  A --> C["2. 避免大数据 message"]
  A --> D["3. 批量请求"]
  A --> E["4. 缓存结果"]

  B --> B1["init 一次,跨多个 message 复用"]
  C --> C1["大数据用 chrome.storage 中转"]
  D --> D1["debounce 多个 message"]
  E --> E1["相同输入缓存输出"]

  style A fill:#6366f1,color:#fff

扩展每次 message passing 有序列化开销——批量处理 + 缓存结果是关键优化。

16.15.8 体积与权限的取舍

graph TD
  A["扩展体积考虑"] --> B["Chrome Web Store 上限"]
  A --> C["用户首次安装时间"]
  A --> D["更新成本"]

  B --> B1["单个扩展 < 100MB(实际建议 < 10MB)"]
  C --> C1["大扩展用户拒绝安装"]
  D --> D1["大扩展更新慢"]

  style A fill:#f59e0b,color:#fff

扩展的 WASM 应该极致优化体积——< 200KB 是理想,500KB 是上限。否则用户安装率会显著下降。

16.15.9 跨浏览器兼容

graph TD
  A["扩展跨浏览器"] --> B["Chrome(基础)"]
  A --> C["Edge(兼容 Chrome)"]
  A --> D["Firefox(差异较大)"]
  A --> E["Safari(最难)"]

  B --> B1["Manifest V3 主流"]
  D --> D1["WebExtensions API + 部分 MV3"]
  E --> E1["Safari Web Extensions 转换"]

  style A fill:#6366f1,color:#fff

Chrome / Edge 共用 Chromium 引擎——一份代码可跑。Firefox / Safari 需要单独适配。WASM 部分通常能复用,但 manifest 和 API 差异显著。

16.15.10 工程清单

flowchart TD
  A["浏览器扩展 + WASM"] --> B["1. CSP 加 wasm-unsafe-eval"]
  A --> C["2. WASM 放 background script"]
  A --> D["3. 体积 < 500KB"]
  A --> E["4. Service Worker 状态外置"]
  A --> F["5. message passing 设计简洁"]
  A --> G["6. 跨浏览器测试"]

  style A fill:#10b981,color:#fff

每条都是扩展开发的实战经验——遵循后能避免 80% 的常见问题。

WASM 在浏览器扩展中是独特的——既能发挥性能优势,又受 MV3 安全限制约束。理解这些约束让 WASM 扩展能稳定上架并被用户接受。

16.16 WASM 在浏览器游戏开发中的应用

游戏是 WASM 在浏览器中最早也最成熟的应用领域——Unity、Unreal、Godot 都支持 WASM 输出。理解游戏开发中的 WASM 模式对其他高性能 Web 应用都有借鉴。

16.16.1 浏览器游戏的技术栈

graph TD
  A["浏览器游戏"] --> B["渲染层"]
  A --> C["游戏逻辑"]
  A --> D["资源管理"]
  A --> E["音频"]
  A --> F["网络"]

  B --> B1["WebGL / WebGPU"]
  C --> C1["WASM"]
  D --> D1["File System Access / OPFS"]
  E --> E1["Web Audio / WebCodecs"]
  F --> F1["WebTransport / WebRTC"]

  style C fill:#10b981,color:#fff

WASM 的位置:游戏逻辑 + 物理 + AI——计算密集、性能关键的部分。

16.16.2 主流游戏引擎的 WASM 支持

引擎WASM 输出状态
Unity是(IL2CPP → WASM)成熟
Unreal Engine历史支持,2024 重启演进
Godot是(Mono 或原生)良好
Bevy原生 Rust → WASM成熟
Three.js + WASM自定义集成灵活

每个引擎的 WASM 输出体积差异巨大——Unity 项目 5-50MB,Bevy 1-5MB。

16.16.3 游戏循环的 WASM 实现

#[wasm_bindgen]
pub struct Game {
    state: GameState,
    last_frame: f64,
}

#[wasm_bindgen]
impl Game {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Game {
        Game {
            state: GameState::default(),
            last_frame: 0.0,
        }
    }

    pub fn frame(&mut self, now: f64) {
        let dt = now - self.last_frame;
        self.last_frame = now;

        // 更新物理(在 WASM 内)
        self.state.update_physics(dt);

        // 更新 AI
        self.state.update_ai(dt);

        // 准备渲染数据
        self.prepare_render_data();
    }

    pub fn render_data_ptr(&self) -> *const u8 {
        self.state.render_buffer.as_ptr()
    }
}

JS 侧驱动游戏循环:

function gameLoop(now) {
    game.frame(now);

    // 从 WASM 内存读渲染数据
    const ptr = game.render_data_ptr();
    const view = new Uint8Array(wasm.memory.buffer, ptr, RENDER_DATA_SIZE);

    // 提交给 WebGL
    submitToGPU(view);

    requestAnimationFrame(gameLoop);
}

requestAnimationFrame(gameLoop);

16.16.4 帧时间预算

graph TD
  A["60 FPS = 16.7ms/帧"] --> B["游戏逻辑(WASM)"]
  A --> C["渲染(GPU)"]
  A --> D["浏览器开销"]

  B --> B1["~5-8 ms"]
  C --> C1["~5-8 ms"]
  D --> D1["~2-3 ms"]

  style A fill:#10b981,color:#fff

WASM 部分必须在 5-8ms 完成——超过会掉帧。这是游戏开发的硬约束。

16.16.5 物理引擎的 WASM 化

物理引擎是 WASM 在游戏中的关键应用:

物理引擎语言WASM 性能
Box2D(C++)Emscripten良好
Rapier(Rust)原生 wasm32极佳
Bullet(C++)Emscripten良好

Rapier 是 Rust 写的——WASM 编译性能接近原生。1000+ 刚体物理模拟 60FPS 在浏览器中可行。

16.16.6 内存管理在游戏中的关键

flowchart TD
  A["游戏中的内存陷阱"] --> B["1. 每帧分配"]
  A --> C["2. 大对象池化"]
  A --> D["3. 资源加载策略"]
  A --> E["4. 内存峰值控制"]

  B --> B1["每帧 alloc → GC 抖动"]
  C --> C1["子弹、粒子等用 pool"]
  D --> D1["资源懒加载 + 预加载"]
  E --> E1["关卡切换释放"]

  style A fill:#f59e0b,color:#fff

每帧分配是性能杀手——游戏 WASM 代码必须几乎零分配

// 反模式:每帧分配
fn update(&mut self) {
    let mut new_bullets = Vec::new();  // 每帧 alloc
    for enemy in &self.enemies {
        if enemy.shoot() {
            new_bullets.push(Bullet::new(enemy.pos));
        }
    }
    self.bullets.append(&mut new_bullets);
}

// 推荐:池化
fn update(&mut self) {
    for enemy in &self.enemies {
        if enemy.shoot() {
            self.bullet_pool.spawn(enemy.pos);  // 复用对象池
        }
    }
}

16.16.7 资源加载策略

graph LR
  A["资源加载"] --> B["立即加载(首屏)"]
  A --> C["预加载(下个关卡)"]
  A --> D["懒加载(按需)"]

  B --> B1["核心 .wasm + 必要资源<br/>< 5MB"]
  C --> C1["背景预加载<br/>用户感知不到"]
  D --> D1["第一次用到时加载"]

  style A fill:#6366f1,color:#fff

游戏资源(图、音、模型)通常巨大(几十到几百 MB)——分级加载是关键。

16.16.8 WebGPU 集成

// 用 wgpu(Rust 的 WebGPU 封装)
use wgpu;

#[wasm_bindgen]
pub async fn init_renderer(canvas_id: &str) -> Renderer {
    let instance = wgpu::Instance::new(wgpu::Backends::all());
    let surface = create_surface(&instance, canvas_id);
    let adapter = instance.request_adapter(...).await.unwrap();
    let (device, queue) = adapter.request_device(...).await.unwrap();
    Renderer { device, queue, surface }
}

wgpu 是 Rust 的 WebGPU 抽象——同一份代码可在浏览器(WebGPU)和原生(Vulkan/Metal/DX12)跑。这是跨平台游戏的基础。

16.16.9 多人游戏的网络处理

flowchart LR
  A["玩家输入"] --> B["WASM 序列化"]
  B --> C["WebTransport 发送"]
  C --> D["服务端处理"]
  D --> E["状态广播"]
  E --> F["WASM 反序列化"]
  F --> G["渲染"]

  style B fill:#10b981,color:#fff
  style F fill:#10b981,color:#fff

WASM 处理序列化/反序列化——比 JS 快 5-10x。这是低延迟网络游戏的关键。

16.16.10 浏览器游戏的工程纪律

flowchart TD
  A["浏览器游戏 + WASM"] --> B["1. 帧时间预算严格"]
  A --> C["2. 几乎零分配"]
  A --> D["3. 资源分级加载"]
  A --> E["4. WebGPU + 兼容 WebGL"]
  A --> F["5. 移动端优先优化"]
  A --> G["6. 持续 profile"]

  style A fill:#10b981,color:#fff

每条都对应游戏开发的特殊约束——遵循后浏览器游戏能做到接近桌面级体验。

16.16.11 启示:高性能 Web 应用的通用模式

游戏开发的经验对其他高性能 Web 应用都适用:

  • 池化对象 → 减少 GC 压力
  • 零拷贝数据流 → 减少传输开销
  • 分级加载 → 优化首屏体验
  • WebGPU 集成 → 利用 GPU
  • 持续 profile → 性能可控

这些模式在图像编辑、视频处理、数据可视化等场景同样有效。

游戏是 WASM 性能的极限测试——能跑游戏的工程能力,可应用到任何高性能 Web 应用上。

16.17 跨书关联:与 React Fiber 的对比

Yew 的虚拟 DOM diff 和《React 19 内核探秘》第 3 章(Fiber 架构)的 reconciler 是同一类算法——都是”两棵树的最小编辑距离”。但实现差异显著:

flowchart TD
  subgraph "React Fiber"
    A1["Virtual DOM Tree"] --> B1["Fiber 节点<br/>含 expirationTime"]
    B1 --> C1["workLoop<br/>可中断"]
    C1 --> D1["每帧处理部分节点<br/>requestIdleCallback"]
    D1 --> E1["大组件树不阻塞主线程"]
  end

  subgraph "Yew"
    A2["Virtual DOM Tree"] --> B2["VNode 节点"]
    B2 --> C2["同步 diff"]
    C2 --> D2["单次遍历完所有节点"]
    D2 --> E2["大组件树可能阻塞主线程<br/>需要手动拆分"]
  end

  style C1 fill:#10b981,color:#fff
  style C2 fill:#ef4444,color:#fff
  • React:Fiber 树 + 可中断的 work loop → 支持时间切片(Time Slicing),大组件树的 diff 可以分多帧完成。React 的 Fiber 架构让 JS 引擎在 diff 过程中可以响应其他任务(用户输入、动画帧)——这是 React Concurrent Mode 的基础。
  • Yew:同步 diff → 简单高效,但大组件树可能阻塞主线程。WASM 的执行是原子的——一旦开始,JS 引擎无法中断它(没有 requestIdleCallback 等价物)。这意味着 Yew 的 diff 必须在一帧内完成——如果 diff 超过 16ms,页面会掉帧。

Yew 没有 Fiber 的”可中断 diff”——因为 WASM 的执行不能被 JS 的 requestIdleCallback 中断。这意味着 Yew 在大列表渲染时可能掉帧——一个解决方案是把大列表拆成小组件,减少单次 diff 的范围。

这也解释了为什么 Leptos 选择了 Signal 模型——Signal 不需要 diff,不需要 Fiber,每个 effect 只更新受影响的 DOM 节点。Signal 的更新粒度天然是”单个 DOM 属性”——比 Fiber 的”组件级并发更新”更细。但 Signal 也有缺点:effect 之间可能产生”钻石依赖”(多个 effect 依赖同一个 Signal),需要去重以避免重复更新——这是 SolidJS 和 Leptos 的运行时都要处理的问题。

下一章看服务器端的 WASM——边缘计算和插件系统。