跳转至

Leptos: Rust 全栈 Web 框架完全指南

为什么要学 Leptos

  1. Rust 的性能与安全性带到 Web 开发:Leptos 编译为 WebAssembly(Wasm),在浏览器中运行速度接近原生。Rust 的所有权系统和类型系统在编译时消除了空指针、数据竞争等整类 bug,Web 应用也能享受这些安全保障。

  2. 细粒度响应式,无虚拟 DOM:Leptos 采用与 SolidJS 类似的细粒度响应式系统。数据变化时只更新受影响的 DOM 节点,不做整棵树的 diff。在 JS Framework Benchmark 中,Leptos 性能在所有 Web 框架中名列前茅。

  3. 真正的全栈 Rust:前端(Wasm)和后端(Actix-web/Axum)共享同一份 Rust 代码。Server Functions 让你在组件中直接调用服务端逻辑,无需手动写 API。类型安全贯穿前后端。

  4. 多种渲染模式:支持 CSR(客户端渲染)、SSR(服务端渲染)、Islands(岛屿架构)以及静态站点生成。可以根据应用需求灵活选择,甚至混合使用。

  5. Rust 生态整合:可以直接使用 Rust 生态中的海量 crate——serde 序列化、tokio 异步运行时、sqlx 数据库访问、tracing 日志等,不需要在 JS 和原生之间架桥。


核心概念详解

Leptos 是什么(白话解释)

如果你了解 React 或 SolidJS,可以把 Leptos 理解为"Rust 版的 SolidJS"。它让你用 Rust 写前端组件,编译成 WebAssembly 在浏览器运行。同时,它也能在服务端渲染 HTML,实现 SSR。

核心理念是"信号"(Signal):你创建一个信号,它就像一个可以被监听的变量。当信号的值改变时,所有使用它的地方自动更新,不需要手动通知,也不需要虚拟 DOM。

信号系统(Signals)

use leptos::prelude::*;

#[component]
fn Counter() -> impl IntoView {
    // 创建信号:返回 (getter, setter)
    let (count, set_count) = signal(0);

    // 派生信号:自动追踪依赖
    let doubled = move || count.get() * 2;
    let is_even = move || count.get() % 2 == 0;

    view! {
        <div>
            <p>"计数: " {count}</p>
            <p>"双倍: " {doubled}</p>
            <p>{move || if is_even() { "偶数" } else { "奇数" }}</p>
            <button on:click=move |_| set_count.update(|n| *n += 1)>
                "+1"
            </button>
            <button on:click=move |_| set_count.set(0)>
                "重置"
            </button>
        </div>
    }
}

信号类型对比

类型用途示例
signal(value)可读写的响应式值let (name, set_name) = signal("张三".to_string());
move \|\| expr派生计算(闭包)let doubled = move \|\| count.get() * 2;
Memo::new(fn)缓存的派生计算let expensive = Memo::new(move \|_\| heavy_calc(count.get()));
Effect::new(fn)副作用Effect::new(move \|_\| log!("{}", count.get()));
Resource::new(fn, fetcher)异步数据获取从服务器获取数据
RwSignal::new(value)读写合一的信号let count = RwSignal::new(0);

组件与视图宏

use leptos::prelude::*;

// 组件是一个函数,用 #[component] 宏标记
#[component]
fn UserCard(
    // Props 通过函数参数定义
    name: String,
    #[prop(default = 0)] age: u32,
    #[prop(optional)] email: Option<String>,
    #[prop(into)] class: String, // into 自动转换类型
) -> impl IntoView {
    view! {
        <div class=class>
            <h2>{name}</h2>
            <p>"年龄: " {age}</p>
            {email.map(|e| view! { <p>"邮箱: " {e}</p> })}
        </div>
    }
}

// 使用组件
#[component]
fn App() -> impl IntoView {
    view! {
        <UserCard
            name="张三".to_string()
            age=25
            email=Some("zhang@example.com".to_string())
            class="user-card"
        />
    }
}

控制流

