一、缘起:为什么要用 Rust 写前端
一年前我在做这个家政 SaaS 系统的时候,面临一个很现实的问题:我是一个人,要写前端、后端、数据库、部署。当时后端已经用 Rust(Axum + SeaORM + disintegrate),前端如果再用 React/Vue,就得维护两套语言、两套类型系统、两套构建工具链。
正好那段时间刷到了 Leptos,一个 Rust 全栈框架,号称可以 SSR + WASM hydration,一个 main.rs 搞定所有事。抱着"大不了回退到 React"的心态,我开了这个坑。
提前声明:这不是框架对比文,也不是"Rust 前端暴打 JS"的引战贴。只是在一个真实项目中用了一年之后,客观记下来的真实体验。如果你也在考虑用 Rust 写全栈,希望这篇能帮你少走点弯路。
二、初体验:从 cargo-leptos build,到第一页 SSR 渲染出来
2.1 项目结构:三个 crate,一个思路
Pico-CRM 的代码组织是这样的:
bash
pico-crm/
app/ # 共享代码:components、pages、server functions
frontend/ # WASM 入口:cdylib,只做 hydrate
server/ # SSR 入口:Axum 二进制,处理 HTTP + SSR 渲染
backend/ # 纯业务逻辑:DDD、CQRS、SeaORM
关键在于 app crate。它用两个 feature flag 区分编译目标:
toml
# app/Cargo.toml
[features]
default = []
hydrate = ["leptos/hydrate"]
ssr = [
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"dep:leptos_axum",
"dep:axum",
"dep:tokio",
"backend",
]
同一个 app crate,在 server 里编译时带 ssr feature------包含后端逻辑、leptos_axum、axum、tokio 等完整后端依赖;在 frontend 里编译时带 hydrate feature------只包含前端渲染逻辑,所有后端依赖全部裁剪掉。同一个 #[server] 函数,在 SSR 侧直接调用本体,在 WASM 侧自动生成 HTTP fetch 调用。
第一印象:这套设计挺聪明的。 不需要手动定义 API 契约,你在 Rust 里写一个函数,Leptos 就帮你搞定前后端的序列化和通信。
2.2 第一个 SSR 页面跑起来
在 server/src/main.rs 里,整个启动链路是这样的:
rust
// server/src/main.rs
#[tokio::main]
async fn main() {
let env = env::var("APP_ENV").unwrap_or_else(|_| "dev".to_string());
let env_file = format!(".env.{}", env);
dotenvy::from_filename(&env_file)
.unwrap_or_else(|_| panic!("无法读取 {} 文件", env_file));
let db = Database::new().await;
Migrator::up(db.get_connection(), None)
.await
.unwrap_or_else(|err| panic!("执行数据库迁移失败: {}", err));
bootstrap_cqrs(db.connection.clone())
.await
.unwrap_or_else(|err| panic!("启动 CQRS 基础设施失败: {}", err));
let conf = get_configuration(None)
.unwrap_or_else(|err| panic!("加载 Leptos 配置失败: {}", err));
let routes = generate_route_list(App);
let db_clone = db.clone();
let app = Router::new()
.leptos_routes_with_context(
&leptos_options,
routes,
move || {
provide_context(db.clone()); // Database 注入 Leptos Context
},
{
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone()) // SSR Shell
},
)
.layer(from_fn_with_state(db_clone, global_api_auth_middleware))
.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();
}
这里有两点值得展开。
第一,leptos_routes_with_context 是踩了坑之后的版本。 模板默认用的是 leptos_routes,但那个接口把 context 注入和 shell 渲染塞在同一个闭包里,导致 Server Function 在某些场景下拿不到 context。下面 3.1 节会详细展开这个问题。
第二,shell() 函数在 app/src/lib.rs 里,负责输出 HTML 壳子:
rust
// app/src/lib.rs
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/> // 页面主体
<script src="/vendor/flatpickr.min.js"></script>
<script src="/vendor/zh.js"></script>
<script src="/vendor/flyonui.js"></script> // 第三方 JS 组件库
<script src="/sw-register.js"></script> // PWA Service Worker
</body>
</html>
}
}
HydrationScripts 和 AutoReload 放在 <head> 里开启 WASM hydration。<App/> 是页面内容------在 SSR 阶段服务端渲染出完整 HTML,浏览器收到后 WASM 下载完成再 hydrate 绑定交互。
构建产物就一个二进制:
bash
$ cargo-leptos build --release
# 产物:target/server/pico-crm(后端 + WASM + 静态文件全包)
$ APP_ENV=prod ./target/server/pico-crm # 一条命令启动
部署不需要 nginx 反代,不需要 npm dev server,不需要单独跑 migration。 这一刻确实很爽。
三、进阶:踩过的五个坑
3.1 Context 跨边界注入------SSR 和 WASM 是两个世界
第一个大坑出现在认证逻辑上。
我在 Axum 中间件里解析了 JWT Cookie,拿到了 User 对象。按理说在 Server Function 里应该能直接拿到这个 User------但实际是,有些场景下拿不到。
原因是 Server Function 有三种不同的执行上下文:
- SSR 渲染期间 :Server Function 在服务端"原地"执行,Leptos Context 里有
User - WASM hydrate 后的 fetch 调用 :Server Function 变成 HTTP 请求,通过 Axum 中间件注入的
Extension<User>才能拿到用户信息 - 某些边界情况 :中间件直接把
TenantContext挂在 Extension 上,而不是User
所以我的 resolve_tenant_context() 函数变成了三层回退:
rust
// app/src/server/mod.rs
#[cfg(feature = "ssr")]
pub async fn resolve_tenant_context(
) -> Result<backend::infrastructure::tenant::TenantContext, ServerFnError> {
use axum::Extension;
use leptos::prelude::use_context;
use leptos_axum::extract;
// 第一层:尝试 Axum Extension(TenantContext)
if let Ok(Extension(tenant)) = extract::<Extension<TenantContext>>().await {
return Ok(tenant);
}
// 第二层:尝试 Leptos Context(SSR 路径,从 User 提取租户信息)
if let Some(user) = use_context::<User>() {
if let Some(merchant_id) = user.merchant_uuid
.clone()
.filter(|id| !id.trim().is_empty())
{
return Ok(TenantContext { merchant_id, role: user.role });
}
}
// 第三层:尝试 Axum Extension(User,fetch 路径)
if let Ok(Extension(user)) = extract::<Extension<User>>().await {
if let Some(merchant_id) = user.merchant_uuid
.clone()
.filter(|id| !id.trim().is_empty())
{
return Ok(TenantContext { merchant_id, role: user.role });
}
}
Err(ServerFnError::new("缺少租户上下文,无法识别当前商户".to_string()))
}
第二层和第三层的逻辑几乎一模一样------都从 User.merchant_uuid 里提取租户 ID 并验证非空------唯一的区别是 User 的来源不同:一个来自 Leptos Context、一个来自 Axum Extension。
体会:Leptos 的 "同构" 只是代码同构,不是运行时同构。 SSR 和 WASM hydration 是两个完全不同的运行环境,Context 的生命周期、注入方式都不一样。如果不搞清楚每一层数据的来路,很容易出现"SSR 页面正常,但客户端导航就报 401"的情况。
3.2 Resource vs LocalResource------SSR 数据加载的取舍
Leptos 提供了两种数据加载方式:
Resource:SSR 期间就会执行,数据序列化进 HTML。首屏能看到数据,但增加了服务端负担。LocalResource:只在客户端执行,SSR 时不跑。首屏可能是空白,但不会拖慢 SSR。
在联系人列表页,我用的是 Resource------因为列表数据对首屏体验至关重要,需要 SSR 直接渲染出来:
rust
// app/src/pages/contacts_management.rs
let data = Resource::new(
move || {
(
sort_ops.get(),
name.get(),
address_keyword.get(),
tag_keyword.get(),
follow_up_status.get(),
refresh_count.get(),
query.with(|value| value.clone()),
)
},
|(sort_ops, name, address_keyword, tag_keyword, follow_up_status, _, query)| async move {
let page = query.get("page").unwrap_or_default().parse::<u64>().unwrap_or(1);
let page_size = query.get("page_size").unwrap_or_default().parse::<u64>().unwrap_or(10);
// 构建 params...
call_api(fetch_contacts(params)).await.unwrap_or_default()
},
);
Resource 的第一个闭包返回所有响应式依赖------只要任何一个变化,数据就重新加载。第二个闭包执行实际的 fetch 逻辑,在 SSR 期间直接调用 fetch_contacts 的服务端实现。
而 Dashboard 页面用的是 LocalResource,因为它是实时数据看板,首屏先出骨架再异步加载更合理:
rust
// app/src/pages/dashboard.rs
let data = LocalResource::new(move || async move {
let preset = preset.get();
let start = date_start.get();
let end = date_end.get();
let _refresh_count = refresh_count.get();
let (preset, start, end) = if preset == "custom" {
(None, normalize_optional(&start), normalize_optional(&end))
} else {
(Some(preset), None, None)
};
let query = MerchantDashboardQuery {
preset, start, end,
timezone: Some("Asia/Shanghai".to_string()),
granularity: Some("day".to_string()),
};
call_api(fetch_merchant_dashboard(query)).await
});
LocalResource 在 SSR 期间直接跳过,返回 None。WASM hydrate 完成后再异步拉数据,页面先出布局、后填数据。
经验: 不是所有数据都要在 SSR 阶段加载。业务列表页(对首屏有关键影响的)用 Resource,实时看板、个人信息(取决于 Cookie / 浏览器环境的)用 LocalResource。两者混用是常态,不是设计缺陷。
3.3 第三方 JS 库 + WASM Hydration = 一场时序噩梦
Pico-CRM 用了 FlyonUI(一个基于 Tailwind 的 UI 组件库),它的侧边栏、下拉菜单、日期选择器都依赖 JavaScript 初始化。
问题是:FlyonUI 的 JS 脚本在 <body> 末尾加载,但 WASM 的 hydrate_lazy 是异步的------DOM 节点可能还没出来,JS 初始化已经跑完了。结果就是:hydration 完成后,侧边栏折叠按钮不响应、下拉菜单不弹出。
我的解决方案是在 Navbar 组件的 Effect 里做延迟重试初始化:
rust
// app/src/components/features/navbar.rs
// WASM 侧的 FlyonUI 初始化函数
#[cfg(target_arch = "wasm32")]
fn try_init_flyonui_components() {
let Some(window) = web_sys::window() else { return };
let Some(document) = window.document() else { return };
// 检查 DOM 元素就绪
if document.get_element_by_id("collapsible-mini-sidebar").is_none() {
return;
}
// 优先尝试 HSStaticMethods.autoInit()
let static_methods = js_sys::Reflect::get(&window, &JsValue::from_str("HSStaticMethods"));
if let Ok(static_methods) = static_methods {
if !static_methods.is_undefined() && !static_methods.is_null() {
let auto_init = js_sys::Reflect::get(&static_methods, &JsValue::from_str("autoInit"));
if let Ok(auto_init) = auto_init {
if let Ok(auto_init) = auto_init.dyn_into::<js_sys::Function>() {
let collections = js_sys::Array::of2(
&JsValue::from_str("overlay"),
&JsValue::from_str("dropdown"),
);
let _ = auto_init.call1(&static_methods, &collections);
return;
}
}
}
}
// 回退:单独调 HSOverlay.autoInit()
// 再回退:单独调 HSDropdown.autoInit()
// ...
}
// 带重试的初始化:立即试一次,100ms 后再试一次,400ms 后再试一次
#[cfg(target_arch = "wasm32")]
fn init_flyonui_overlay_with_retry() {
try_init_flyonui_components();
let Some(window) = web_sys::window() else { return };
// 100ms 后重试
let cb_1 = Closure::once_into_js(|| { try_init_flyonui_components(); });
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
cb_1.unchecked_ref(), 100,
);
// 400ms 后再重试一次
let cb_2 = Closure::once_into_js(|| { try_init_flyonui_components(); });
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
cb_2.unchecked_ref(), 400,
);
}
// SSR 侧空实现
#[cfg(not(target_arch = "wasm32"))]
fn init_flyonui_overlay_with_retry() {}
// 在组件 Effect 中调用
#[component]
pub fn Navbar() -> impl IntoView {
Effect::new(move |_| {
init_flyonui_overlay_with_retry();
});
// ... 渲染逻辑
}
三个时间点(0ms、100ms、400ms)各尝试一次初始化,覆盖 hydration 完成前后的窗口期。如果 DOM 还没准备好(get_element_by_id 返回 None),函数直接 return,等下一个时间点再试。
这不是优雅的方案,但它是唯一有效的方案。 try_init_flyonui_components 内部还有三套回退机制(HSStaticMethods → HSOverlay → HSDropdown),因为不同版本的 FlyonUI 把初始化函数放在不同的命名空间下。如果让我重新选,可能会优先考虑纯 Rust 的 UI 组件库,但在 Leptos 生态还不够成熟的当下,这种 WASM-JS 交界的摩擦几乎是不可避免的。
3.4 thread_local! 全局弹窗------没有 Context 总线时的土办法
Leptos 0.8 没有一个内置的全局事件总线,但 Toast 通知和 MessageBox 确认弹窗又需要从任何地方触发------表单提交成功弹 toast、删除操作前弹确认框。
常规做法是通过 Context 层层传递 signal,但在一个十几页的 SaaS 系统里这样写太痛苦了。我用了 thread_local! 做全局单例:
rust
// app/src/components/ui/toast.rs
thread_local! {
static TOAST_SIGNAL: RefCell<Option<RwSignal<ToastState>>> = const { RefCell::new(None) };
}
#[derive(Debug, Clone, Default)]
pub struct ToastState {
message: Option<String>,
toast_type: Option<ToastType>,
visible: bool,
}
// Toast 组件挂载时注册自己
#[component]
pub fn Toast() -> impl IntoView {
let toast_state = RwSignal::new(ToastState::default());
TOAST_SIGNAL.with(|slot| {
*slot.borrow_mut() = Some(toast_state);
});
// Effect 自动隐藏:3 秒后 visible = false
Effect::new(move |_| {
if toast_state.with(|s| s.visible) {
set_timeout(
move || {
toast_state.set(ToastState { visible: false, ..Default::default() });
},
Duration::from_secs(3),
);
}
});
view! {
<div class="toast toast-top toast-center z-[100]">
<div class=move || if toast_state.with(|s| s.visible) { "block" } else { "hidden" }>
{move || {
toast_state.with(|state| {
let msg = state.message.clone()?;
let class = match state.toast_type.unwrap_or(ToastType::Info) {
ToastType::Success => "alert alert-success",
ToastType::Error => "alert alert-error",
ToastType::Warning => "alert alert-warning",
ToastType::Info => "alert alert-info",
};
Some(view! { <div class=class>{msg}</div> })
})
}}
</div>
</div>
}
}
// 内部辅助函数
fn show_toast(message: String, toast_type: ToastType) {
TOAST_SIGNAL.with(|slot| {
if let Some(state) = *slot.borrow() {
state.set(ToastState {
message: Some(message),
toast_type: Some(toast_type),
visible: true,
..Default::default()
});
}
});
}
// 对外暴露的便捷函数
pub fn success(message: String) { show_toast(message, ToastType::Success); }
pub fn error(message: String) { show_toast(message, ToastType::Error); }
pub fn warning(message: String) { show_toast(message, ToastType::Warning); }
pub fn info(message: String) { show_toast(message, ToastType::Info); }
优点: 任何地方一行 toast::success("保存成功".to_string()) 就能弹通知。不用 Context 传递,不用 prop drilling。
缺点: 只能在单线程环境中用(好在 WASM 和 tokio 单线程 SSR 都满足),写单元测试需要额外处理。set_timeout 的 3 秒自动消失逻辑在某些极端场景下可能和用户操作产生竞态,但实际使用中还没遇到过问题。
3.5 hydrate_lazy 的渐进式 hydration 没那么美好
Leptos 0.8 的 hydrate_lazy 不是一次性 hydration 整个应用,而是按需加载:
rust
// frontend/src/lib.rs
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
use app::*;
#[cfg(debug_assertions)]
{
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
}
leptos::mount::hydrate_lazy(App);
}
理论上,用户进来只会看到 Dashboard,其他页面的组件不会 hydrate------直到他点进那个页面。这对首屏 WASM 初始化时间有明显改善。
但实际体验是:路由切换时会有一个短暂的"空白闪烁",因为新路由对应的 WASM chunk 要先下载、初始化、再匹配 SSR 的 DOM 。在网速不好的情况下,这种闪烁体感很明显。Pico-CRM 的 WASM release profile 已经把能开的优化全开了(opt-level='z'、lto=true、codegen-units=1、wasm-opt -Oz),但路由懒加载的延迟依然存在。
我的做法是给 <Routes> 配一个 fallback,路由切换期间的空白由 SSR 时已经渲染好的静态 HTML 兜底------至少不会有白屏,只是交互上暂时不可点击。
体会: hydrate_lazy 是一个"用空间换时间"的权衡。它确实减少了首屏 JS 体积,但路由切换的延迟是新引入的问题。如果你的应用页面不多、WASM 总体积也不大,直接全量 hydrate 可能体感更好。
四、实战落地:哪些地方真的香
4.1 Server Function 的类型安全是全栈的
前端调后端接口,改一个字段要前后端各改一次?在 Leptos 里不存在。#[server] 函数的入参和返回值是同一个 Rust 类型:
rust
// app/src/server/contact_handlers.rs
#[server(
name = FetchContactsFn,
prefix = "/api",
endpoint = "/fetch_contacts",
)]
pub async fn fetch_contacts(
params: ContactQuery, // 前后端共享同一个 struct(定义在 shared crate)
) -> Result<ListResult<Contact>, ServerFnError> {
use self::ssr::*; // SSR 侧的实际实现,WASM 侧不会编译
let pool = expect_context::<Database>();
let tenant = crate::server::resolve_tenant_context().await?;
// ... 业务逻辑
}
use self::ssr::* 是关键------这个模块只在 #[cfg(feature = "ssr")] 下编译,WASM 侧只看到一个自动生成的 HTTP fetch stub。你改了 ContactQuery 加个字段,编译不过的地方就是你需要处理的地方,前后端同时暴露。
编译期保证 + 共享类型 = 消除一整类 runtime bug。 这在 JavaScript 全栈框架里虽然也能做到(tRPC 就是类似思路),但在 Rust 里它是编译时零成本的。
4.2 call_api 统一封装------错误处理不再散落各处
十几个 Server Function,每个都要写一遍"调失败了怎么办"的逻辑会很灾难。Pico-CRM 封装了一个 call_api:
rust
// app/src/utils/api.rs
pub async fn call_api<T, F>(server_fn_call: F) -> Result<T, String>
where
F: Future<Output = Result<T, ServerFnError>>,
{
match server_fn_call.await {
Ok(data) => Ok(data),
Err(e) => {
handle_api_error(&e); // 401 跳登录 + toast 提示
Err(format_api_error(&e)) // 返回清理后的错误信息
}
}
}
handle_api_error 做了三件事:
- 检测
MiddlewareError和包含 "401" / "unauthorized" 的错误 →redirect_to_login() - 其他错误 → 根据
ServerFnError的变体映射成中文提示("网络连接失败"、"数据格式错误"等) - 统一的 toast 弹窗
这样所有页面的调用都变成一行:
rust
let result = call_api(fetch_contacts(params)).await;
// result: Result<ListResult<Contact>, String>
// 401 自动跳登录、其他错误已弹 toast,业务层只需处理成功路径
一个统一封装,省掉了十几个页面里重复的错误处理代码。
4.3 一个二进制部署的极致简单
bash
$ cargo-leptos build --release
$ scp target/server/pico-crm user@vps:/opt/pico-crm/
$ APP_ENV=prod /opt/pico-crm/pico-crm
没有 CI 里的一坨构建步骤,没有 Dockerfile 里三层嵌套,没有 npm install && npm run build && node dist/index.js。一个二进制,包含 SSR 渲染 + WASM 静态资源 + API 服务 + 数据库 migration。VPS 部署的极致简单,是一年下来最持久的爽感来源。
当然这个方案也有局限------单二进制意味着无状态部署靠数据库,水平扩展需要额外处理 CQRS 投影的 Leader 选举(上一篇已经详细讲过了)。但在 MVP 阶段、一个 VPS 够用的场景下,这种简单性是很难被替代的。
五、总结
用 Rust 写全栈前端,不是银弹,但它在一个人的独立开发场景下,确实是最优解之一。
回顾这一年的体验:
- 真香的: 一个二进制部署、前后端共享类型、编译期消除 bug、
call_api统一错误处理 - 需要忍的: 第三方 JS 库集成(WASM-JS 时序问题)、Context 双世界的认知成本、
hydrate_lazy路由切换的延迟 - 还在等的: Leptos 生态的 UI 组件库还不够丰富,很多轮子要自己造(Toast、MessageBox、Form 校验都是手写)
如果你的项目是一个人 or 小团队在做、后端已经是 Rust、对部署简单性和类型安全有高要求------那 Leptos 值得一试。如果你有一个成熟的前端团队用 React/Vue 出活很快,那没有必要硬上 Rust 前端。
完整的代码在 GitHub 搜 Pico-CRM,Rust 全栈(Axum + Leptos + SeaORM + disintegrate),欢迎 Star 和交流。
你用过 Rust 写前端吗?是 Leptos、Dioxus 还是 Yew?遇到了什么坑、有什么真香的地方?评论区唠唠。