Rust + WebAssembly 全链路解析

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

作者 杨艺韬 · 21,071 字 · 发布于 · 更新于

"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:#333
  style C fill:#6366f1,color:#fff
  style D fill:#f59e0b,color:#333

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

JS 框架负责 UI,WASM 只做计算密集的部分。这是目前最成熟的模式——Figma 的 C++ 渲染引擎、1Password 编成 WASM 的 Rust 核心、Google Earth 的 3D 渲染,都采用这种架构。

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

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

劣势:WASM 和 JS 之间的数据传递有开销——第 6 章已经拆过这条路径:i32 参数的跨边界调用几乎零开销(和一次普通 JS 函数调用同量级),而字符串和字节数组要经历"分配 + 复制 + UTF-8 编解码 + 释放",量级高出一到两档。如果接口设计不当,这个开销会吃掉 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)、产物体积通常比同等功能的 JS 应用大一档(Rust 侧要把格式化、panic 处理、集合类型等运行时代码一起编进 .wasm,具体大小取决于用了哪些 crate,第 9 章讲过如何裁剪)、调试困难(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:#2563eb,color:#fff
  style D fill:#6366f1,color:#fff
  style G fill:#f59e0b,color:#333

关键设计原则: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 尚未就绪,需要处理"加载中"状态。注意这个默认导出的 init 只有 wasm-pack build --target web 的产物才有;wasm-pack 默认的 --target bundler 产物没有 init(),而是在模块顶层直接 import * as wasm from "./xxx_bg.wasm"(wasm-bindgen 0.2.118 实测),实例化交给打包器——webpack 5 要开 experiments.asyncWebAssembly,Vite 8.0.3 内置只处理 .wasm?init 导入(见《Vite 设计与实现》第 17 章 §17.2),要加 vite-plugin-wasm(构建目标不支持顶层 await 时还要配 vite-plugin-top-level-await)。用 bundler 产物时去掉 init() 这一步,直接使用命名导出。

  2. 实例生命周期:ImageProcessor 实例存储在 useRef 中——不在 useState 中,因为它不需要触发重渲染。实例的生命周期和组件绑定——组件卸载时应该调用 processor.free() 释放 WASM 内存。wasm-bindgen 0.2.118 生成的胶水会用 FinalizationRegistry 兜底,JS 对象被 GC 后也会释放 Rust 侧内存,但回收时机不确定,不能代替显式 free()。

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

性能陷阱:序列化瓶颈

React 组件和 WASM 之间传数据的方式直接影响性能。跨边界调用的开销不在"调用"本身(纯数值参数几乎零成本),而在"数据传递"——每传一次字符串或数组,都要付一次分配、一次复制、一次释放。

graph LR
  subgraph "反模式:逐个调用"
    A1["JS 循环 N 次"] --> B1["N 次跨边界调用<br/>固定开销 × N"]
  end
  subgraph "正确模式:批量调用"
    A2["JS 传数组指针"] --> B2["1 次跨边界调用<br/>循环在 WASM 内部<br/>固定开销 × 1"]
  end
  style B1 fill:#dc2626,color:#fff
  style B2 fill:#10b981,color:#333

常见的反模式:

// ❌ 每帧调用 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 内部,减少跨边界调用次数。收益主要来自"N 份固定开销降为 1 份"——N 越大、单项数据越小,收益越明显;如果每项本身就是几百 KB,固定开销可以忽略,批量化就没什么意义。

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:#dc2626,color:#fff
  style G fill:#dc2626,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 3/4 的响应式模型和 Vue 不同——它使用编译时分析(而非运行时 Proxy)追踪依赖(下例是这套 $: 写法;Svelte 5 的 runes 改用运行时 signal,但 WASM 直接改内存同样不会被感知)。这对 WASM 集成意味着:

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

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

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

WASM 模块的加载策略

无论使用哪个 JS 框架,WASM 模块的加载都是一个必须面对的工程问题。浏览器下载和编译 .wasm 文件都需要时间,且两者都随文件大小线性增长(编译还取决于是否走 instantiateStreaming 的流式编译、以及浏览器是否命中了代码缓存)。如果 WASM 模块在关键渲染路径上,用户会看到明显的白屏——具体多久取决于文件大小、网络与设备,必须用 RUM 实测(见第 18 章 §18.11),不能照搬任何经验值。

flowchart TD
  A[页面加载] --> B{WASM 在关键路径?}
  B -->|是: 计算引擎模式| C[预加载: link rel=preload]
  B -->|否: 非关键功能| D[懒加载: 动态 import]
  C --> E["<link rel='preload'<br/>as='fetch' crossorigin<br/>href='module.wasm'>"]
  E --> F[浏览器提前下载<br/>不阻塞渲染]
  D --> G["const wasm = await<br/>import('./pkg/module.js')"]
  G --> H[用户触发时才加载<br/>减少首屏时间]
  style C fill:#10b981,color:#333
  style D fill:#f59e0b,color:#333

图里动态导入的是 wasm-bindgen 生成的 JS 胶水,而不是 .wasm 本身:浏览器原生 import 一个 .wasm 属于 ESM Integration 提案(2026-09-24 核对 WebAssembly/proposals 表,处于 Phase 3),目前要靠打包器支持。as='fetch' 的预加载要带 crossorigin,否则和胶水里 fetch() 发出的 CORS 模式请求匹配不上,会下载两次。

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

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

--target web 产物的 init() 是异步的,返回一个 Promise,天然适合懒加载(bundler 产物则由动态 import() 本身完成加载与实例化)。React 的 React.lazy 和 Vue 的 defineAsyncComponent 可以直接包装 WASM 模块的加载:

// React 懒加载 WASM 模块:先动态 import 模块,再等它的 init() 完成
// 以 --target web 产物为例:init 是默认导出,命名导出是各个 wasm 函数
const ImageEditor = React.lazy(async () => {
    const mod = await import('image-processor-wasm');
    await mod.default();            // init():下载 + 编译 .wasm
    return { default: () => <ImageEditorInner wasm={mod} /> };
});

// 使用处需要 Suspense 提供 fallback
// <Suspense fallback={<Spinner />}><ImageEditor /></Suspense>

注意不要把 Hook 写进 React.lazy 的工厂函数里——工厂只跑一次,返回的必须是一个正常的组件。

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:#333

Web Worker 中的 WASM 不受主线程帧预算限制——可以执行几百毫秒甚至更久的计算而不会导致 UI 卡顿。但 Worker 和主线程之间的数据传递有开销——postMessage 默认对 ArrayBuffer 做结构化克隆(structured clone),也就是整块复制,成本随数据量线性增长。

优化方案:使用 Transferable 对象(ArrayBuffer、ImageBitmap)——它们的所有权从发送方转移到接收方,不需要复制。发送方在转移后不能再访问该 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) => {
    // wasm-bindgen 的 &[u8] 参数要传 Uint8Array;返回的 Vec<u8> 是一份独立的 Uint8Array
    const result = wasm.process(new Uint8Array(e.data.data));
    // 转移列表里只能放 ArrayBuffer 等 Transferable,放 Uint8Array 本身会抛 DataCloneError
    self.postMessage({ type: 'result', data: result }, [result.buffer]);
};