#[component]
fn ControlFlow() -> impl IntoView {
    let (count, set_count) = signal(0);
    let (items, set_items) = signal(vec!["苹果", "香蕉", "橙子"]);
    let (show, set_show) = signal(true);

    view! {
        // 条件渲染
        <Show
            when=move || show.get()
            fallback=|| view! { <p>"已隐藏"</p> }
        >
            <p>"可见内容"</p>
        </Show>

        // 列表渲染(静态列表)
        <For
            each=move || items.get()
            key=|item| item.to_string()
            children=|item| view! { <li>{item}</li> }
        />

        // 动态类名
        <div class:active=move || count.get() > 5>
            "动态样式"
        </div>

        // 动态属性
        <input
            type="text"
            prop:value=move || count.get().to_string()
            on:input=move |ev| {
                let val = event_target_value(&ev);
                if let Ok(n) = val.parse::<i32>() {
                    set_count.set(n);
                }
            }
        />
    }
}

Leptos vs Yew vs Dioxus 对比

特性LeptosYewDioxus
响应式细粒度信号虚拟DOM(类React)虚拟DOM(类React)
SSR完整支持实验性支持(LiveView模式)
全栈Server Functions需手动APIServer Functions
路由内置yew-router内置
性能极高(无VDOM)
语法风格类SolidJS类React(hooks风格)类React(RSX宏)
跨平台Web专注Web专注Web/桌面/移动
成熟度活跃开发较成熟活跃开发
社区快速增长最大Rust前端社区增长中
Islands支持不支持不支持
学习曲线中(需了解信号)中(类React)中低(类React)

安装与配置

环境准备

# 1. 安装 Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# 2. 添加 Wasm 编译目标
rustup target add wasm32-unknown-unknown

# 3. 安装 cargo-leptos(构建工具)
cargo install cargo-leptos

# 4. 安装 Trunk(可选,用于纯 CSR 项目)
cargo install trunk

创建新项目(SSR 全栈)

# 使用官方模板
cargo leptos new my-app
cd my-app

或手动创建:

cargo init my-app
cd my-app

Cargo.toml

[package]
name = "my-app"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
leptos = { version = "0.7", features = ["nightly"] }
leptos_meta = { version = "0.7" }
leptos_router = { version = "0.7" }
leptos_axum = { version = "0.7", optional = true }
axum = { version = "0.7", optional = true }
tokio = { version = "1", features = ["full"], optional = true }
tower = { version = "0.4", optional = true }
tower-http = { version = "0.5", features = ["fs"], optional = true }
serde = { version = "1", features = ["derive"] }
thiserror = "1"
http = "1"
wasm-bindgen = "0.2"
console_error_panic_hook = "0.1"

[features]
default = []
hydrate = ["leptos/hydrate"]
ssr = [
    "dep:axum",
    "dep:tokio",
    "dep:tower",
    "dep:tower-http",
    "dep:leptos_axum",
    "leptos/ssr",
    "leptos_meta/ssr",
    "leptos_router/ssr",
]

[package.metadata.leptos]
output-name = "my-app"
site-root = "target/site"
site-pkg-dir = "pkg"
style-file = "style/main.css"
assets-dir = "public"
site-addr = "127.0.0.1:3000"
reload-port = 3001
browserquery = "defaults"
env = "DEV"
bin-features = ["ssr"]
bin-default-features = false
lib-features = ["hydrate"]
lib-default-features = false

运行开发环境

# SSR 项目
cargo leptos watch

# CSR 项目(使用 Trunk)
trunk serve

快速上手:5 分钟最小示例

src/lib.rs

use leptos::prelude::*;

#[component]
pub fn App() -> impl IntoView {
    let (count, set_count) = signal(0);
    let doubled = move || count.get() * 2;

    view! {
        <main>
            <h1>"Leptos 计数器"</h1>
            <p>"当前值: " {count}</p>
            <p>"双倍值: " {doubled}</p>
            <div class="buttons">
                <button on:click=move |_| set_count.update(|n| *n -= 1)>
                    "-1"
                </button>
                <button on:click=move |_| set_count.set(0)>
                    "重置"
                </button>
                <button on:click=move |_| set_count.update(|n| *n += 1)>
                    "+1"
                </button>
            </div>
        </main>
    }
}

src/main.rs(SSR 版):

#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
    use axum::Router;
    use leptos::prelude::*;
    use leptos_axum::{generate_route_list, LeptosRoutes};
    use my_app::App;

    let conf = get_configuration(None).unwrap();
    let leptos_options = conf.leptos_options;
    let addr = leptos_options.site_addr;
    let routes = generate_route_list(App);

    let app = Router::new()
        .leptos_routes(&leptos_options, routes, {
            let options = leptos_options.clone();
            move || view! { <App /> }
        })
        .fallback(leptos_axum::file_and_error_handler::<App>)
        .with_state(leptos_options);

    let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
    println!("listening on http://{}", &addr);
    axum::serve(listener, app.into_make_service()).await.unwrap();
}

