导语: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 前端,以及如果要用,该选哪一个。
目录
- 1. 三框架架构对比
- 2. 信号与响应式模型
- 3. CSR 渲染流程
- 4. SSR 与 hydration
- 5. 路由与组件模型
- 6. wasm-bindgen 与 DOM 开销
- 7. 体积与首屏优化
- 8. 与 JS 框架互操作
- 9. 性能测量与对比
- 10. 生产取舍与选型
1. 三框架架构对比
1.1 设计哲学
三个框架的分歧从「如何知道该更新哪一块 DOM」开始:Yew 走 React 路线,用虚拟 DOM 差分;Leptos 与 Dioxus 走 Solid 路线,用信号(signal)做细粒度依赖追踪。
| 维度 | Yew | Leptos | Dioxus |
|---|---|---|---|
| 更新模型 | 虚拟 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 实测参考
| 场景 | Yew | Leptos | Dioxus |
|---|---|---|---|
| 首屏可交互 | 基准 | 略快 | 接近 |
| 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 不一致两类高频问题。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。