跨专栏关联:与《Vite 设计与实现》专栏第 17 章(Web 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:#333
  style G fill:#f59e0b,color:#333

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::Increment → update 方法修改 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 {
    // props 是引用,闭包里要用的是它的 Copy 字段,先取出来再 move
    let id = props.id;
    let on_toggle = props.on_toggle.reform(move |_| id);
    let on_delete = props.on_delete.reform(move |_| 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),但没有 self 字段来承载状态——状态必须通过 use_state/use_ref 等 hook 管理。这和 React 的函数组件 + Hooks 模式完全一致。

Yew 的性能特征

Yew 的虚拟 DOM diff 在 WASM 中执行——纯计算部分有机会比 JS 版本快,因为 WASM 是静态类型的,不受 JIT 预热和去优化影响。但最终的 DOM 更新必须调用 JS 的 document.createElement/element.setAttribute——这些跨边界调用的开销在 Yew 中比在 React 中更高。

graph LR
  subgraph "React 路径"
    A1["JS diff"] --> B1["JS 直接调 DOM API<br/>引擎内部调用"]
    B1 --> C1["无跨边界开销"]
  end
  subgraph "Yew 路径"
    A2["WASM diff<br/>无 JIT 预热/去优化"] --> B2["每个 DOM 操作<br/>一次 web-sys 跨边界调用"]
    B2 --> C2["跨边界开销 × DOM 操作数"]
  end
  style C1 fill:#10b981,color:#333
  style C2 fill:#f59e0b,color:#333

这里的权衡是结构性的、而不是某个数字决定的:Yew 在 diff 阶段占优,在 commit 阶段吃亏。DOM 操作越密集(长列表整体重排、大量属性变更),跨边界调用的累积开销越可能吃掉 diff 的优势;反过来,diff 计算重、实际 DOM 变更少的场景,Yew 更划算。哪一侧胜出取决于具体的组件形状,必须在自己的场景里实测,不能靠"WASM 更快"这种直觉下结论。

Yew 的局限

  1. 无时间切片:Yew 的 diff 是同步的——大组件树的 diff 会一次跑完,期间主线程被占住。React 的 Fiber 架构把 diff 拆成可暂停的工作单元,Transition 等非阻塞优先级的更新可以分多帧完成(React 19 里默认优先级的更新同样同步跑完)。要澄清一个常见误解:这不是因为"WASM 不能被中断"——WASM 调用和同步 JS 函数调用一样,都跑到返回为止,浏览器谁也不会抢占。React 的可中断靠的是它自己主动让出(处理若干工作单元后 return 到宿主环境,下一帧再续),Yew 只是没有采用这种协作式调度。

  2. SSR 起步较晚:Yew 提供了 ServerRenderer(ssr feature)做服务端渲染,客户端用 Renderer::hydrate() 接管,功能是有的;但相比 Leptos 把 SSR/hydration 作为一等设计目标、并配套 server function 与流式渲染,Yew 这条链路的成熟度和配套要弱一截。

  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:#333
  style C2 fill:#10b981,color:#333

虚拟 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 中维护;这不等于依赖追踪一定更便宜——每次更新 DOM 仍要经 web-sys 跨一次边界,两者孰快要实测。

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:#333
  style D fill:#6366f1,color:#fff

Signal 的依赖追踪基于一个全局的"当前 effect"指针——当 count.get() 在某个 effect 内被调用时,Signal 把这个 effect 注册为自己的依赖。之后 Signal 值变化时,只通知注册的 effect。这个机制和 Vue 3 的 effect + track/trigger 系统同构,跨专栏关联可参考《Vue 3 设计与实现》专栏第 4、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>
    }
}

// 服务器端(leptos 开 ssr feature):渲染为字符串(实际项目里更多用 leptos_axum
// 提供的 LeptosRoutes 扩展与流式渲染 handler,这里取最直白的形式)
// 需要 use axum::response::{Html, IntoResponse};
async fn server_render() -> impl IntoResponse {
    let html = view! { <App/> }.to_html();
    Html(html)
}

