Rust + Yew 试水前端:一次真实的体验
Rust 写后端、写 CLI、写数据管道都很舒服,但前端呢?
React 和 Vue 不是不会用,而是每次写到状态管理和组件通信就浑身难受 —— JS 的动态类型在复杂状态面前让人没有安全感。
这次决定用 Yew 试试,原因很简单:如果前端也能用 Rust 写,那我是不是就不用学 JS 那套了?
试完之后,答案比我想的复杂。
为什么选 Yew,不选 Leptos 或 Dioxus
Rust 前端框架现在有三个主要选手:Yew、Leptos、Dioxus。
先说结论:我选 Yew,不是因为它最好,而是因为它最成熟。
| 框架 | 版本 | GitHub Stars | 特点 | 适合场景 |
|---|---|---|---|---|
| Yew | 0.23 | 32k+ | 类 React 的组件模型,社区最大 | Web 应用、SPA |
| Leptos | 0.8 | 20k+ | 细粒度响应式,全栈能力 | 全栈 Web 应用 |
| Dioxus | 0.7 | 36k+ | 跨平台(Web/桌面/移动端) | 多平台应用 |
Leptos 的响应式模型确实更现代,Dioxus 的跨平台野心也很大。但 Yew 的文档最全、社区最活跃、踩坑帖最多。对于一个前端新手来说,能搜到答案比框架本身的设计更重要。
说实话,如果你是前端老手转 Rust,Leptos 可能更顺手 —— 它的信号系统跟 Solid.js 很像。但如果你更熟悉 Rust 而不是前端生态,Yew 的类 React 模型反而更容易理解。
第一个 Yew 项目:五分钟跑起来
环境要求很简单,装好 Rust 就行:
# 安装 Trunk(Yew 推荐的构建工具)
cargo install trunk
# 添加 WASM 编译目标
rustup target add wasm32-unknown-unknown
# 创建项目
cargo new yew-playground
cd yew-playgroundCargo.toml 加上 Yew 依赖:
[package]
name = "yew-playground"
version = "0.1.0"
edition = "2024"
[dependencies]
yew = { version = "0.23", features = ["csr"] }index.html 放到项目根目录——Trunk 需要它作为入口:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Yew Playground</title>
</head>
<body></body>
</html>然后写 src/main.rs:
use yew::prelude::*;
#[function_component(App)]
fn app() -> Html {
html! {
<h1>{"Hello, Yew!"}</h1>
}
}
fn main() {
yew::Renderer::<App>::new().render();
}启动:
trunk serve浏览器打开 http://localhost:8080,看到 "Hello, Yew!" —— 整个过程不到三分钟。
这里有个细节值得注意:Trunk 干了 webpack 的活——编译 WASM、打包资源、热重载,全包了。你不需要配置任何 JS 工具链。对于被 node_modules 折磨过的人来说,这种清爽感很加分。
组件模型:Rust 的 struct + enum 替代了 JSX
Yew 的组件有两种写法:函数组件和结构体组件。现在主流用函数组件,跟 React Hooks 一个思路。
但我想先展示结构体组件,因为它更能体现 Rust 的类型系统怎么融入前端:
use yew::prelude::*;
// 用 enum 定义消息类型——这就是组件能接收的所有事件
enum Msg {
Increment,
Decrement,
}
struct Counter {
value: i32,
}
// Component trait 让这个 struct 成为一个 Yew 组件
impl Component for Counter {
type Message = Msg; // 组件能处理的消息类型
type Properties = (); // 父组件传入的 props
fn create(_ctx: &Context<Self>) -> Self {
Self { value: 0 }
}
// 所有状态变更都通过 update 方法
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::Increment => {
self.value += 1;
true // 返回 true 表示需要重新渲染
}
Msg::Decrement => {
self.value -= 1;
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
html! {
<div>
<button onclick={link.callback(|_| Msg::Decrement)}>{"-"}</button>
<span>{ self.value }</span>
<button onclick={link.callback(|_| Msg::Increment)}>{"+"}</button>
</div>
}
}
}第一眼看上去代码量比 React 多不少。但你注意到没有:
Msg是一个 enum,组件能处理的所有事件在编译期就确定了。
你不可能发一个组件不认识的消息——编译器会拦住你。
这跟 React 的 dispatch 不一样。React 的 action 是个普通对象,你可以随便写 { type: "FOOBAR" },运行时才会发现 reducer 不处理这个 type。Yew 直接在编译期消灭了这类 bug。
当然,现在大家更常用函数组件 + Hooks:
use yew::prelude::*;
#[function_component(Counter)]
fn counter() -> Html {
// use_state 返回一个智能指针,读写分离
let value = use_state(|| 0);
let increment = {
let value = value.clone();
Callback::from(move |_| value.set(*value + 1))
};
let decrement = {
let value = value.clone();
Callback::from(move |_| value.set(*value - 1))
};
html! {
<div>
<button onclick={decrement}>{"-"}</button>
<span>{ *value }</span>
<button onclick={increment}>{"+"}</button>
</div>
}
}函数组件确实简洁很多。但有个地方让我很不习惯:每次要回调里修改状态,都得 .clone() 一次。 这是因为 Rust 的所有权系统 —— value 被 move 进闭包之后,原来的绑定就失效了,所以你得先 clone 一份。
写惯了 JS 的人可能觉得这是噪音。但换个角度想:Rust 在告诉你,这个状态被多个地方共享了。 你需要意识到这件事,因为并发修改状态是前端最常见的 bug 来源之一。
真正的项目:写个 TODO 应用
Hello World 不能说明问题。我花了半天写了个 TODO 应用——前端界的 "Hello World Plus",有增删改查、过滤、本地存储。
先定义数据模型:
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
struct Todo {
id: u32,
title: String,
completed: bool,
}
#[derive(Clone, Debug, PartialEq)]
enum Filter {
All,
Active,
Completed,
}这里用了 serde 做序列化 —— 等下存 localStorage 的时候需要。Rust 的 derive 宏真的很爽,一行搞定 JS 里要写一堆的序列化逻辑。
主组件的状态设计:
use web_sys::window;
use gloo_storage::Storage;
#[function_component(TodoApp)]
fn todo_app() -> Html {
let todos = use_state(|| load_todos());
let filter = use_state(|| Filter::All);
let next_id = use_state(|| {
todos.iter().map(|t| t.id).max().unwrap_or(0) + 1
});
// 每次 todos 变化,自动存到 localStorage
{
let todos = todos.clone();
use_effect_with(todos.clone(), move |todos| {
save_todos(&**todos);
|| ()
});
}
// ... 渲染逻辑
}
fn load_todos() -> Vec<Todo> {
gloo_storage::LocalStorage::get("yew-todos").unwrap_or_default()
}
fn save_todos(todos: &[Todo]) {
gloo_storage::LocalStorage::set("yew-todos", todos).expect("failed to save");
}注意 gloo-storage 这个 crate —— 它是 Yew 生态里操作浏览器 API 的工具库。Yew 自己不管浏览器 API,这部分由 gloo 系列 crate 负责。 这种模块化设计我觉得很 Rust:每个 crate 只干一件事。
添加 TODO 的逻辑:
let on_submit = {
let todos = todos.clone();
let next_id = next_id.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
let title = input.value().trim().to_string();
if title.is_empty() {
return;
}
let mut new_todos = (*todos).clone();
new_todos.push(Todo {
id: *next_id,
title,
completed: false,
});
todos.set(new_todos);
next_id.set(*next_id + 1);
input.set_value("");
})
};这段代码有几个 Rust 前端独有的特点:
e.prevent_default()——跟 JS 的e.preventDefault()一个意思,但通过web_sys调用。类型安全,不会有拼写错误。e.target_unchecked_into()——把EventTarget转成HtmlInputElement。这里用了unchecked,因为 Yew 的事件系统不知道 target 是什么类型。说实话这个 API 设计我觉得不太优雅,但在 Web 的动态类型世界里,这已经是能做到的最好程度了。input.value().trim().to_string()——从 DOM 读值、清理、转成 Rust 的String。每一步都有明确的类型转换,不像 JS 那样随便拿来就用。
组件通信:Props 和 Callback
Yew 的组件通信模式跟 React 几乎一样:父组件通过 Props 传数据,子组件通过 Callback 传事件。
// 子组件:单个 TODO 项
#[derive(Properties, PartialEq)]
pub struct TodoItemProps {
pub todo: Todo,
pub on_toggle: Callback<u32>,
pub on_delete: Callback<u32>,
}
#[function_component(TodoItem)]
fn todo_item(props: &TodoItemProps) -> Html {
let todo = &props.todo;
let id = todo.id;
let on_toggle = {
let on_toggle = props.on_toggle.clone();
Callback::from(move |_| on_toggle.emit(id))
};
let on_delete = {
let on_delete = props.on_delete.clone();
Callback::from(move |_| on_delete.emit(id))
};
html! {
<div class="todo-item">
<input
type="checkbox"
checked={todo.completed}
onchange={on_toggle}
/>
<span class={if todo.completed { "completed" } else { "" }}>
{ &todo.title }
</span>
<button onclick={on_delete}>{"×"}</button>
</div>
}
}父组件这样用:
// 在 TodoApp 的 view 方法里
{for filtered_todos.iter().map(|todo| {
html! {
<TodoItem
key={todo.id}
todo={todo.clone()}
on_toggle={on_toggle.clone()}
on_delete={on_delete.clone()}
/>
}
})}Callback::clone() 满天飞 —— 这是我写 Yew 最大的体感噪音。每个要传给子组件的回调都得 clone,因为 Props 会消费掉它。
React 里你直接传个箭头函数就行,JS 的闭包没有所有权问题。但在 Rust 里,你得明确地管理每个值的生命周期和所有权。这是 Rust 前端的代价,也是它的保证。
样式:CSS-in-Rust 还是普通 CSS?
Yew 对样式没有特殊要求,你可以直接用普通 CSS。index.html 里引入就行:
<link data-trunk rel="css" href="style.css" />但如果你想用 CSS-in-Rust 的方案,stylist 这个 crate 不错:
use stylist::yew::styled_component;
#[styled_component(TodoItem)]
fn todo_item(props: &TodoItemProps) -> Html {
let css = css! {
r#"
display: flex;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid #eee;
.completed {
text-decoration: line-through;
color: #999;
}
"#
};
html! {
<div class={css}>
// ...
</div>
}
}我个人的选择:用普通 CSS。 原因很简单——前端同事能看懂。如果你用 CSS-in-Rust,整个样式逻辑都锁在 Rust 代码里,前端团队想改个样式都得会 Rust。除非你的项目是纯后端团队在维护,否则没必要。
最让我惊喜的三件事
1. 编译期的状态安全
// 这段代码编译不过——Msg::Toggle 不接受参数
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::Toggle(id) => { // ✅ 正确:Toggle 带 id
// ...
}
Msg::Toggle => { // ❌ 编译错误:模式不匹配
// ...
}
}
}React 里你发个 { type: "toggle" } 和 { type: "toggle", id: 1 } 都能跑,区别只在 reducer 里能不能正确处理。Yew 里这种错误在 cargo build 阶段就被抓住了。
前端的状态管理 bug 有 80% 是这类"事件类型不匹配"的问题,Rust 直接在编译期消灭了。
2. 没有 undefined is not a function
写 JS 最怕的就是运行时的 TypeError: Cannot read property 'xxx' of undefined。在 Rust 里,所有可能为空的值都用 Option<T> 包着,编译器逼你处理每一种情况:
// 你必须处理 None 的情况,不然编译不过
fn get_todo_by_id(todos: &[Todo], id: u32) -> Option<&Todo> {
todos.iter().find(|t| t.id == id)
}
// 使用时必须 match 或 unwrap
match get_todo_by_id(&todos, target_id) {
Some(todo) => { /* 处理找到的情况 */ }
None => { /* 处理没找到的情况 */ }
}这种强制性一开始会觉得啰嗦,但用了一段时间之后我发现:我几乎没有遇到运行时错误。 所有我能想到的边界情况,编译器都提前帮我找到了。
3. 生态比我想的好
本以为 Yew 的生态会很荒凉,但实际上常用的都有:
| 需求 | Crate | 说明 |
|---|---|---|
| 路由 | yew-router |
基本够用,支持嵌套路由 |
| HTTP 请求 | reqwest + gloo-net |
reqwest 支持 WASM,gloo-net 更轻量 |
| 浏览器 API | gloo 系列 |
localStorage、Timer、Console 等 |
| 状态管理 | yewdux |
类 Redux,支持全局状态 |
| CSS-in-Rust | stylist |
用 Rust 写 CSS,可选 |
| 组件库 | yew-mdc、yewprint |
Material Design 等 UI 组件 |
不算丰富,但覆盖了大多数常见需求。最缺的是成熟的 UI 组件库——不像 React 有 Ant Design、MUI 那种量级的选择。
最让我头疼的三件事
1. 编译速度
$ time trunk serve
# 首次编译:45 秒
# 增量编译:8-12 秒每次改一行代码,等 8-12 秒才能看到效果。 对比 Vite 的毫秒级热更新,这个体验落差是真实的。
原因很清楚:Rust → WASM 的编译链条比 JS → JS 长得多。这不是 Yew 的问题,是 Rust 编译 WASM 的通用痛点。
我试过 trunk serve --release,编译更慢但产出更小。开发模式下还是用默认的 debug profile 吧。
2. 调试体验
浏览器的 DevTools 对 WASM 的支持还很初级。你不能像调试 JS 那样打断点、看变量值。调试主要靠:
// 最原始但最有效的方法
gloo::console::log!("state updated:", format!("{:?}", self.state));
// 或者用 web_sys 的 console API
web_sys::console::log_1(&"debug info".into());说实话,这跟 JS 的 console.log 没什么区别,但你失去了 Chrome DevTools 的断点调试、变量查看、调用栈追踪等能力。这是目前最大的开发体验短板。
3. "Rust 思维"和"前端思维"的冲突
最大的不适不是语法层面的,而是思维方式的:
// 前端思维:用户点按钮 → 改状态 → 重新渲染
// 这是单向数据流,React/Yew 都是这么设计的
// 但 Rust 思维会问:
// - 这个状态的所有权归谁?
// - 这个回调的生命周期是什么?
// - clone 的开销大吗?在 React 里,你不需要想这些问题。JS 的 GC 帮你处理了一切。但在 Rust 里,每个值的生命周期和所有权都是你自己的责任。这在后端是优势(性能可控),在前端是额外的心智负担。
写了一阵子才适应:不要试图用 Rust 后端的思维写前端。 接受 clone、接受 Rc、接受看起来"不够 Rust"的写法。前端的性能瓶颈不在 clone 一个 10 字节的 struct 上。
完整的 TODO 应用代码
把所有部分串起来,这是一个能跑的 TODO 应用:
use gloo_storage::Storage;
use serde::{Deserialize, Serialize};
use web_sys::HtmlInputElement;
use yew::prelude::*;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
struct Todo {
id: u32,
title: String,
completed: bool,
}
#[derive(Clone, Debug, PartialEq)]
enum Filter {
All,
Active,
Completed,
}
#[function_component(TodoApp)]
fn todo_app() -> Html {
let todos = use_state(|| -> Vec<Todo> {
gloo_storage::LocalStorage::get("yew-todos").unwrap_or_default()
});
let filter = use_state(|| Filter::All);
let input_ref = use_node_ref();
// 自动保存到 localStorage
{
let todos = todos.clone();
use_effect_with(todos.clone(), move |todos| {
let _ = gloo_storage::LocalStorage::set("yew-todos", &**todos);
|| ()
});
}
let next_id = todos.iter().map(|t| t.id).max().unwrap_or(0) + 1;
// 添加 TODO
let on_submit = {
let todos = todos.clone();
let input_ref = input_ref.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
let input = input_ref.cast::<HtmlInputElement>().unwrap();
let title = input.value().trim().to_string();
if title.is_empty() {
return;
}
let mut new_todos = (*todos).clone();
new_todos.push(Todo {
id: next_id,
title,
completed: false,
});
todos.set(new_todos);
input.set_value("");
})
};
// 切换完成状态
let on_toggle = {
let todos = todos.clone();
Callback::from(move |id: u32| {
let mut new_todos = (*todos).clone();
if let Some(todo) = new_todos.iter_mut().find(|t| t.id == id) {
todo.completed = !todo.completed;
}
todos.set(new_todos);
})
};
// 删除 TODO
let on_delete = {
let todos = todos.clone();
Callback::from(move |id: u32| {
let new_todos: Vec<Todo> = (*todos).iter().filter(|t| t.id != id).cloned().collect();
todos.set(new_todos);
})
};
// 过滤
let filtered: Vec<&Todo> = match *filter {
Filter::All => todos.iter().collect(),
Filter::Active => todos.iter().filter(|t| !t.completed).collect(),
Filter::Completed => todos.iter().filter(|t| t.completed).collect(),
};
let set_filter = |f: Filter| {
let filter = filter.clone();
Callback::from(move |_| filter.set(f.clone()))
};
html! {
<div class="todo-app">
<h1>{"Yew TODO"}</h1>
<form onsubmit={on_submit}>
<input
ref={input_ref}
type="text"
placeholder="What needs to be done?"
/>
</form>
<div class="filters">
<button onclick={set_filter(Filter::All)}>{"All"}</button>
<button onclick={set_filter(Filter::Active)}>{"Active"}</button>
<button onclick={set_filter(Filter::Completed)}>{"Completed"}</button>
</div>
<div class="todo-list">
{for filtered.iter().map(|todo| {
let id = todo.id;
html! {
<div key={id} class="todo-item">
<input
type="checkbox"
checked={todo.completed}
onchange={let on_toggle = on_toggle.clone();
move |_| on_toggle.emit(id)}
/>
<span class={if todo.completed { "completed" } else { "" }}>
{ &todo.title }
</span>
<button onclick={let on_delete = on_delete.clone();
move |_| on_delete.emit(id)}>
{"×"}
</button>
</div>
}
})}
</div>
<div class="count">
{format!("{} items left", todos.iter().filter(|t| !t.completed).count())}
</div>
</div>
}
}
fn main() {
yew::Renderer::<TodoApp>::new().render();
}依赖配置:
[dependencies]
yew = { version = "0.23", features = ["csr"] }
gloo-storage = "0.3"
serde = { version = "1", features = ["derive"] }
web-sys = "0.3"启动 trunk serve,你就能在浏览器里体验一个纯 Rust 写的 TODO 应用。
Yew 适合什么场景?
试完之后,我的判断:
适合用 Yew 的:
- 内部工具、管理后台 —— 用户对 UI 精致度要求不高,但对正确性要求高
- 数据密集型的 Dashboard —— Rust 的计算性能优势能体现
- 后端团队自研前端 —— 不需要额外招前端,Rust 全栈搞定
- 对 WASM 有硬需求的场景 —— 比如需要复用 Rust 的业务逻辑
不适合用 Yew 的:
- 面向消费者的营销网站 —— UI 生态太弱,做不出那种精致动效
- 需要大量第三方集成的项目 —— 很多 SaaS 的 JS SDK 没有 WASM 版本
- 追求极致开发体验的团队 —— 编译速度和调试体验确实有差距
- 需要 SEO 的页面 —— WASM 渲染对搜索引擎不友好(虽然可以用 SSR 解决,但 Yew 的 SSR 方案还不成熟)
结论
Rust 能不能写前端?能。 Yew 已经是一个可以正常使用的框架,不是玩具。
Rust 该不该写前端?看情况。 如果你的团队全是 Rust 开发者,不想引入 JS 工具链,Yew 是个合理的选择。如果你有专业的前端团队,让他们用 React/Vue 可能效率更高。
最大的收获不是学会了 Yew,而是换了个角度看 Rust。 在后端,所有权和借用是性能优化的工具;在前端,它们是状态安全的保障。同一套机制,在不同场景下有不同的价值。
说实话,我现在写后端代码的时候,对状态管理的思考方式也变了。以前觉得 Arc<Mutex<T>> 能用就行,现在会想"这个状态的共享关系能不能在类型系统里表达得更清楚"。前端的状态管理经验,反过来影响了我的后端设计。
如果你是 Rust 开发者,我建议你至少花一个周末试试 Yew。 不是为了转前端,而是为了更全面地理解 Rust 的类型系统。当你看到编译器在前端场景下帮你抓住的那些 bug 时,你会对 Rust 有新的认识。
如果你决定深入,这份资源清单可以帮到你:
- Yew 官方文档 —— 最权威的入门指南
- Yew GitHub —— 源码和 examples
- Trunk —— 构建工具文档
- gloo —— 浏览器 API 工具库
- Awesome Yew —— 生态资源汇总