Dioxus 0.7 全栈电商站点:用 SSR + LiveView 双模式构建 FakeStoreAPI 商品站的完整拆解
本文围绕 Dioxus 官方示例库中的电商站点 Demo(examples/01-app-demos/ecommerce-site)展开:它是一个基于 FakeStoreAPI 假数据与 Tailwind CSS 的全栈 Web 应用。读完后你会掌握 Dioxus 0.7 中 dx serve 的开发流程、Tailwind watcher 的自动初始化机制、SSR 与 LiveView 两种渲染模式在同一站点中如何分工,以及 use_loader、use_signal、SuspenseBoundary 等关键 API 的实战用法。
示例定位与功能状态
该示例是一个「工作进行中(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"]
几个值得注意的配置点:
dioxus启用fullstack与router两个 feature:前者提供 SSR/LiveView/Server Function 等全栈能力,后者提供声明式路由(本例中Route枚举派生了Routable)。reqwest+serde组合:由服务端直接请求 FakeStoreAPI 并把 JSON 反序列化为Product结构体,这是典型的「服务端取数」模式。- 平台条件依赖:
chrono在 WASM 目标下额外启用wasmbind,体现了 Dioxus 全栈应用中同一代码库编译到客户端与服务端时依赖差异的处理方式。 web/server两个 feature 别名:dxCLI 在按平台构建时分别启用它们,以链接正确的渲染端(dioxus/web或dioxus/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 }
}
}
}
这里有四个要点:
dioxus::launch(|| rsx! { ... }):0.7 的启动方式,传入根组件即可,平台(Web/桌面/移动)由 feature 决定。document::Link+asset!宏:以类型安全的方式注入<link rel="stylesheet">,asset!("/public/tailwind.css")在构建期解析资源路径,而不是硬编码字符串 URL。Router::<Route> {}:泛型 Router 直接绑定路由枚举,#[route("/details/:product_id")]声明了动态路由段,product_id: usize由路径参数自动解析并作为 props 传入Details组件。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_id是ReadSignal<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}
}
}
}
SuspenseBoundary的fallback闭包:任何子树中的use_loader未就绪时,边界渲染一个spinnerdiv;loading.css(public/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::launch、Router::<Route>、#[route] |
| 服务端取数 | api.rs | reqwest + serde 反序列化 |
| SSR 列表页 | home.rs | use_loader |
| LiveView 交互页 | product_page.rs | use_signal、ReadSignal、FromStr |
| 加载/错误兜底 | loading.rs | SuspenseBoundary、Stylesheet、asset! |
| 开发体验 | tailwind.rs | dx serve 自动检测 tailwind.css 并启动 watcher |
购物车、结账与登录页面在 README 中仍标记为未实现,导航栏中的相关入口目前都是 href="/" 或 href="#" 占位——如果你要在本地继续扩展,最直接的路径就是新增 Route 变体(如 /cart),并在其中复用 api.rs 的取数模式。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