// 客户端(leptos 开 hydrate feature):WASM 入口,接管服务端输出的 DOM
fn main() {
    leptos::mount::hydrate_body(App);
}

Leptos 0.8(本节按 0.8.20 核对)不给节点打 data-hk 这类 hydration key 属性:客户端 WASM 加载后按视图结构顺序遍历服务端输出的 DOM,逐个认领已有节点(动态文本、列表等片段借 <!> 注释标记定位)——跳过初始渲染,直接绑定事件处理器和 effect。

hydration 的核心挑战:服务器和客户端的初始值必须一致——否则会出现"闪烁"或认领错节点。真正需要跨端传递的是异步数据(Resource 的结果):服务端把它序列化进页面里的一段脚本,客户端 hydration 时反序列化复用,避免重新发一遍请求。纯本地 Signal(如上面的 count)两端各自初始化即可,不需要序列化。

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

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

web-sys 是 wasm-bindgen 仓库里的一个 crate(和 js-sys 一样由同一批维护者维护),提供浏览器 Web API 的 Rust 绑定——DOM、Canvas、WebSocket、Fetch、WebGL、WebGPU 等。它的绑定不是手写的,而是从 WebIDL 定义自动生成。Yew 和 Leptos 内部都依赖 web-sys 调用 DOM API。本章对照的是与 wasm-bindgen 0.2.118 配套的 web-sys/js-sys 0.3.95。WebGPU 这类规范仍在变动的接口在 web-sys 里标为不稳定,除了打开对应 feature,还要编译时传 --cfg=web_sys_unstable_apis(如 RUSTFLAGS=--cfg=web_sys_unstable_apis),否则这些类型根本不会生成。

按需引入

web-sys 的绑定代码量极大——所有 feature 全开时编译时间会显著变长。Rust 的 feature gate 机制实现了按需引入:

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

每个 feature 对应一组 Web API,只有启用的 feature 才会被编译。要注意这里省的主要是编译时间而不是产物体积:web-sys 的绑定几乎都是薄薄的 extern 声明,没被调用的部分会在链接期被 --gc-sections 和 LTO 清掉,不进入最终的 .wasm。真正会因为"多开 feature"而变大的是编译产物中的中间体和编译耗时——全量开 feature 的 cargo build 可能要多花几分钟。

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::JsCast trait——它按目标类型的 instanceof 做运行时检查,成功返回 Ok(T),失败把原值原样还回来(Err(Self),即转换前的那个类型),所以上面代码里的 .unwrap() 是我们自己加的,不是它内部 panic。同一 trait 还提供不做检查的 unchecked_into() 和只判断不转换的 is_instance_of::<T>()。

直接使用 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. 极致体积控制:框架运行时(组件系统、调度、diff 或 Signal 图)本身要占一块固定体积,直接用 web-sys 则几乎只剩自己的代码——web-sys 的调用会被展开成对导入函数的直接调用,不引入额外运行时。

web-sys 的性能开销

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

graph LR
  subgraph "直接 JS WebGL"
    A1["gl.bindVertexArray(vao)<br/>JS 引擎内部调用"]
  end
  subgraph "web-sys 调用"
    A2["gl.bindVertexArray(vao)<br/>WASM → JS 跨边界<br/>多一层 import 调用 + 参数转换"]
  end
  style A1 fill:#10b981,color:#333
  style A2 fill:#f59e0b,color:#333

单次差异很小,问题在于它按调用次数线性累积:几百次 draw call 时可以忽略,几万次时就可能吃掉一帧预算的可观比例。判断标准不是"差几倍",而是"每帧总共跨边界多少次"——用 DevTools Performance 面板实测自己的场景。

解决方案和第 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:#333

Rust 侧:让线性内存本身成为共享内存

这里有一个容易写错的方向:从 Rust 侧拿到一个 js_sys::SharedArrayBuffer 再用 get_index/set_index 逐字节读写——那样每次访问都是一次 WASM → JS 的跨边界调用,比复制一遍还慢,完全没有"零拷贝"可言。

正确的做法是让 WASM 模块自己的线性内存就是共享内存(WebAssembly.Memory 带 shared: true),这样 Rust 侧照常用普通的 &mut [u8] 访问,多个 Worker 里实例化的同一模块共享同一块内存。代价是要开启 atomics 目标特性,并且必须用 nightly 工具链重编 std。wasm-bindgen 仓库的 examples/raytrace-parallel 给出了完整的构建参数:

# 摘自 wasm-bindgen 0.2.118 的 examples/raytrace-parallel/package.json,
# 为便于阅读折了行、省掉了 --out-dir
wasm-pack build --target no-modules . \
  -Zbuild-std=std,panic_abort \
  --config "target.wasm32-unknown-unknown.rustflags='\
    -Ctarget-feature=+atomics \
    -Clink-args=--shared-memory \
    -Clink-args=--max-memory=1073741824 \
    -Clink-args=--import-memory \
    -Clink-args=--export=__wasm_init_tls \
    -Clink-args=--export=__tls_size \
    -Clink-args=--export=__tls_align \
    -Clink-args=--export=__tls_base'"

