首页
/ Dioxus 0.7 全栈电商站点:用 SSR + LiveView 双模式构建 FakeStoreAPI 商品站的完整拆解

Dioxus 0.7 全栈电商站点:用 SSR + LiveView 双模式构建 FakeStoreAPI 商品站的完整拆解

2026-09-05 12:33:30作者:何将鹤

本文围绕 Dioxus 官方示例库中的电商站点 Demo(examples/01-app-demos/ecommerce-site)展开:它是一个基于 FakeStoreAPI 假数据与 Tailwind CSS 的全栈 Web 应用。读完后你会掌握 Dioxus 0.7 中 dx serve 的开发流程、Tailwind watcher 的自动初始化机制、SSR 与 LiveView 两种渲染模式在同一站点中如何分工,以及 use_loaderuse_signalSuspenseBoundary 等关键 API 的实战用法。

电商站点运行效果:左侧商品列表页展示 FakeStoreAPI 拉取的商品,右侧为产品详情页

示例定位与功能状态

该示例是一个「工作进行中(work in progress)」的全栈 Web 应用,README(examples/01-app-demos/ecommerce-site/README.md)给出了明确的功能清单,可以作为理解整个代码结构的索引:

  • [x] 首页:动态从 FakeStoreAPI 拉取商品列表(SSR 渲染
  • [x] 商品详情页:展示单个商品详情(LiveView 渲染
  • [ ] 购物车页面
  • [ ] 结账页面
  • [ ] 登录页面

也就是说,当前仓库里实际交付的是前两项。这个「SSR 首页 + LiveView 详情页」的组合正是 Dioxus 0.7 全栈模型的核心卖点:静态内容直接由服务端产出 HTML,无需建立 WebSocket 连接;需要交互的页面则升级为 LiveView,保留持久连接进行客户端状态同步。源码中的注释印证了这一点,home.rs 第一行写道:「The homepage is statically rendered, so we don't need a persistent websocket connection.」(首页是静态渲染的,因此不需要持久 WebSocket 连接)。

技术栈与依赖配置

查看 Cargo.toml 可以看到该示例的完整依赖与平台条件依赖:

[package]
name = "ecommerce-site"
version = "0.1.1"
edition = "2024"
publish = false

[dependencies]
dioxus = { workspace = true, features = ["fullstack", "router"] }
reqwest = { workspace = true, features = ["json"] }
serde = { workspace = true }

# 客户端(WASM)与服务端使用不同的 chrono 特性组合
[target.'cfg(target_family = "wasm")'.dependencies]
chrono = { workspace = true, features = ["serde", "wasmbind"] }

[target.'cfg(not(target_family = "wasm"))'.dependencies]
chrono = { workspace = true, features = ["serde"] }

[features]
web = ["dioxus/web"]
server = ["dioxus/server"]

几个值得注意的配置点:

  1. dioxus 启用 fullstackrouter 两个 feature:前者提供 SSR/LiveView/Server Function 等全栈能力,后者提供声明式路由(本例中 Route 枚举派生了 Routable)。
  2. reqwest + serde 组合:由服务端直接请求 FakeStoreAPI 并把 JSON 反序列化为 Product 结构体,这是典型的「服务端取数」模式。
  3. 平台条件依赖chrono 在 WASM 目标下额外启用 wasmbind,体现了 Dioxus 全栈应用中同一代码库编译到客户端与服务端时依赖差异的处理方式。
  4. web / server 两个 feature 别名dx CLI 在按平台构建时分别启用它们,以链接正确的渲染端(dioxus/webdioxus/server)。

开发流程:dx serve 与 Tailwind watcher 自动初始化

启动开发服务器的命令只有一条(在示例目录下执行):

dx serve

README 特别指出一个 0.7 版本的行为变化:当应用根目录存在 tailwind.css 文件时,dx serve 会自动初始化 Tailwind watcher。本示例根目录恰好有一个 tailwind.css 输入文件,因此开发时无需手动启动 Tailwind 编译进程。

从 CLI 源码可以验证这一自动检测逻辑。packages/cli/src/tailwind.rs 中,当没有显式指定输入路径时,会回退到 manifest_dir.join("tailwind.css") 判断文件是否存在:

// packages/cli/src/tailwind.rs(节选逻辑)
.input_path()
.unwrap_or_else(|| manifest_dir.join("tailwind.css").exists())

输入文件确定后,输出默认落在 assets/tailwind.css。示例的入口组件通过 asset!("/public/tailwind.css") 引入编译产物(见下文 main.rs 分析),二者配合完成样式链路:tailwind.css(源)→ watcher 监听变更 → 编译输出 → 页面 <link> 引入。

项目结构总览

整个示例代码量很小,结构清晰,便于按文件逐个精读:

examples/01-app-demos/ecommerce-site/
├── Cargo.toml
├── README.md
├── tailwind.css              # Tailwind 输入(watcher 自动检测)
├── public/
│   ├── loading.css           # 加载动画样式
│   └── tailwind.css          # 编译产物入口
└── src/
    ├── main.rs               # 入口:launch + 路由定义
    ├── api.rs                # FakeStoreAPI 客户端 + 数据模型
    └── components/
        ├── error.rs          # 错误页面
        ├── home.rs           # 首页(SSR)
        ├── loading.rs       # SuspenseBoundary + spinner 包装器
        ├── nav.rs            # 顶部导航(含移动端汉堡菜单)
        ├── product_item.rs   # 商品卡片
        └── product_page.rs   # 商品详情页(LiveView 交互)

应用入口与路由定义

main.rs 展示了 Dioxus 0.7 的全栈应用骨架:

fn main() {
    dioxus::launch(|| {
        rsx! {
            document::Link {
                rel: "stylesheet",
                href: asset!("/public/tailwind.css")
            }
            ChildrenOrLoading {
                Router::<Route> {}
            }
        }
    });
}

#[derive(Clone, Routable, Debug, PartialEq)]
enum Route {
    #[route("/")]
    Home {},

    #[route("/details/:product_id")]
    Details { product_id: usize },
}

#[component]
fn Details(product_id: usize) -> Element {
    rsx! {
        div {
            components::nav::Nav {}
            components::product_page::ProductPage { product_id }
        }
    }
}

这里有四个要点:

  1. dioxus::launch(|| rsx! { ... }):0.7 的启动方式,传入根组件即可,平台(Web/桌面/移动)由 feature 决定。
  2. document::Link + asset!:以类型安全的方式注入 <link rel="stylesheet">asset!("/public/tailwind.css") 在构建期解析资源路径,而不是硬编码字符串 URL。
  3. Router::<Route> {}:泛型 Router 直接绑定路由枚举,#[route("/details/:product_id")] 声明了动态路由段,product_id: usize 由路径参数自动解析并作为 props 传入 Details 组件。
  4. ChildrenOrLoading 包裹整个路由树:所有路由切换期间的异步加载都被统一兜底为一个 spinner(下一节详述)。

API 层:FakeStoreAPI 客户端与数据模型

所有网络访问集中在 api.rs,对外暴露两个服务端执行的异步函数和三个数据模型:

// 拉取单个商品(详情页用)
pub(crate) async fn fetch_product(product_id: usize) -> Result<Product> {
    Ok(
        reqwest::get(format!("https://fakestoreapi.com/products/{product_id}"))
            .await?
            .json()
            .await?,
    )
}

// 拉取商品列表(首页用),支持排序与数量限制
pub(crate) async fn fetch_products(count: usize, sort: Sort) -> Result<Vec<Product>> {
    Ok(reqwest::get(format!(
        "https://fakestoreapi.com/products/?sort={sort}&limit={count}"
    ))
    .await?
    .json()
    .await?)
}

#[derive(Serialize, Deserialize, PartialEq, Clone, Debug, Default)]
pub(crate) struct Product {
    pub(crate) id: u32,
    pub(crate) title: String,
    pub(crate) price: f32,
    pub(crate) description: String,
    pub(crate) category: String,
    pub(crate) image: String,
    pub(crate) rating: Rating,
}

数据模型的几个细节值得学习:

  • Product / Rating 都派生 Serialize + Deserialize + Clone:全栈渲染模式下数据需要在服务端与(可能的)客户端之间传递,序列化边界必须提前定义好。
  • Rating 实现了自定义 Display:把 rate: f32 四舍五入后输出 ★★★★☆ (4) (123 ratings) 这样的星级文本,渲染时直接写 "{rating}" 即可,无需在模板里做字符串拼接:
impl Display for Rating {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let rounded = self.rate.round() as usize;
        for _ in 0..rounded { "★".fmt(f)?; }
        for _ in 0..(5 - rounded) { "☆".fmt(f)?; }
        write!(f, " ({:01}) ({} ratings)", self.rate, self.count)?;
        Ok(())
    }
}
  • Sort 枚举 + Display 映射查询参数Sort::Descending"desc"Sort::Ascending"asc",这样调用方传递的是语义化枚举,URL 拼接由 Display 完成。
  • 源码中的缓存意图:两个函数上方都留有注释「Cache up to 100 requests, invalidating them after 60 seconds」(缓存最多 100 个请求,60 秒后失效)。从源码结构看,当前实现尚未接入缓存中间件,这是示例后续迭代的预留方向。

首页:SSR 模式与 use_loader

home.rs 只有 20 来行,是理解 Dioxus 全栈数据加载的最小范例:

pub(crate) fn Home() -> Element {
    let products = use_loader(|| fetch_products(10, Sort::Ascending))?;

    rsx! {
        Nav {}
        section { class: "p-10",
            for product in products.iter() {
                ProductItem { product: product.clone() }
            }
        }
    }
}
  • use_loader(|| fetch_products(10, Sort::Ascending))?:声明式地在组件中执行异步任务,返回 Option 包装的加载结果;在数据到达之前,该组件会挂起并触发 SuspenseBoundary 的 fallback。? 运算符在加载出错时把错误向上抛给错误边界。
  • ? 即错误处理协议:配合 use_loader,组件不需要手写 match,网络错误会统一交由 SuspenseBoundary 的 fallback 或错误页面处理。
  • 首页固定拉取 10 个商品、按 ID 升序Sort::Ascending),即 ?sort=asc&limit=10 请求。
  • 列表渲染交给 product_item.rs:每个 ProductItem 渲染商品图、标题(链接指向 /details/{id})、星级、分类与价格。标题上的 href: "/details/{id}" 是字符串插值路由链接,点击后触发 Route::Details 的匹配。

