Rust 编译到 WebAssembly 做前端已经不是概念验证了——Leptos 框架提供了接近 React/SolidJS 的开发体验,同时保持 Rust 的类型安全和性能优势。本文记录用 Leptos 开发一个简单 Web 应用的过程,重点看它的信号系统和组件模型。
Rust WASM 前端生态概览
目前 Rust WASM 前端框架主要有三个:
| 框架 | 特点 | 响应式模型 |
|---|---|---|
| Yew | 最早的 Rust 前端框架,类 React | Virtual DOM |
| Dioxus | 类 React,支持多平台(Web/Desktop/Mobile) | Virtual DOM |
| Leptos | 类 SolidJS,细粒度响应式 | Signal(无 VDOM) |
Leptos 选择了 SolidJS 路线——不用 Virtual DOM,通过 Signal 做细粒度更新。这意味着组件函数只执行一次(而非每次状态变化都重新执行),性能上限更高。
环境搭建
# 安装 trunk(WASM 打包工具)
cargo install trunk
# 安装 wasm target
rustup target add wasm32-unknown-unknown
# 创建项目
cargo new leptos-demo
cd leptos-demo
Cargo.toml:
[package]
name = "leptos-demo"
version = "0.1.0"
edition = "2021"
[dependencies]
leptos = { version = "0.6", features = ["csr"] } # csr = Client Side Rendering
console_error_panic_hook = "0.1"
项目根目录创建 index.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>Leptos Demo</title>
<link data-trunk rel="rust" data-wasm-opt="z"/>
</head>
<body></body>
</html>
信号系统(Signal)
信号是 Leptos 的核心。create_signal 返回一个 getter 和一个 setter:
use leptos::*;
#[component]
fn Counter() -> impl IntoView {
// create_signal 返回 (ReadSignal<T>, WriteSignal<T>)
let (count, set_count) = create_signal(0);
view! {
<div class="counter">
// count() 读取当前值;每次 count 变化,只有这个 <span> 会更新
<p>"当前计数: " {count}</p>
<button on:click=move |_| set_count.update(|n| *n += 1)>
"+1"
</button>
<button on:click=move |_| set_count.set(0)>
"重置"
</button>
</div>
}
}
关键概念:
count是ReadSignal<i32>——调用count()或在view!中使用时会自动追踪依赖set_count是WriteSignal<i32>——set()替换值,update()基于当前值修改- 与 React 不同,组件函数只执行一次。后续状态变化只更新绑定了信号的 DOM 节点
派生信号(Derived Signal):
let (count, set_count) = create_signal(0);
// 派生信号:依赖 count,count 变化时自动重新计算
let doubled = move || count() * 2;
let is_even = move || count() % 2 == 0;
view! {
<p>"Count: " {count}</p>
<p>"Doubled: " {doubled}</p>
<p>"Is Even: " {is_even}</p>
}
派生信号就是闭包——不需要额外的 API。这一点比 React 的 useMemo 自然很多。
组件编写
use leptos::*;
// 组件参数用 struct-like 的方式定义
#[component]
fn TodoApp() -> impl IntoView {
let (todos, set_todos) = create_signal(vec![
Todo { id: 0, text: "学习 Leptos".to_string(), done: false },
Todo { id: 1, text: "写博客".to_string(), done: false },
]);
let (next_id, set_next_id) = create_signal(2u32);
let (input, set_input) = create_signal(String::new());
let add_todo = move |_| {
let text = input().trim().to_string();
if text.is_empty() { return; }
set_todos.update(|todos| {
todos.push(Todo {
id: next_id(),
text,
done: false,
});
});
set_next_id.update(|id| *id += 1);
set_input.set(String::new());
};
let toggle_todo = move |id: u32| {
set_todos.update(|todos| {
if let Some(todo) = todos.iter_mut().find(|t| t.id == id) {
todo.done = !todo.done;
}
});
};
let remaining = move || todos().iter().filter(|t| !t.done).count();
view! {
<div class="todo-app">
<h1>"Todo List"</h1>
<div class="input-row">
<input
type="text"
prop:value=input
on:input=move |ev| set_input.set(event_target_value(&ev))
placeholder="输入新任务..."
/>
<button on:click=add_todo>"添加"</button>
</div>
<ul>
<For
each=todos
key=|todo| todo.id
children=move |todo| {
let id = todo.id;
view! {
<li class:done=todo.done>
<input
type="checkbox"
prop:checked=todo.done
on:change=move |_| toggle_todo(id)
/>
<span>{todo.text.clone()}</span>
</li>
}
}
/>
</ul>
<p>{remaining} " 项待完成"</p>
</div>
}
}
#[derive(Clone, Debug)]
struct Todo {
id: u32,
text: String,
done: bool,
}
<For> 组件是 Leptos 的列表渲染方案,类似 SolidJS 的 <For>。key 用于高效 diff。
事件处理
// 基本事件
<button on:click=move |_| { /* ... */ }>"Click"</button>
// 带 event 对象
<input on:input=move |ev| {
let value = event_target_value(&ev);
set_input.set(value);
}/>
// 键盘事件
<input on:keydown=move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Enter" {
add_todo(ev);
}
}/>
// 阻止默认行为
<form on:submit=move |ev| {
ev.prevent_default();
// ...
}/>
事件类型来自 web_sys crate,和浏览器原生 API 一一对应。Rust 的类型系统确保你不会拼错事件属性名。
与 JavaScript 互操作
通过 wasm-bindgen 调用 JS API:
use wasm_bindgen::prelude::*;
// 调用 JS 的 alert
#[wasm_bindgen]
extern "C" {
fn alert(s: &str);
}
// 调用 console.log
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}
// 在组件中使用
#[component]
fn App() -> impl IntoView {
let greet = move |_| {
alert("Hello from Rust!");
log("Button clicked");
};
view! {
<button on:click=greet>"Greet"</button>
}
}
也可以用 js_sys crate 直接操作 JS 对象,或通过 web_sys 访问所有 Web API(DOM、fetch、WebSocket 等)。
SSR 支持
Leptos 原生支持 SSR(Server-Side Rendering),用 Actix-Web 或 Axum 作为后端:
[dependencies]
leptos = { version = "0.6", features = ["ssr"] }
leptos_axum = "0.6"
axum = "0.7"
tokio = { version = "1", features = ["full"] }
SSR 模式下,组件在服务端渲染为 HTML 字符串发送给客户端,然后在客户端"水合"(hydration)恢复交互。这和 Next.js/Nuxt 的思路一样,但全程用 Rust 编写。
开发与构建
# 开发模式(热重载)
trunk serve
# 生产构建
trunk build --release
# 输出在 dist/ 目录
ls dist/
trunk 会自动编译 Rust 到 WASM、处理资源文件、生成 HTML。生产构建默认会做 wasm-opt 优化,通常能把 WASM 文件压到几百 KB。
总结
Leptos 的开发体验比预期好很多。细粒度响应式避免了 VDOM 的开销,view! 宏的语法和 JSX 相似度很高,上手不难。Rust 的类型系统在前端场景下也发挥了作用——状态类型不对、事件属性拼错都能在编译期抓住。目前的主要限制是生态还不够丰富(组件库少),以及 WASM 的初始加载体积比纯 JS 大。适合对性能有要求的、或者团队本身就用 Rust 的场景。