几个要点:+atomics 打开原子指令与线程局部存储;--shared-memory 让链接器把 memory 标记为 shared;--import-memory 让内存由 JS 侧创建后导入(这样主线程和各 Worker 才能拿到同一个 WebAssembly.Memory);--max-memory 是必需的,因为 shared memory 必须声明上限;最后那四个 --export=__tls_* / __wasm_init_tls 不能省——每个 Worker 里的线程都要靠它们自己分配并初始化一块 TLS 区域。-Zbuild-std 意味着这条路径目前仍需 nightly。

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 内核探秘》专栏第 9 章(Concurrent 特性)的关系——React 的并发渲染以约 5ms 的时间切片与主线程上的其他任务交替执行。如果 WASM 计算放在主线程上运行,一次同步调用期间 React 无法让出,会挤占 React 的调度空间——大组件的渲染可能因 WASM 计算占时过长而被延迟。把 WASM 放到 Web Worker 中是正确的架构选择——主线程只负责 React 渲染和 UI 更新,WASM 在 Worker 中做计算,通过 postMessage 或 SharedArrayBuffer 传递结果。

16.7 Yew vs Leptos 的深度对比

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

维度 Yew Leptos
更新模型 虚拟 DOM diff Signal 精确更新
状态管理 Component self + Msg signal(),嵌套状态用 reactive_stores 的 Store
SSR 支持(ssr feature + ServerRenderer) 一等公民(leptos_axum/leptos_actix 集成 + server function)
Hydration 支持(hydration feature + Renderer::hydrate) 支持,且与 SSR 深度耦合
社区规模 较大,起步更早 较小但增长快
UI 组件库 有少量第三方库,无事实标准 有少量第三方库,无事实标准
TypeScript 互操作 通过 wasm-bindgen 通过 wasm-bindgen
学习曲线 较低(React 开发者熟悉) 中等(Signal 概念需要适应)
编译速度 宏展开量大,增量编译体感偏慢 同样重度依赖宏,量级相近
.wasm 体积 两者都取决于用了哪些 crate 与裁剪配置,框架运行时本身不是主导项 同左

选择建议:

  • 现有 Yew 项目 → 继续用 Yew(迁移成本不值得)
  • 新项目 + SSR/hydration 是核心需求 → Leptos(配套最完整)
  • 新项目 + 纯 CSR → Leptos(Signal 模型省掉 diff,更新路径更短)
  • 新项目 + 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 -->|"是(SSR 是核心需求)"| 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:#333
  style D fill:#6366f1,color:#fff
  style I fill:#6366f1,color:#fff
  style K fill:#f59e0b,color:#333

核心原则:不要用 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:#333
  style G fill:#6366f1,color:#fff
  style H fill:#f59e0b,color:#333

四种 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,后续访问直接命中 Cache Storage,省掉整个网络往返——同时获得离线可用能力。

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', (event) => {
    const url = new URL(event.request.url);
    if (url.pathname === '/api/process') {
        event.respondWith((async () => {
            const wasm = await ensureWasm();
            // 裸导出只收数字,不能直接传 Uint8Array:先把输入拷进线性内存,再传指针和长度。
            // 这里假设模块导出了 memory、alloc(len) -> ptr,以及原地处理的 process(ptr, len)
            const { memory, alloc, process } = wasm.instance.exports;
            const input = new Uint8Array(await event.request.arrayBuffer());
            const ptr = alloc(input.length);
            new Uint8Array(memory.buffer, ptr, input.length).set(input);
            process(ptr, input.length);
            const result = new Uint8Array(memory.buffer, ptr, input.length).slice();
            return new Response(result, { headers: { 'content-type': 'application/octet-stream' } });
        })());
    }
});

这种模式让一些请求在客户端 SW 完成,不必触达服务器——降低网络延迟和服务器压力。但要记住 Service Worker 是随时可能被浏览器终止的:空闲一段时间就会被回收,长时间占用 CPU 也可能被强制结束(具体阈值各浏览器不同且不保证)。所以 SW 里的 WASM 实例必须能随时重建(懒加载 + 单例),并且不能把状态只留在内存里。

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 是 CSP 规范里为 WebAssembly 专设的 source 关键字——它只放开 WASM 的编译/实例化,不打开 JS 的 eval。这是比 'unsafe-eval' 安全得多的选择。主流浏览器都已支持,但具体起始版本各不相同,上线前查 MDN 的 CSP script-src 条目或 caniuse 确认你的兼容基线。

旧浏览器的兼容做法:

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——instantiateStreaming 会直接以 TypeError reject,代码只能回退到"先 arrayBuffer() 再 instantiate"的非流式路径,失去边下边编译的收益。wasm-bindgen 0.2.118 的 --target web 胶水就内置了这条回退,并在 console 打出 "WebAssembly.instantiateStreaming failed because your server does not serve Wasm with application/wasm MIME type" 的警告——看到它就该去修服务器配置。修复 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:#333
  style E fill:#10b981,color:#333
  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.html、app.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 直接加载失败、Content-Type 错配让流式编译失效、缓存错配让用户停在旧版本。部署清单嵌入 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:#333

启用完整调试需要构建配置,而且光靠 --dev 或 --profiling 是不够的——wasm-pack 0.14 的四个内置 profile(dev / release / profiling / custom)全都把 dwarf-debug-info 设为 false(src/manifest/mod.rs 的 default_dev()/default_release()/default_profiling()/default_custom() 分别定义在 282、295、308、321 行,dwarf_debug_info 那一行是 287、300、313、326),也就是不给 wasm-bindgen 传 --keep-debug(src/bindgen.rs:68-70),DWARF 段会被丢掉。必须在 Cargo.toml 里显式打开:

[package.metadata.wasm-pack.profile.dev.wasm-bindgen]
dwarf-debug-info = true
# rustc 侧生成 DWARF,wasm-pack 侧按上面的配置保留它
RUSTFLAGS="-C debuginfo=2" wasm-pack build --dev