由于首页没有任何客户端交互(无信号、无事件状态),它在 Dioxus 0.7 的全栈模型下走纯 SSR 路径:服务端执行 fetch_products,把渲染完成的 HTML 直接吐出,浏览器不需要为它建立 LiveView WebSocket。

商品详情页:LiveView 交互模式

product_page.rs 是第二个已交付页面,也是示例中交互逻辑最集中的文件。组件签名与数据加载:

#[component]
pub fn ProductPage(product_id: ReadSignal<usize>) -> Element {
    let mut quantity = use_signal(|| 1);
    let mut size = use_signal(Size::default);
    let product = use_loader(move || fetch_product(product_id()))?;
    ...
}
  • product_idReadSignal<usize>:路由参数以只读信号形式注入,product_id() 读取当前值。
  • fetch_product 仍是服务端取数:详情页的商品数据(标题、价格、描述、图片、评分)在服务端请求后随页面同步。
  • 两个纯客户端状态信号quantity(购买数量,初始 1)与 size(尺码,默认 Medium),它们只影响 UI,不触发网络请求——这正是 LiveView 的典型形态:数据来自服务端,交互状态留在页面。

数量步进器与尺码选择的实现

数量输入框绑定了一个受控数值信号,加减按钮与文本输入共用同一状态:

