Rust 前端框架深度对比:Leptos、Yew 与 Dioxus 的 WASM 架构

系统对比 Rust 前端三大框架 Leptos、Yew 与 Dioxus 的 WASM 架构:信号与响应式模型、虚拟 DOM 与细粒度更新、CSR 与 SSR/hydration、路由与组件模型、wasm-bindgen 与 DOM 交互开销、产物体积与首屏优化、与 JS 框架互操作,以及生产环境下的选型取舍。

导语:Rust 写前端的三种路线

把 Rust 编译到 WASM 直接操作 DOM,最初被视为「性能上的奢侈品」——一个 Hello World 的 .wasm 动辄几百 KB,任何一次点击都要跨 JS 边界。但三件事改变了局面:细粒度响应式模型让 DOM 更新不再依赖整棵虚拟 DOM 差分,SSR 与 hydration 把首屏还给服务端,wasm-bindgen 与 trunk/wasm-pack 把开发体验拉到可接受的水准。今天 Leptos、Yew、Dioxus 三条路线各有清晰的取舍。

本文不写「Hello World 教程」,而是把三个框架放在同一张解剖台上:它们如何表达状态、如何更新 DOM、如何做 SSR、如何与 JS 生态互操作、产物体积到底从哪里来。读完你应该能判断——你的项目该不该用 Rust 前端,以及如果要用,该选哪一个。

前置:WASM 基础、JS 边界、构建工具链。


目录


1. 三框架架构对比

1.1 设计哲学

三个框架的分歧从「如何知道该更新哪一块 DOM」开始:Yew 走 React 路线,用虚拟 DOM 差分;Leptos 与 Dioxus 走 Solid 路线,用信号(signal)做细粒度依赖追踪。

维度YewLeptosDioxus
更新模型虚拟 DOM 差分细粒度信号虚拟 DOM + 信号
模板语法rsx! 宏view! 宏rsx! 宏
SSR支持一等公民支持
全栈有限强(server fn)强(server fn)
移动端无无一等公民
成熟度高中高中
渲染路径对比(一次状态变化):

Yew    state → 重跑组件函数 → 新 VDOM → diff 旧 VDOM → patch DOM
Leptos state → 通知订阅该信号的 DOM 节点 → 直接 set_text / set_attr
Dioxus state → 标脏组件 → 重跑该组件 → diff 局部 VDOM → patch DOM

1.2 编译产物

三个框架都通过 trunk 或 dx 打包,最终产物是一份 .wasm 加一份 JS glue。

典型 release 产物体积(计数器应用,gzip 前):
  Yew     约 380 KB wasm + 45 KB js
  Leptos  约 290 KB wasm + 30 KB js
  Dioxus  约 340 KB wasm + 40 KB js
真实业务应用普遍在 800 KB ~ 2 MB 之间,压缩后约 300~600 KB。

体积差距主要来自运行时抽象层厚度,而不是框架「性能」本身。

一句话总结:Yew 是虚拟 DOM 派、Leptos 是细粒度信号派、Dioxus 是两者融合并押注多端;产物体积差异源于运行时抽象厚度,而非渲染性能。


2. 信号与响应式模型

2.1 信号基础

信号是「可被订阅的单元格」:读取即建立依赖,写入即通知订阅者。这是 Leptos 与 Dioxus 更新的核心。

use leptos::*;