DevTools 中安装 "C/C++ DevTools Support (DWARF)" 扩展(虽然名字带 C/C++,对 Rust 一样工作)——Rust 这条链路上 DWARF 是内嵌在 .wasm 里的,扩展直接从模块的自定义段读取,断点就可以打在 .rs 源文件上。(Chrome 文档里那套把调试信息拆成独立文件的做法用的是 Emscripten 的 -gseparate-dwarf,Rust + wasm-pack 走不到那条路。)第 18 章 §18.5 有完整的 profiling 构建配置。

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 在 Chrome/Node(V8)里只显示为 RuntimeError: unreachable(Firefox 的措辞是 unreachable executed),没有任何上下文:

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

启用后 panic 消息和发生位置会经 console.error 打出来,后面附一段 JS 的 Error().stack。下面是 Rust 1.96.0 + wasm-bindgen 0.2.118 的 dev 构建在 Node 24(与 Chrome 同为 V8)里实跑的输出,节选:

panicked at src/lib.rs:20:5:
attempt to subtract with overflow

Stack:

Error
    at __wbg_new_227d7c05414eb861 (.../demo.js:78:25)
    at demo.wasm.console_error_panic_hook::Error::new::h1e11921a73e419e8 (wasm://wasm/demo.wasm-0005a882:wasm-function[161]:0x9c62)
    at demo.wasm.console_error_panic_hook::hook_impl::h59856416d65660dc (wasm://wasm/demo.wasm-0005a882:wasm-function[64]:0x70d0)
    ...
    at demo.wasm.std[f839c7267e543978]::panicking::panic_with_hook (wasm://wasm/demo.wasm-0005a882:wasm-function[44]:0x6016)
    ...

几点要看清:栈里的 WASM 帧显示的是 name 段里的函数名加 wasm-function[N]:偏移,不是 .rs 行号(源码位置只出现在第一行 panic 消息里);V8 默认只保留 10 帧,这 10 帧大多花在 panic 机制自身,业务函数常常被截掉,需要时在 JS 侧调大 Error.stackTraceLimit;release 构建若被 wasm-opt 去掉了 name 段,函数名会退化成 wasm-function[N](见下一节)。

16.11.4 Performance 面板的 WASM 分析

Chrome DevTools 的 Performance 面板录制后,WASM 函数和 JS 函数一样出现在主线程或 Worker 的火焰图里:

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

要在这里看到 Rust 函数名而不是 wasm-function[42],.wasm 必须保留 name 段。这里有个容易踩空的地方:wasm-pack 的 profiling profile 确实不会让 wasm-bindgen 去掉 name 段,但它会接着跑 wasm-opt,参数是写死的 ["-O"](src/manifest/mod.rs:416 的 wasm_opt_args()),而 wasm-opt 不加 -g 时默认丢弃 name 段。所以要保住函数名,得把 wasm-opt 参数显式改成带 -g 的形式:

[package.metadata.wasm-pack.profile.profiling]
wasm-opt = ['-O', '-g']

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

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

工具 功能
rust-analyzer Rust 端的智能提示、错误检查、重构
TypeScript LSP TS 端的类型检查(消费 wasm-pack 生成的 .d.ts)
Chrome DevTools 运行时调试、性能分析
cargo watch -- wasm-pack build 文件变化自动重建(wasm-pack 本身没有 watch 子命令,用 cargo-watch 驱动)
Vite HMR 浏览器自动刷新

完整开发循环:

  1. 在 VSCode 编辑 .rs 文件——rust-analyzer 实时检查
  2. 保存触发 wasm-pack build --dev(增量重建,耗时取决于 crate 规模和依赖数量)
  3. Vite 检测到 wasm-pack 产物变化,按模块依赖触发 HMR 或整页刷新
  4. 浏览器更新页面(.wasm 会重新实例化,线性内存里的状态不会保留)
  5. 在 DevTools Sources 设断点验证

整套循环的耗时主要由 Rust 增量编译决定——小 crate 上已经接近纯 JS 的改完即见,依赖重的项目则明显更慢。要缩短它,优先减少每次改动触及的 crate 数量(把业务逻辑和绑定层拆开,见 §6.18.2 的 bindings 与 core 分离)。

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:#dc2626,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 开发体验和纯 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:#dc2626,color:#fff
  style C1 fill:#10b981,color:#333
  style D1 fill:#6366f1,color:#fff
  style E1 fill:#f59e0b,color:#333

各 API 容量与性能特征:

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

16.12.2 WASM 与 IndexedDB 的集成

IndexedDB 是异步 KV 数据库——适合 WASM 应用持久化结构化数据。Rust 侧用 idb crate(下例按 idb 0.6.5 在 wasm-bindgen 0.2.118 下编译通过;open_db() 与可序列化的 Document 结构体省略):

use idb::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()))?;
    // idb 0.6 的 commit() 先同步返回 Result<Transaction>,再 await 等事务完成
    txn.commit()
        .map_err(|e| JsValue::from_str(&e.to_string()))?
        .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)让浏览器有了一块"私有的本地文件系统"。它相对 IndexedDB 的关键优势不是某个固定倍数的吞吐,而是语义:支持字节级随机读写(seek/truncate),并且在 Worker 中提供同步句柄——这让"把一个按块寻址的数据库塞进浏览器"第一次变得现实。

// 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:#333
  style E fill:#6366f1,color:#fff

混合架构:OPFS 存大数据(图像、PDF、视频),IndexedDB 存元数据(文件列表、用户配置)。这是大型 Web 应用处理本地数据的常见分工。

16.12.4 sqlite-wasm 与 OPFS:浏览器内的 SQLite