button {
    onclick: move |_| quantity += 1,
    icons::icon_2 {}
}
input {
    r#type: "number",
    value: "{quantity}",
    oninput: move |evt| {
        if let Ok(as_number) = evt.value().parse() {
            quantity.set(as_number)
        }
    },
}
button {
    onclick: move |_| quantity -= 1,
    icons::icon_3 {}
}

尺码下拉框通过 FromStr 把字符串选项安全地转换回枚举:

select {
    onchange: move |evt| {
        if let Ok(new_size) = evt.value().parse() {
            size.set(new_size);
        }
    },
    option { value: "1", "Medium" }
    option { value: "2", "Small" }
    option { value: "3", "Large" }
}

配套的 Size 枚举同时实现了 Display(枚举 → 小写字符串)与 FromStr(字符串 → 枚举),保证双向转换失败时静默忽略而不是 panic:

#[derive(Default)]
enum Size { Small, #[default] Medium, Large }

impl FromStr for Size {
    type Err = ();
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        use Size::*;
        match s.to_lowercase().as_str() {
            "small" => Ok(Small),
            "medium" => Ok(Medium),
            "large" => Ok(Large),
            _ => Err(()),
        }
    }
}

页面其余部分是典型的电商详情页布局:商品大图、h2 标题、星级评分、"${price}" 价格插值、描述文本、「Add to cart」按钮(当前 href="#" 占位,对应 README 中尚未实现的购物车页)以及社交分享图标区。