#[component]
fn Counter() -> impl IntoView {
    let (count, set_count) = create_signal(0);

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

关键在于 {move || count.get()}:这是一个闭包而非求值结果。框架把它注册为订阅者,count 变化时只重跑这个闭包并更新那一个文本节点,组件函数不会重跑。

2.2 依赖追踪与更新粒度

// 派生信号:自动追踪依赖,无需显式声明依赖数组
let doubled = create_memo(move |_| count.get() * 2);

// 副作用:依赖变化时执行,可做 DOM 之外的同步
create_effect(move |_| {
    log!("count is now {}", count.get());
});

信号的代价是心智模型更陡:你必须理解「闭包在被调用时才读值」,否则会踩 count.get() 在组件函数体内直接求值导致永不更新的坑。

一句话总结:信号模型把更新粒度降到单节点,代价是必须理解「闭包延迟求值」;在组件函数体内直接 count.get() 会失去响应性。


3. CSR 渲染流程

3.1 挂载

纯客户端渲染(CSR)是最简单的形态:加载 wasm、执行 main、把组件挂到 body。

fn main() {
    console_error_panic_hook::set_once();      // panic 输出到控制台
    leptos::mount_to_body(App);                // 挂载根组件
}

3.2 更新路径

CSR 首次加载时序:
  HTML(空壳)→ 下载 js glue → 下载并编译 wasm → 执行 main
  → 创建 DOM → 首次 paint
关键指标:wasm 下载与编译时间往往超过执行时间,是首屏的主要瓶颈。

因此 CSR 场景下,产物体积与流式编译比运行时性能更值得优化(见第 7 章与流式实例化专题)。

一句话总结:CSR 的首屏瓶颈是 wasm 下载与编译而非渲染;务必等 init() 完成后再挂载,并在编译期启用体积优化。


4. SSR 与 hydration

4.1 服务端渲染

Leptos 与 Dioxus 支持把组件渲染成 HTML 字符串,服务端先返回完整 HTML,浏览器再「接管」。

// Leptos + Axum:服务端把 HTML 与序列化状态一起返回
use leptos_axum::{generate_route_list, LeptosRoutes};

let conf = get_configuration(None).await?;
let routes = generate_route_list(App);
let app = Router::new()
    .leptos_routes(&conf, routes, || view! { <App/> })
    .fallback(leptos_axum::file_and_error_handler(|| view! { <App/> }));

4.2 hydration 与不匹配

hydration 是「把静态 HTML 变成可交互」的过程:框架遍历已有 DOM,把事件监听器和信号订阅挂上去,而不重新创建节点。

hydration 不匹配的三大来源:
  1. 服务端与客户端分支条件不同(时间、随机数、localStorage)
  2. 服务端渲染的列表顺序与客户端不一致
  3. 第三方脚本在 hydration 前修改了 DOM
后果:框架要么报警告后丢弃整棵子树重建,要么出现事件绑定错位。

铁律:任何依赖浏览器环境的判断都不能参与服务端渲染输出,必须放进 create_effect 或 on_mount 回调,让它在 hydration 之后才执行。

一句话总结:SSR 先把 HTML 吐给用户、hydration 再接管;服务端与客户端渲染结果必须逐字节一致,环境相关逻辑一律延后到挂载后执行。


5. 路由与组件模型

5.1 路由

use leptos_router::*;

#[component]
fn App() -> impl IntoView {
    view! {
        <Router>
            <Routes>
                <Route path="/" view=Home/>
                <Route path="/post/:id" view=Post/>
                <Route path="/*any" view=NotFound/>
            </Routes>
        </Router>
    }
}

5.2 组件与 props

#[derive(Clone, PartialEq)]
struct CardProps { title: String, count: i32 }

#[component]
fn Card(#[prop(into)] title: String, #[prop(default = 0)] count: i32) -> impl IntoView {
    view! { <div class="card"><h3>{title}</h3><span>{count}</span></div> }
}
组件模型差异:
  Yew     函数组件 + props 结构体,类 React,生态组件最多
  Leptos  函数组件 + 宏参数,信号可直接跨组件传递,无需 context 传递
  Dioxus  函数组件 + props,强调跨平台(web/desktop/mobile 同一套组件)

三个框架都不需要「shouldComponentUpdate」这类手动优化,但 Yew 因为虚拟 DOM 仍需 #[derive(PartialEq)] 让 diff 短路。

一句话总结:三框架都是函数组件 + 宏 props;Yew 需要 PartialEq 优化 diff,Leptos 靠信号天然细粒度,Dioxus 强调一套代码多端复用。


6. wasm-bindgen 与 DOM 开销

6.1 边界成本

所有 Rust 前端最终都要通过 wasm-bindgen 调用 Web API。每次跨边界调用都有固定成本:参数编解码、JS 引擎进入/退出。

use web_sys::window;

// 每次调用都要把字符串从线性内存复制到 JS 堆
let title = window().unwrap().document().unwrap().title();
跨边界成本来源:
  1. 字符串/结构体需要序列化(复制 + 编解码)
  2. JS 对象在 Rust 侧是「不透明句柄」,访问属性要再次跨界
  3. 异常需要从 JS 异常转成 Rust Result

6.2 减少跨边界

// 正例:一次构造 DocumentFragment,最后只挂载一次
let frag = document.create_document_fragment();
for item in &items { frag.append_child(&make_node(item)).unwrap(); }
parent.append_child(&frag).unwrap();

细粒度信号框架天然倾向「精准更新单节点」,这恰好与「减少跨边界次数」的目标一致,是 Leptos 类框架在交互密集场景更省的原因之一。

一句话总结:跨边界成本主要来自字符串编解码与对象句柄;用 DocumentFragment 批量挂载、避免循环内逐元素操作,能把跨界次数降一个数量级。


7. 体积与首屏优化

7.1 体积来源

来源占比参考可削减
框架运行时30%~50%有限
序列化/格式化代码10%~20%高
panic 与调试信息10%~25%高
业务逻辑20%~40%视情况

7.2 优化手段

# Cargo.toml:为体积优化而非速度优化
[profile.release]
opt-level = "z"        # 优先体积
lto = true             # 链接时优化,跨 crate 内联与裁剪
codegen-units = 1      # 单编译单元,最大化优化
panic = "abort"        # 去掉 unwind 表
strip = true           # 剥离符号
首屏优化的三件事(按收益排序):
  1. 开启 gzip/brotli,CDN 传输压缩(wasm 压缩率通常 60%~70%)
  2. opt-level=z + lto + wasm-opt -Oz,产物体积可降 30%~50%
  3. 流式编译(instantiateStreaming)+ 预加载,把编译与下载重叠

不要为了体积把 panic="abort" 与调试符号同时打开——开发期保留 console_error_panic_hook,只有 release 才剥离。

一句话总结:体积优化的主力是 opt-level=z + LTO + wasm-opt -Oz,首屏的主力是 CDN 压缩与流式编译;二者叠加通常能把加载时间砍半。


8. 与 JS 框架互操作

8.1 在 JS 中挂载

Rust 组件可以被当作一个「黑盒组件」嵌进 React/Vue 页面。

#[wasm_bindgen]
pub fn mount_into(selector: &str) {
    let el = document().query_selector(selector).unwrap().unwrap();
    leptos::mount_to(el.unchecked_into(), App);
}
// React 侧:在 useEffect 里挂载,卸载时调用 Rust 暴露的 unmount
useEffect(() => {
  mount_into('#rust-root');
  return () => unmount();
}, []);

8.2 调用 JS

#[wasm_bindgen(module = "/src/analytics.js")]
extern "C" {
    fn track(event: &str, payload: &str);
}

// 也可以在 JS 里把回调传给 Rust,实现双向通信

边界越少越安全。整块嵌入几乎不产生长期维护成本,函数级互调则需要仔细管理回调句柄,否则极易泄漏。

一句话总结:优先「整块嵌入」,其次「事件总线」,最后才是函数级双向调用;回调句柄必须显式注销,否则内存与函数表都会泄漏。


9. 性能测量与对比

9.1 度量指标

前端性能必须分三段度量:
  加载:wasm 下载字节数、编译时间、首次可交互时间
  渲染:首次挂载耗时、hydration 耗时
  交互:单次事件处理耗时、1000 次更新的总耗时
只用「跑分」衡量框架性能是最常见的误区。

9.2 实测参考

场景YewLeptosDioxus
首屏可交互基准略快接近
1000 次列表更新慢最快快
大表单输入中快快
包体积最大最小中

一句话总结:性能要分「加载 / 渲染 / 交互」三段度量;交互密集场景信号模型领先,静态页面三者无本质差异。


10. 生产取舍与选型

10.1 决策树

要不要用 Rust 前端?
  已有成熟 JS 团队、需求以表单与 CRUD 为主 → 不要,收益不足以覆盖学习成本
  需要复用大量 Rust 业务逻辑 / 极致交互性能 → 值得
  需要一套代码覆盖 web + desktop + mobile → Dioxus

选哪个框架?
  生态与招人优先 → Yew
  全栈 SSR 与性能优先 → Leptos
  多端与长期路线优先 → Dioxus

10.2 踩坑清单

[ ] 组件函数体内直接 count.get() 求值 → 失去响应性
[ ] hydration 前访问 window/localStorage → 服务端与客户端不一致
[ ] 循环内逐元素操作 DOM → 跨边界开销爆炸
[ ] release 未开 lto 与 opt-level=z → 体积翻倍
[ ] 回调句柄未注销 → 内存与函数表泄漏
[ ] 把框架跑分当作项目性能结论 → 与真实业务无关

一句话总结:先判断「该不该用」,再按生态/全栈/多端三要素选框架;上线前用踩坑清单逐项自查,尤其注意响应性丢失与 hydration 不一致两类高频问题。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「wasm」更多文章

  1. WASM 模块测试与模糊测试:从单元测试到差分验证
  2. 浏览器扩展中的 WASM:MV3 约束、CSP 与生命周期实践
  3. WASM 流式编译与实例化优化:从首字节到可执行