OPFS 让 SQLite 真正在浏览器内可用——SQLite 官方的 sqlite-wasm(npm 包 @sqlite.org/sqlite-wasm)提供完整的 SQLite + OPFS 集成。它的 "opfs" VFS 依赖同步访问句柄,下面的代码必须跑在 Worker 里,而且页面要处于跨域隔离状态(COOP/COEP,见 §16.10.3);满足不了时 OpfsDb 不可用:

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'] });

// exec 默认返回 db 本身,要拿结果行得显式指定 returnValue
const rows = db.exec({ sql: "SELECT * FROM users", rowMode: 'object', returnValue: 'resultRows' });

为什么 OPFS 后端比早期基于 IndexedDB 的后端快得多,看它们各自的 I/O 形状就清楚了:

维度 IndexedDB 后端 OPFS createSyncAccessHandle 后端
读写粒度 以"KV 条目"模拟数据库页,一次页写变成一次事务 直接按偏移量读写文件字节
同步性 全异步,SQLite 的同步 VFS 要靠额外机制桥接 Worker 内同步 API,天然贴合 SQLite 的 VFS 接口
事务开销 每次刷盘走一遍 IndexedDB 事务 一次 write() / flush()

差距的量级会随工作负载(写多写少、页大小、是否批量事务)变化很大,官方文档也没有给出可通用引用的固定倍数——要为自己的场景取数就用 console.time 在目标浏览器上实测。方向是确定的:OPFS 后端让浏览器内 SQLite 第一次在真实工作负载下可用,这是 WASM + OPFS 的代表性应用。

16.12.5 配额管理与持久化

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

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

配额由浏览器按可用磁盘空间动态计算(Chromium 系一般是可用空间的一个较大比例,Firefox/Safari 各有自己的算法),不要写死任何数字——用上面的 estimate() 在运行时读。磁盘紧张时浏览器会按"最近最少使用"清理未持久化的 origin。请求"持久化"避免被清:

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

persist() 是否弹出授权提示因浏览器而异(Firefox 会询问用户,Chromium 按站点互动情况自动决定)——获准后浏览器不会主动清理这个 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:#dc2626,color:#fff
  style H fill:#10b981,color:#333
  style I fill:#6366f1,color:#fff
  style J fill:#10b981,color:#333

绝大多数 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:#333

Web Component 的优势:

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

16.13.2 实战:WASM 图像滤镜组件

import init, { apply_filter } from './my_wasm_lib.js';  // --target web 产物

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:#333

每个框架的使用方式:

// 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 完全隔离,避免与宿主样式冲突
  • 资源清理:disconnectedCallback 中 wasmObj.free(),及时释放(FinalizationRegistry 兜底的回收时机不确定)

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: 单例返回(首次含下载+编译,之后直接复用)
    E->>E: render()
    H->>E: 属性变更
    E->>E: attributeChangedCallback()
    E->>W: 重新 render
    H->>E: 节点移除
    E->>E: disconnectedCallback()
    E->>W: free 资源

首次加载要付一次下载 + 编译的成本;后续实例因为共享同一个已实例化的 WASM,只剩 DOM 操作,量级完全不同。这正是"WASM 单例"这条设计要点的价值所在。

16.13.6 Web Component 的 SSR 挑战

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

graph TD
  A["Web Component + SSR"] --> B{"挑战?"}
  B --> C["Shadow DOM 不能 SSR"]
  B --> D["依赖 DOM/Canvas 的 WASM<br/>逻辑在服务端跑不了"]
  B --> E["首屏 hydration 慢"]
  C --> F["用 Light DOM 退化"]
  D --> G["fallback 占位图"]
  E --> H["延迟加载非首屏组件"]
  style C fill:#dc2626,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:#333

业务价值:

  • 降低集成成本:发布 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["移动端单标签页内存上限远低于桌面<br/>超限直接被系统回收"]
  C --> C1["移动 CPU 单核性能明显弱于桌面<br/>且核间差异大"]
  D --> D1["持续满载触发热降频<br/>后期性能不如前期"]
  E --> E1["移动网络不稳定<br/>大文件下载易失败"]
  F --> F1["SharedArrayBuffer 依赖跨域隔离<br/>WebGPU 支持时间线各端不同"]
  style A fill:#dc2626,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]) {
    // 原地处理,不额外分配
}

移动端 Safari 的内存压力策略比桌面严得多——超过阈值不是变慢而是直接 kill 标签页,而且这个阈值随设备型号和当时的系统压力浮动,无法在代码里假定。WASM 应用必须主动控制内存峰值,并且在设计上留出余量。

16.14.3 性能约束的应对

flowchart TD
  A["移动 CPU 明显弱于桌面"] --> B{"如何应对?"}
  B --> C["1. SIMD 仍然加速明显"]
  B --> D["2. 避免单帧 > 16ms 操作"]
  B --> E["3. 用 Web Worker 卸载主线程"]
  B --> F["4. 分帧处理大数据"]
  style A fill:#f59e0b,color:#333

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

16.14.4 iOS 的特殊限制

graph TD
  A["iOS 上的额外约束"] --> B["1. SharedArrayBuffer 需跨域隔离"]
  A --> C["2. WebGPU 落地晚于 Chromium"]
  A --> D["3. 内存上限更紧"]
  A --> E["4. 所有浏览器共用同一套 Web 引擎"]
  B --> B1["COOP/COEP 配不下来<br/>多线程就用不了"]
  C --> C1["GPU 加速必须有 WebGL 兜底"]
  D --> D1["shared memory 必须声明 max-memory"]
  E --> E1["iOS 上的 Chrome/Firefox 也走 WebKit<br/>换浏览器绕不开限制"]
  style A fill:#dc2626,color:#fff

