返回文章列表

Rust + Yew 试水前端:一次真实的体验

1138·8 分钟阅读
RustYewWeb 前端WASM

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-playground

Cargo.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 前端独有的特点:

  1. e.prevent_default() ——跟 JS 的 e.preventDefault() 一个意思,但通过 web_sys 调用。类型安全,不会有拼写错误。
  2. e.target_unchecked_into() ——把 EventTarget 转成 HtmlInputElement。这里用了 unchecked,因为 Yew 的事件系统不知道 target 是什么类型。说实话这个 API 设计我觉得不太优雅,但在 Web 的动态类型世界里,这已经是能做到的最好程度了。
  3. 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-mdcyewprint 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 有新的认识。

如果你决定深入,这份资源清单可以帮到你: