Rust 全栈 SSR 用了一年,我踩过的 5 个坑和 3 个真香瞬间

一、缘起:为什么要用 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_axumaxumtokio 等完整后端依赖;在 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>
    }
}

HydrationScriptsAutoReload 放在 <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 有三种不同的执行上下文:

  1. SSR 渲染期间 :Server Function 在服务端"原地"执行,Leptos Context 里有 User
  2. WASM hydrate 后的 fetch 调用 :Server Function 变成 HTTP 请求,通过 Axum 中间件注入的 Extension<User> 才能拿到用户信息
  3. 某些边界情况 :中间件直接把 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 内部还有三套回退机制(HSStaticMethodsHSOverlayHSDropdown),因为不同版本的 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=truecodegen-units=1wasm-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 做了三件事:

  1. 检测 MiddlewareError 和包含 "401" / "unauthorized" 的错误 → redirect_to_login()
  2. 其他错误 → 根据 ServerFnError 的变体映射成中文提示("网络连接失败"、"数据格式错误"等)
  3. 统一的 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?遇到了什么坑、有什么真香的地方?评论区唠唠。

相关推荐
Rust研习社1 小时前
手把手带你使用 Bacon 高效开发应用
后端·rust·编程语言
前端再部署2 小时前
Nuxt3 AI Agent 控制台实战 08:部署跑通之后,正式进入 Agent Runtime 开发
全栈
日取其半万世不竭2 小时前
Supabase 自建:开源的 Firebase 替代品,带数据库的后端服务
数据库·开源
stereohomology2 小时前
DeepSeek对我首个Github开源项目mcp的点评
开源·github·mcp
stereohomology2 小时前
Qwen36plus对我首个开源在Github的mcp的点评和建议
开源·github
Arman_2 小时前
Rust 客户端安全上传下载微软 Azure Blob:rusty-cat SAS 预签名实战
安全·microsoft·rust·azure·断点续传
X54先生(人文科技)2 小时前
《元创力》纪实录·桥段古方新用:当“文明诊断书”成为星际法庭的密钥
人工智能·开源·ai写作·零知识证明
Arman_3 小时前
Rust 接入微软 Azure Blob 文件上传下载:rusty-cat 直连模式实战
microsoft·rust·azure·断点续传