需要澄清两点常见误解:Safari 支持 SharedArrayBuffer,只是和其他浏览器一样要求页面处于跨域隔离状态(COOP/COEP)——真正的障碍是移动站点往往嵌了大量无法满足 COEP 的第三方资源。另外 Safari 的 JS 引擎是 JavaScriptCore(分层为 LLInt/Baseline/DFG/FTL),不是 V8 的 TurboFan,讨论它的优化行为时别用错名字。

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

  • 必备:所有 WASM 在单线程也能工作
  • 可选:多线程作为 progressive enhancement(运行时 typeof SharedArrayBuffer !== 'undefined' && crossOriginIsolated 探测后再启用)
  • 避免:把 SharedArrayBuffer 或 WebGPU 作为核心功能的唯一实现路径

16.14.5 低端 Android 的应对

graph LR
  A["低端 Android"] --> B["内存小"]
  A --> C["CPU 弱、核心少"]
  A --> D["系统自带浏览器版本旧"]
  B --> E["内存峰值控制"]
  C --> F["分帧处理 + 降级"]
  D --> G["target-feature 谨慎<br/>SIMD/threads 要探测"]
  style A fill:#f59e0b,color:#333

低端 Android 上,计算耗时和实例化耗时都会比桌面高出一个量级,而且离散度极大——同一份代码在不同机型上的差距,往往比"桌面 vs 移动"这个分类本身还大。所以不要用单一的基准数字做决策,要在 RUM 里按设备档位分桶看 P95(§16.14.8)。

业务需要降级方案——例如图像处理在低端 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:#333

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

  • 一台近几代的 iPhone(覆盖大部分 iOS 流量)
  • 一台 Android 中端机
  • 一台 Android 低端机(内存 ≤ 4GB)

具体机型按自己 RUM 里的真实机型分布挑,不要照抄别人的清单。每次发布前必须在这 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:#333

移动用户耐心比桌面用户低——白屏稍长就会流失。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 这一桶的加载耗时是整体均值的好几倍",进而针对性优化。混在一起的均值会把这类问题完全掩盖掉。

16.14.9 移动端工程清单

flowchart TD
  A["移动端 WASM 工程"] --> B["1. 设定并监控内存峰值预算"]
  A --> C["2. 单帧任务不超过帧预算"]
  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:#333

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

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:#333

每条都是 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:#333
  style C fill:#dc2626,color:#fff
  • background:最适合 WASM——独立进程,CSP 自己控制
  • content script:受目标页面 CSP 限制——可能加载失败
  • popup:扩展页面,受扩展 CSP 控制

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

16.15.4 实战:内容拦截扩展

这里有一个 MV3 迁移中最容易踩的坑:MV3 移除了阻塞式 webRequest(["blocking"] 选项在 MV3 下不可用,chrome.webRequest 只剩观测能力),拦截必须改用声明式的 declarativeNetRequest。也就是说,扩展不能在请求发起时同步调用一段 WASM 来决定放行与否——浏览器只认预先注册的规则。

可行的架构是把 WASM 用在"生成规则"这一侧:

// background.js(Service Worker)
import init, { compile_rules } from './my_wasm/my_wasm.js';

let wasmPromise = null;
function ensureWasm() {
    if (!wasmPromise) wasmPromise = init();
    return wasmPromise;
}

// 订阅源更新时,用 WASM 把过滤规则文本编译成 DNR 规则集
async function updateRules(filterListText) {
    await ensureWasm();
    const rules = compile_rules(filterListText); // WASM 做重活:解析 + 去重 + 优先级
    await chrome.declarativeNetRequest.updateDynamicRules({
        removeRuleIds: rules.removeIds,
        addRules: rules.add,
    });
}

manifest 里相应要加 "permissions": ["declarativeNetRequest"]。这样 WASM 承担的是"规则编译"这类计算密集、离线执行的工作,实际匹配交给浏览器的原生实现——既绕开了 MV3 的限制,性能也更好。

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:#333

应对:

  • 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((msg, sender, sendResponse) => {
    if (msg.action === 'process') {
        getWasmInstance().then((wasm) => sendResponse(wasm.process(msg.data)));
        return true;  // 同步返回 true,告诉浏览器稍后异步 sendResponse
    }
});

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["商店有包体上限,且体积直接影响安装转化"]
  C --> C1["大扩展用户拒绝安装"]
  D --> D1["大扩展更新慢"]
  style A fill:#f59e0b,color:#333

扩展的 WASM 应该极致优化体积。Chrome Web Store 的硬上限远高于实际需要(具体数值以商店文档为准),真正的约束是用户的安装意愿——扩展详情页会显示体积,包越大安装转化越低。第 9 章的裁剪手段(opt-level="z"、LTO、wasm-opt、panic=abort、去掉 format!)在这里全部适用。

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 service worker"]
  A --> D["3. 体积尽量压到几百 KB 级"]
  A --> I["3b. 拦截走 declarativeNetRequest<br/>不要用阻塞式 webRequest"]
  A --> E["4. Service Worker 状态外置"]
  A --> F["5. message passing 设计简洁"]
  A --> G["6. 跨浏览器测试"]
  style A fill:#10b981,color:#333

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

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:#333

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

16.16.2 主流游戏引擎的 WASM 支持