#[cfg(not(feature = "ssr"))]
pub fn main() {}

运行:

cargo leptos watch
# 打开 http://127.0.0.1:3000


进阶用法

场景一:Server Functions

use leptos::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Todo {
    pub id: u32,
    pub title: String,
    pub completed: bool,
}

// Server Function: 在服务端执行,自动生成 API 端点
#[server(GetTodos, "/api")]
pub async fn get_todos() -> Result<Vec<Todo>, ServerFnError> {
    // 这里的代码只在服务端运行
    // 可以安全地访问数据库、文件系统等
    let todos = sqlx::query_as!(Todo, "SELECT * FROM todos ORDER BY id")
        .fetch_all(&pool())
        .await?;
    Ok(todos)
}

#[server(AddTodo, "/api")]
pub async fn add_todo(title: String) -> Result<Todo, ServerFnError> {
    let todo = sqlx::query_as!(
        Todo,
        "INSERT INTO todos (title, completed) VALUES ($1, false) RETURNING *",
        title
    )
    .fetch_one(&pool())
    .await?;
    Ok(todo)
}

#[component]
fn TodoApp() -> impl IntoView {
    // Resource: 自动调用 Server Function 并管理加载状态
    let todos = Resource::new(|| (), |_| get_todos());

    let add_action = Action::new(|title: &String| {
        let title = title.clone();
        async move { add_todo(title).await }
    });

    view! {
        <h1>"Todo List"</h1>

        <form on:submit=move |ev| {
            ev.prevent_default();
            let input = document().get_element_by_id("title").unwrap();
            let title = input.dyn_ref::<web_sys::HtmlInputElement>().unwrap().value();
            add_action.dispatch(title);
        }>
            <input id="title" type="text" placeholder="新任务..." />
            <button type="submit">"添加"</button>
        </form>

        <Suspense fallback=move || view! { <p>"加载中..."</p> }>
            {move || todos.get().map(|result| match result {
                Ok(todos) => view! {
                    <ul>
                        {todos.into_iter().map(|todo| view! {
                            <li class:completed=todo.completed>
                                {todo.title}
                            </li>
                        }).collect_view()}
                    </ul>
                }.into_any(),
                Err(e) => view! { <p>"错误: " {e.to_string()}</p> }.into_any(),
            })}
        </Suspense>
    }
}

场景二:路由

use leptos::prelude::*;
use leptos_router::components::*;
use leptos_router::path;

#[component]
fn App() -> impl IntoView {
    view! {
        <Router>
            <nav>
                <A href="/">"首页"</A>
                <A href="/about">"关于"</A>
                <A href="/users">"用户"</A>
            </nav>
            <main>
                <Routes fallback=|| "404 页面未找到">
                    <Route path=path!("/") view=HomePage />
                    <Route path=path!("/about") view=AboutPage />
                    <Route path=path!("/users") view=UsersPage />
                    <Route path=path!("/users/:id") view=UserDetail />
                </Routes>
            </main>
        </Router>
    }
}

#[component]
fn UserDetail() -> impl IntoView {
    let params = leptos_router::hooks::use_params_map();
    let id = move || params.get().get("id").unwrap_or_default();

    view! {
        <h1>"用户详情: " {id}</h1>
    }
}

场景三:表单处理与 Action

use leptos::prelude::*;

#[server(Login, "/api")]
async fn login(username: String, password: String) -> Result<String, ServerFnError> {
    if username == "admin" && password == "password" {
        Ok("登录成功".to_string())
    } else {
        Err(ServerFnError::ServerError("用户名或密码错误".to_string()))
    }
}

#[component]
fn LoginForm() -> impl IntoView {
    let login_action = ServerAction::<Login>::new();
    let value = login_action.value();

    view! {
        <ActionForm action=login_action>
            <label>
                "用户名"
                <input type="text" name="username" required />
            </label>
            <label>
                "密码"
                <input type="password" name="password" required />
            </label>
            <button type="submit">"登录"</button>
        </ActionForm>

        {move || value.get().map(|result| match result {
            Ok(msg) => view! { <p class="success">{msg}</p> }.into_any(),
            Err(e) => view! { <p class="error">{e.to_string()}</p> }.into_any(),
        })}
    }
}

场景四:错误处理