加载态与错误处理:ChildrenOrLoading

入口中用 loading.rs 里的包装器包裹了整个 Router,它演示了「Suspense 兜底 + 全局加载动画」的标准写法:

#[component]
pub(crate) fn ChildrenOrLoading(children: Element) -> Element {
    rsx! {
        Stylesheet { href: asset!("/public/loading.css") }
        SuspenseBoundary {
            fallback: |_| rsx! { div { class: "spinner", } },
            {children}
        }
    }
}
  • SuspenseBoundaryfallback 闭包:任何子树中的 use_loader 未就绪时,边界渲染一个 spinner div;loading.csspublic/loading.css)定义了转圈动画。
  • Stylesheet 组件 + asset!:与入口中的 document::Link 类似,以组件形式声明样式表依赖。
  • 错误场景则由 error.rs 中的 error_page 组件承载,当 use_loader 返回的错误冒泡到边界时展示「An internal error has occurred」的占位页面。

另外,nav.rs 实现了响应式顶部导航:桌面端(xl: 断点以上)展示分类链接、搜索框、购物车角标与 Sign In 按钮,窄屏下收起为汉堡菜单并展开侧滑式 navbar-menu,全部用 Tailwind 工具类实现,没有任何手写 CSS。

小结:这个示例教会了什么

对照 README 的功能清单与源码实现,这个电商 Demo 实际上是一张 Dioxus 0.7 全栈模式的速查表:

主题 对应文件 关键 API
全栈启动与路由 main.rs dioxus::launchRouter::<Route>#[route]
服务端取数 api.rs reqwest + serde 反序列化
SSR 列表页 home.rs use_loader
LiveView 交互页 product_page.rs use_signalReadSignalFromStr
加载/错误兜底 loading.rs SuspenseBoundaryStylesheetasset!
开发体验 tailwind.rs dx serve 自动检测 tailwind.css 并启动 watcher

购物车、结账与登录页面在 README 中仍标记为未实现,导航栏中的相关入口目前都是 href="/"href="#" 占位——如果你要在本地继续扩展,最直接的路径就是新增 Route 变体(如 /cart),并在其中复用 api.rs 的取数模式。

登录后查看全文
热门项目推荐
相关项目推荐