引擎 WASM 输出 状态
Unity 是(IL2CPP → C++ → Emscripten → WASM) 成熟,官方长期支持
Unreal Engine 官方 HTML5 支持已在 UE4 中期移除,之后靠第三方/实验分支 不建议作为主路径
Godot 是(官方 Web 导出,基于 Emscripten) 良好
Bevy 原生 Rust → wasm32-unknown-unknown 可用,随 Bevy 本身快速演进
Three.js + 自写 WASM 渲染用 JS,热点用 WASM 最灵活,集成成本自负

不同引擎的输出体积差异巨大——带完整运行时的通用引擎(Unity、Godot)通常是几 MB 到几十 MB 级别,而只编译自己代码的 Rust 引擎小得多。具体大小取决于用了多少引擎子系统和资源打包方式,必须按项目实测。

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["逻辑 + 物理 + AI"]
  C --> C1["draw call 提交与 GPU 执行"]
  D --> D1["合成、事件分发等固定开销"]
  style A fill:#10b981,color:#333

60 FPS 下每帧总预算约 16.7ms,三部分要挤在这个数里;具体怎么分配没有普适答案(GPU 重的场景渲染吃掉大头,模拟重的场景反过来),但总和不能越界是硬约束。工程做法是给逻辑帧设一个明确的预算上限并持续 profile,超了就降级(降低模拟频率、减少实体数、简化 AI)。

16.16.5 物理引擎的 WASM 化

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

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

Rapier 是纯 Rust 写的,直接编到 wasm32-unknown-unknown,不经过 Emscripten 这一层。它相比 Emscripten 移植的 C++ 引擎的优势主要在集成成本(不用 glue、不用 emscripten 运行时)和体积,纯计算性能上三者都受同一个 WASM 执行模型约束,差距远小于"原生 vs 浏览器"的差距。能同时模拟多少刚体取决于机器和场景复杂度,要在目标设备上实测。

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

flowchart TD
  A["游戏中的内存陷阱"] --> B["1. 每帧分配"]
  A --> C["2. 大对象池化"]
  A --> D["3. 资源加载策略"]
  A --> E["4. 内存峰值控制"]
  B --> B1["每帧 alloc → 分配器开销 + 碎片"]
  C --> C1["子弹、粒子等用 pool"]
  D --> D1["资源懒加载 + 预加载"]
  E --> E1["关卡切换释放"]
  style A fill:#f59e0b,color:#333

每帧分配是性能杀手——游戏 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 封装);具体构造函数签名随 wgpu 版本变化,
// 升级时以该版本的文档为准
use wgpu::{Instance, InstanceDescriptor};

#[wasm_bindgen]
pub async fn init_renderer(canvas_id: &str) -> Renderer {
    let instance = Instance::new(&InstanceDescriptor::default());
    let surface = create_surface(&instance, canvas_id);
    let adapter = instance.request_adapter(&Default::default()).await.unwrap();
    let (device, queue) = adapter.request_device(&Default::default()).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:#333
  style F fill:#10b981,color:#333

WASM 处理序列化/反序列化通常比 JS 更稳定:它没有 GC 停顿,也没有 JIT 预热和去优化带来的抖动——对帧同步类游戏来说,"最坏情况可预测"比"平均值更快"更重要。

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:#333

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

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

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

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

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

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

16.17 跨专栏关联:与 React Fiber 的对比

Yew 的虚拟 DOM diff 和《React 19 内核探秘》专栏第 3 章(Fiber 架构)的 reconciler 是同一类算法——都是比较新旧两棵树、产出 DOM 变更集,而且都用 O(n) 的启发式规则(同层比较、按 key 复用),并不去求真正的最小编辑距离。但实现差异显著:

flowchart TD
  subgraph "React Fiber"
    A1["Virtual DOM Tree"] --> B1["Fiber 节点<br/>含 lanes 优先级"]
    B1 --> C1["workLoop<br/>可中断"]
    C1 --> D1["处理若干工作单元后主动让出<br/>下一帧继续"]
    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:#333
  style C2 fill:#dc2626,color:#fff
  • React:Fiber 树 + 可中断的 work loop → 支持时间切片(Time Slicing),Transition 等非阻塞优先级下大组件树的 diff 可以分多帧完成(React 19 里默认优先级的更新走同步 work loop,不切片)。React 的 Fiber 架构让 JS 引擎在 diff 过程中可以响应其他任务(用户输入、动画帧)——这是 React Concurrent Mode 的基础。
  • Yew:同步 diff → 简单高效,但一次 diff 会一路跑完,期间主线程被占住。

需要澄清这里流传很广的一个错误解释:Yew 缺少可中断 diff 不是因为"WASM 不能被中断"。WASM 调用和同步 JS 函数调用在这一点上完全一样——浏览器不会抢占任何一种,两者都是跑到返回为止;requestIdleCallback 也从来不是"中断执行中的代码"的机制,它只是"下一次空闲时再调我"的调度回调。React 之所以能切片,是因为 reconciler 每处理若干工作单元就主动 return 回宿主环境,下一帧再从断点继续。Yew 完全可以走同样的路,只是它选择了实现简单得多的同步 diff。实际影响是:大列表整树 diff 有掉帧风险,缓解办法是把大列表拆成小组件、或用虚拟滚动减少单次 diff 的范围。

这也解释了为什么 Leptos 选择了 Signal 模型——Signal 不需要 diff,不需要 Fiber,每个 effect 只更新受影响的 DOM 节点。Signal 的更新粒度天然是"单个 DOM 属性"——比 Fiber 的"组件级并发更新"更细。但 Signal 也有缺点:依赖图里可能出现"钻石依赖"(一个 effect 经由两条派生路径依赖同一个 Signal),需要去重以避免重复更新或读到中间态——这是 SolidJS 和 Leptos 的运行时都要处理的问题。

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