use leptos::prelude::*;

#[component]
fn ErrorHandling() -> impl IntoView {
    let (count, set_count) = signal(0_i32);

    // 可能产生错误的计算
    let checked_div = move || -> Result<String, String> {
        let c = count.get();
        if c == 0 {
            Err("不能除以零!".to_string())
        } else {
            Ok(format!("100 / {} = {}", c, 100 / c))
        }
    };

    view! {
        <ErrorBoundary fallback=|errors| view! {
            <div class="error">
                <h3>"出错了:"</h3>
                <ul>
                    {move || errors.get().into_iter().map(|(_, e)| view! {
                        <li>{e.to_string()}</li>
                    }).collect_view()}
                </ul>
            </div>
        }>
            <p>{checked_div}</p>
        </ErrorBoundary>
        <button on:click=move |_| set_count.update(|n| *n += 1)>"+1"</button>
        <button on:click=move |_| set_count.set(0)>"设为0(触发错误)"</button>
    }
}

场景五:上下文(Context)与全局状态

use leptos::prelude::*;

#[derive(Clone)]
struct AppState {
    theme: RwSignal<String>,
    user: RwSignal<Option<String>>,
}

#[component]
fn App() -> impl IntoView {
    // 在根组件提供上下文
    let state = AppState {
        theme: RwSignal::new("light".to_string()),
        user: RwSignal::new(None),
    };
    provide_context(state);

    view! {
        <ThemeToggle />
        <UserInfo />
    }
}

#[component]
fn ThemeToggle() -> impl IntoView {
    // 在任何子组件中获取上下文
    let state = expect_context::<AppState>();

    view! {
        <button on:click=move |_| {
            state.theme.update(|t| {
                *t = if t == "light" { "dark".to_string() } else { "light".to_string() };
            });
        }>
            "当前主题: " {move || state.theme.get()}
        </button>
    }
}

常见问题与排错

问题一:编译很慢

Rust 编译本身较慢,Wasm 编译更是如此。

优化建议

# Cargo.toml - 开发时优化编译速度
[profile.dev]
opt-level = 0

[profile.dev.package."*"]
opt-level = 2  # 依赖包用 O2

# .cargo/config.toml - 使用更快的链接器
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=lld"]

问题二:move 闭包和借用检查器

// 错误:count 被 move 后不能再使用
let (count, set_count) = signal(0);
let handler1 = move |_| set_count.set(count.get() + 1);
let handler2 = move |_| set_count.set(0); // 编译错误!

// 正确:Signal 实现了 Copy trait,可以多次 move
// 在 Leptos 0.7 中,Signal 类型是 Copy 的
let (count, set_count) = signal(0);
let handler1 = move |_| set_count.update(|n| *n += 1); // OK
let handler2 = move |_| set_count.set(0); // OK

问题三:Server Function 不工作

确保 ssr feature 正确配置,Server Function 需要在 cfg(feature = "ssr") 下编译才有实际实现。

问题四:Wasm 包体积过大

# Cargo.toml
[profile.release]
opt-level = "z"    # 最小体积优化
lto = true         # 链接时优化
codegen-units = 1  # 单编译单元
strip = true       # 去掉调试符号
# 使用 wasm-opt 进一步优化
wasm-opt -Oz -o output.wasm input.wasm

问题五:如何调试

// 在浏览器控制台输出
use leptos::logging::log;
log!("count = {}", count.get());

// 设置 panic hook 在控制台显示 Rust panic
console_error_panic_hook::set_once();

问题六:如何与 JavaScript 互操作

use wasm_bindgen::prelude::*;

// 调用 JavaScript 函数
#[wasm_bindgen]
extern "C" {
    fn alert(s: &str);

    #[wasm_bindgen(js_namespace = console)]
    fn log(s: &str);
}

// 暴露 Rust 函数给 JavaScript
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
    format!("Hello, {}!", name)
}

参考资源

  • 官方文档:https://leptos.dev/
  • Leptos Book:https://book.leptos.dev/
  • GitHub:https://github.com/leptos-rs/leptos
  • 官方示例:https://github.com/leptos-rs/leptos/tree/main/examples
  • Leptos Discord:链接在 GitHub README 中
  • cargo-leptos:https://github.com/leptos-rs/cargo-leptos
  • Rust Wasm Book:https://rustwasm.github.io/docs/book/
  • JS Framework Benchmark:https://krausest.github.io/js-framework-benchmark/