使用 Leptos + Axum 构建同构 Hacker News 应用:SSR 与 CSR 共存的完整实战示例解析
【免费下载链接】leptosBuild fast web applications with Rust.项目地址: https://gitcode.com/GitHub_Trending/le/leptos
本篇指南深入剖析 hackernews_axum 示例——一个基于 Leptos 框架、以 Axum 作为服务端的 Hacker News 克隆应用。它在一个仓库中同时展示了客户端渲染(CSR)与服务端渲染(SSR)+ 客户端水合(hydration)两种运行模式,覆盖了 Axum 集成、路由懒加载、服务端/客户端同构数据获取等核心工程能力。读完本文,你将掌握 Leptos 应用如何接入 Axum 服务器、如何通过 feature 区分构建目标,以及如何组织一套可复制的同构数据请求模式。
示例定位:一个仓库,两种渲染模式
根据 hackernews_axum 的 README,这个示例是 Hacker News 网站的基础克隆,核心价值在于:它展示了 Leptos 在同一个仓库中同时构建"客户端渲染应用"和"带水合的服务端渲染应用"的能力。
它与仓库中另一个 hackernews 示例 的区别在于:本示例使用Axum作为 HTTP 服务器。这一点在 main.rs 中得到了印证——服务端入口直接引入了axum::routing::get与Router,并通过leptos_axum集成层完成 Leptos 应用与 Axum 路由的对接。
项目结构与关键文件
examples/hackernews_axum/ ├── Cargo.toml # feature 划分与 cargo-leptos 构建配置 ├── Makefile.toml # cargo-make 任务入口 ├── index.html # Trunk(CSR 模式)的 HTML 入口 ├── rust-toolchain.toml # 固定 wasm32-unknown-unknown 编译目标 ├── style.css # 示例样式(经 Lightning CSS 优化) ├── public/favicon.ico # 站点图标 └── src/ ├── main.rs # 服务端入口(SSR)与客户端入口(CSR)双实现 ├── lib.rs # 共享的 App 组件、shell 与路由声明 ├── api.rs # 同构数据获取层(SSR 用 reqwest,CSR 用 gloo-net) ├── error_template.rs # 错误模板 └── routes/ ├── nav.rs # 顶部导航栏组件 ├── stories.rs # 新闻列表页(top / new / show / ask / job) ├── story.rs # 新闻详情页(含嵌套评论树) └── users.rs # 用户信息页从文件结构可以看出一个典型的分层思路:main.rs只负责"启动什么",lib.rs定义"应用是什么",api.rs抽象"数据从哪来",routes/目录承载各页面组件的实现。
快速启动:两条开箱即用的命令
原文档给出了两种快速启动方式,分别对应 Leptos 生态中两套主流构建工具:
| 命令 | 适用模式 | 工具 |
|---|---|---|
trunk serve --open | CSR(默认 feature) | Trunk |
cargo leptos watch | SSR + 水合 | cargo-leptos |
trunk serve --open:以客户端渲染模式运行。默认 feature 为csr(见 Cargo.toml),Trunk 会编译 WASM 并启动开发服务器,适合纯前端快速预览。cargo leptos watch:以服务端渲染模式运行。cargo-leptos 依据 Cargo.toml 中的[package.metadata.leptos]配置,分别以ssrfeature 编译 bin 目标、以hydratefeature 编译 lib 目标,并开启自动热重载。
环境准备(来自 Examples README)
在运行前需要准备以下工具链:
- 安装 Rust 与 Nightly 工具链:
rustup toolchain install nightly - 添加 WASM 编译目标:
rustup target add wasm32-unknown-unknown(本示例的 rust-toolchain.toml 已固定该目标) - 安装构建工具:
cargo install trunk(CSR 模式)或cargo install cargo-leptos(SSR 模式) - 可选安装
cargo install cargo-make,然后可执行cargo make start/cargo make stop管理示例进程(见 Makefile.toml)
Feature 矩阵:csr / hydrate / ssr 三态切换
Hackernews_axum 的 Cargo.toml 是理解整个示例的关键:
[features] default = ["csr"] csr = ["leptos/csr"] hydrate = ["leptos/hydrate"] ssr = [ "dep:axum", "dep:tower", "dep:tower-http", "dep:tokio", "dep:http", "leptos/ssr", "leptos_axum", "leptos_meta/ssr", "leptos_router/ssr", ]csr:纯客户端渲染,不引入任何服务器依赖,所有网络请求通过gloo-net在浏览器内发起;hydrate:为服务端输出的 HTML 提供水合入口,leptos::mount::hydrate_body会把事件绑定到已存在的 DOM 上(见 lib.rs);ssr:开启 Axum、Tokio、tower 等服务器依赖,同时联动开启leptos_meta、leptos_router的 SSR 能力。
注意csr与ssr相互独立、可分别编译,这正是"同一仓库两种模式"的工程基础。cargo-all-features的 denylist 配置(Cargo.toml)则避免了对 axum 等可选依赖的重复组合测试。
cargo-leptos 关键配置项
Cargo.toml 中[package.metadata.leptos]段的参数含义如下:
| 配置项 | 示例值 | 作用 |
|---|---|---|
output-name | hackernews_axum | JS/WASM 产物命名,默认取 crate 名 |
site-root | target/site | 构建输出根目录,重建时会清空该目录全部内容 |
site-pkg-dir | pkg | 站点根目录下存放 JS/WASM/CSS 产物的子目录 |
style-file | ./style.css | 源 CSS,经 Lightning CSS 优化后写入<site-root>/<site-pkg>/app.css |
assets-dir | public | 该目录下的文件会被复制到站点根目录 |
site-addr | 127.0.0.1:3000 | 服务器监听地址与端口 |
reload-port | 3001 | 自动重载监控端口 |
bin-features | ["ssr"] | 编译 bin 目标使用的 feature |
bin-default-features | false | 编译 bin 目标时不使用默认 feature |
lib-features | ["hydrate"] | 编译 lib 目标使用的 feature |
lib-default-features | false | 编译 lib 目标时不使用默认 feature |
这套配置实现了cargo leptos watch时"服务端用ssr、客户端用hydrate"的分目标构建,这也是 SSR + 水合应用的标准构建方式。
Axum 服务端集成:从路由生成到文件服务
main.rs 完整展示了 Leptos 应用挂载到 Axum 的过程:
#[cfg(feature = "ssr")] #[tokio::main] async fn main() { use axum::{routing::get, Router}; use hackernews_axum::{shell, App}; use leptos::config::get_configuration; use leptos_axum::{generate_route_list, LeptosRoutes}; let conf = get_configuration(Some("Cargo.toml")).unwrap(); let leptos_options = conf.leptos_options; let addr = leptos_options.site_addr; let routes = generate_route_list(App); let app = Router::new() .route("/favicon.ico", get(|| async { ... })) .leptos_routes(&leptos_options, routes, { move || shell(leptos_options.clone()) }) .fallback(leptos_axum::file_and_error_handler(shell)) .with_state(leptos_options); let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); axum::serve(listener, app.into_make_service()).await.unwrap(); }核心调用链可以拆解为四个环节:
- 读取配置:
get_configuration(Some("Cargo.toml"))从 Cargo.toml 的[package.metadata.leptos]段加载LeptosOptions,其中的site_addr(127.0.0.1:3000)直接决定监听地址; - 生成路由表:
generate_route_list(App)静态遍历App组件中声明的<Route>树,生成 Leptos 可识别的路径列表; - 注册动态路由:
.leptos_routes(...)为这些路径统一注册服务端渲染入口——请求到达时由 Leptos 执行 SSR 输出完整 HTML,并内联水合脚本; - 文件与错误兜底:
.fallback(leptos_axum::file_and_error_handler(shell))负责提供target/site下的静态资源(JS/WASM/CSS)以及错误页回退。
对应的 HTML 外壳由shell函数定义(lib.rs),其中<AutoReload>在开发期注入热重载脚本,<HydrationScripts>输出水合所需脚本,<MetaTags/>为leptos_meta提供元信息出口:
pub fn shell(options: LeptosOptions) -> impl IntoView { view! { <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"/> <meta name="viewport" content="width=device-width, initial-scale=1"/> <AutoReload options=options.clone() /> <HydrationScripts options/> <MetaTags/> </head> <body> <App/> </body> </html> } }此外还有一条Trunk(CSR)入口:当未启用ssrfeature 时,main.rs编译为纯客户端入口,直接mount_to_body(App),与 SSR 入口通过#[cfg(feature = "ssr")]互斥共存(main.rs)。
同构数据获取:一套代码,两种网络栈
Hacker News 类应用的核心是拉取外部 API。示例没有把数据获取逻辑写在组件里,而是收敛在 api.rs 中,并通过#[cfg]实现"同一函数签名、两种实现":
#[cfg(not(feature = "ssr"))] // 客户端:gloo-net + AbortController pub fn fetch_api<T>(path: &str) -> impl Future<Output = Option<T>> + Send + '_ { SendWrapper::new(async move { let abort_controller = SendWrapper::new(web_sys::AbortController::new().ok()); let abort_signal = abort_controller.as_ref().map(|a| a.signal()); on_cleanup(move || { if let Some(abort_controller) = abort_controller.take() { abort_controller.abort() // 离开页面时中止未完成的请求 } }); gloo_net::http::Request::get(path) .abort_signal(abort_signal.as_ref()) .send().await.ok()?.json().await.ok() }) } #[cfg(feature = "ssr")] // 服务端:reqwest pub async fn fetch_api<T>(path: &str) -> Option<T> { reqwest::get(path).await.ok()?.json().await.ok() }- SSR 端:在服务器进程内用
reqwest直接请求上游 API,将数据渲染进 HTML 后返回; - CSR 端:在浏览器内用
gloo-net请求,并利用web_sys::AbortController与on_cleanup钩子实现"离开页面即中止在途请求"的体验优化; - 契约一致:两者都返回
Option<T>,调用方(各页面组件)无需区分当前运行环境,这保证了同构代码的书写体验。
数据模型同样集中在 api.rs:Story、Comment、User三个结构体用serde反序列化上游 JSON。值得注意的细节是Story中#[serde(alias = "type")] pub story_type: String(api.rs)——由于type是 Rust 关键字,这里通过 alias 映射外部字段,并利用#[serde(default)]兜底可选字段。
路由与页面组件:懒加载、资源与过渡动画
路由声明与懒加载
lib.rs 使用leptos_router声明了三条路由:
<Router set_is_routing> <div class="routing-progress"> <RoutingProgress is_routing max_time=Duration::from_millis(250)/> </div> <Nav /> <main> <FlatRoutes fallback=|| "Not found."> <Route path=(StaticSegment("users"), ParamSegment("id")) view={Lazy::<UserRoute>::new()}/> <Route path=(StaticSegment("stories"), ParamSegment("id")) view={Lazy::<StoryRoute>::new()}/> <Route path=OptionalParamSegment("stories") view=Stories/> </FlatRoutes> </main> </Router>StaticSegment/ParamSegment/OptionalParamSegment组合出/users/:id、/stories/:id、/stories?page=N等路径;Lazy::<UserRoute>::new()与Lazy::<StoryRoute>::new()把用户页和故事详情页拆分为懒加载块,只有访问对应路由时才加载其 WASM/JS 代码;RoutingProgress配合set_is_routing信号,在异步数据加载超过 250ms 时显示路由进度条,避免用户感知到卡顿。
列表页:Resource + Transition 的数据流
stories.rs 是列表页实现,展示了 Leptos 响应式数据流的标准写法:
let stories = Resource::new( move || (page(), story_type()), // 依赖:页码与分类变化时自动重取 move |(page, story_type)| async move { let path = format!("{}?page={}", category(&story_type), page); api::fetch_api::<Vec<api::Story>>(&api::story(&path)).await }, );Resource::new的第一个闭包声明响应式依赖,第二个闭包执行异步获取;<Transition fallback=... set_pending>在切换页面时保留旧内容并显示加载态,数据到达后平滑更新,而不是整页闪烁;<Show>用于在None(请求失败)时显示错误提示;<For>以story.id为 key 高效复用列表项 DOM;- 分页导航通过读取 URL 的 query 参数(
use_query_map)与路径参数(use_params_map)实现,< prev/more >链接直接生成新的 URL,天然支持前进后退与刷新恢复。
详情页:lazy_route 与可折叠评论树
story.rs 展示了#[lazy_route]宏的用法——把"数据预取"与"视图渲染"拆成data()与view()两个方法,实现进入路由前先并行加载数据的体验:
#[lazy_route] impl LazyRoute for StoryRoute { fn data() -> Self { let params = use_params_map(); let story = Resource::new_blocking( move || params.read().get("id").unwrap_or_default(), move |id| async move { api::fetch_api::<api::Story>(&api::story(&format!("item/{id}"))).await }, ); Self { story } } fn view(this: Self) -> AnyView { /* Suspense + 详情渲染 */ } }评论部分采用递归组件<Comment>:每层评论通过signal(true)记录展开状态,点击[-]/[+] N replies切换折叠(story.rs),inner_html=comment.content直接渲染服务端返回的 HTML 内容。pluralize函数负责英文单复数的正确输出,细节上保证了与 Hacker News 原文案的一致性。
用户页与导航
users.rs 同样基于#[lazy_route],展示注册时间(created)、Karma 值、个人简介(inner_html渲染about)并外链到 Hacker News 的 submissions / threads 页面。nav.rs 提供Home / New / Show / Ask / Jobs五个导航链接,全部使用leptos_router::components::A组件实现客户端内跳转(不刷新页面)。
从示例到生产:可迁移的工程要点
综合源码可以提炼出四条可直接迁移到自研项目的模式:
- 双入口 + feature 矩阵:用
csr / hydrate / ssr三个 feature 把客户端入口、水合入口、服务端入口隔离在同一 crate 中,#[cfg(feature = ...)]控制编译内容,[package.metadata.leptos]配置分目标构建(参考 main.rs 与 Cargo.toml); - Axum 四步接入:
get_configuration读配置 →generate_route_list生成路由表 →.leptos_routes注册 SSR →.fallback(file_and_error_handler)兜底静态资源与错误页; - 同构数据层:把网络请求收敛到一个模块,用 cfg 区分
reqwest(服务端)与gloo-net + AbortController(客户端),调用方只依赖Option<T>返回契约(api.rs); - 性能分层:
Lazy路由按需加载代码块、Resource::new_blocking提前预取数据、Transition保持界面连贯、RoutingProgress在慢网络下给出视觉反馈,四者组合即可获得接近原版 Hacker News 的浏览手感。
结语
hackernews_axum 示例在几十行配置与不到千行的业务代码中,完整覆盖了 Leptos 全栈开发的主干路径:Axum 服务端渲染、客户端水合、懒加载路由、响应式资源与同构数据获取。它以"Hacker News 克隆"这一足够真实的需求为载体,让每一种模式都能在 src 目录下找到对应的可运行实现。无论你是想为项目引入 SSR、还是评估 Leptos + Axum 的组合,这个示例都是最直接的起点。
【免费下载链接】leptosBuild fast web applications with Rust.项目地址: https://gitcode.com/GitHub_Trending/le/leptos
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考