第十三章:列表渲染与虚拟滚动优化
博客v1.0系列教程(Dioxus)博客 v1.0 系列教程 (Dioxus)
第十三章:列表渲染与虚拟滚动优化
1. 基础列表渲染
1.1 使用 for 循环
Dioxus 的 rsx! 宏支持直接在模板中使用 for 循环渲染列表:
#[component]
fn ArticleList(articles: Vec<ArticleSummary>) -> Element {
rsx! {
div { class: "flex flex-col gap-3",
for article in &articles {
ArticleCard { summary: article.clone() }
}
}
}
}
// 或者直接内联
rsx! {
ul {
for (i, item) in items.iter().enumerate() {
li { key: "{i}", "{i}. {item}" }
}
}
}
1.2 key 属性的重要性
Dioxus 使用 key 来追踪列表中每个元素的身份。没有 key 或使用了不稳定的 key,会导致不必要的重渲染:
// ❌ 不推荐 —— 使用索引作为 key 在列表变化时会导致问题
for (i, article) in articles.iter().enumerate() {
ArticleCard { key: "{i}", summary: article.clone() }
}
// ✅ 推荐 —— 使用稳定唯一的 slug 作为 key
for article in &articles {
ArticleCard { key: "{article.slug}", summary: article.clone() }
}
key 的最佳实践:
- 使用数据本身的唯一标识符(ID、slug、UUID)
- 不要使用数组索引(除非列表永不变化)
- 不要使用随机数(每次渲染都会变化)
- key 只在同层兄弟元素中需要唯一
1.3 条件内循环渲染
#[component]
fn ArticleSection(articles: Vec<ArticleSummary>, series: Option<String>) -> Element {
// 按系列分组
let grouped: Vec<(&str, Vec<&ArticleSummary>)> = {
let mut map = std::collections::HashMap::new();
for a in &articles {
let key = a.series.as_deref().unwrap_or("随笔");
map.entry(key).or_insert_with(Vec::new).push(a);
}
map.into_iter().collect()
};
rsx! {
for (series_name, items) in &grouped {
div { class: "mb-8",
h2 { class: "text-lg font-bold mb-3", "{series_name}" }
div { class: "flex flex-col gap-2",
for article in items {
ArticleCard { summary: (*article).clone() }
}
}
}
}
}
}
2. 过滤与排序
2.1 响应式筛选
#[component]
fn FilterableList() -> Element {
let articles = use_signal(|| load_all_articles());
let search = use_signal(String::new);
let selected_category = use_signal(|| "all".to_string());
// 派生状态:自动根据搜索和分类过滤
let filtered = use_memo(move || {
let query = search().to_lowercase();
let cat = selected_category();
articles().into_iter().filter(|a| {
let match_search = query.is_empty()
|| a.title.to_lowercase().contains(&query)
|| a.tags.iter().any(|t| t.to_lowercase().contains(&query));
let match_category = cat == "all"
|| a.category.as_deref() == Some(&cat);
match_search && match_category
}).collect::<Vec<_>>()
});
rsx! {
div { class: "space-y-4",
// 搜索框
input {
class: "w-full border rounded-lg px-4 py-2",
placeholder: "搜索文章...",
value: "{search}",
oninput: move |e| search.set(e.value()),
}
// 结果统计
p { class: "text-sm text-gray-500",
"共 {filtered.len()} 篇"
}
// 列表
for article in filtered.read().iter() {
ArticleCard { summary: article.clone() }
}
}
}
}
2.2 排序
enum SortOrder {
Newest,
Oldest,
Title,
}
#[component]
fn SortableList() -> Element {
let articles = load_all_articles();
let mut order = use_signal(|| SortOrder::Newest);
let sorted = use_memo(move || {
let mut items = articles.clone();
match order() {
SortOrder::Newest => items.reverse(), // 假设数组按旧到新排列
SortOrder::Oldest => {} // 保持原序
SortOrder::Title => items.sort_by(|a, b| a.title.cmp(&b.title)),
}
items
});
rsx! {
div {
div { class: "flex gap-2 mb-4",
// 排序按钮
SortButton {
label: "最新",
active: matches!(order(), SortOrder::Newest),
onclick: move |_| order.set(SortOrder::Newest),
}
SortButton {
label: "最早",
active: matches!(order(), SortOrder::Oldest),
onclick: move |_| order.set(SortOrder::Oldest),
}
SortButton {
label: "标题",
active: matches!(order(), SortOrder::Title),
onclick: move |_| order.set(SortOrder::Title),
}
}
for article in sorted.read().iter() {
ArticleCard { summary: article.clone() }
}
}
}
}
3. 分页
3.1 客户端分页
#[component]
fn PaginatedList() -> Element {
let articles = load_all_articles();
let page = use_signal(|| 0usize);
let page_size = 5;
let total_pages = (articles.len() + page_size - 1) / page_size;
let page_items = use_memo(move || {
let start = page() * page_size;
articles.iter().skip(start).take(page_size).cloned().collect::<Vec<_>>()
});
rsx! {
div { class: "space-y-4",
for article in page_items.read().iter() {
ArticleCard { summary: article.clone() }
}
// 分页器
div { class: "flex items-center justify-center gap-2 mt-6",
// 上一页
button {
class: "px-3 py-1 rounded border",
disabled: page() == 0,
onclick: move |_| page -= 1,
"←"
}
// 页码
for p in 0..total_pages {
button {
class: "px-3 py-1 rounded border {if page() == p { \"bg-blue-500 text-white\" } else { \"\" }}",
onclick: move |_| page.set(p),
"{p + 1}"
}
}
// 下一页
button {
class: "px-3 py-1 rounded border",
disabled: page() + 1 >= total_pages,
onclick: move |_| page += 1,
"→"
}
}
// 页码信息
div { class: "text-center text-sm text-gray-500",
"第 {page() + 1} / {total_pages} 页"
}
}
}
}
4. 无限滚动
4.1 滚动加载更多
#[component]
fn InfiniteScrollList() -> Element {
let all_articles = load_all_articles();
let visible_count = use_signal(|| 10);
let loading = use_signal(|| false);
let visible_articles = use_memo(move || {
all_articles.iter().take(visible_count()).cloned().collect::<Vec<_>>()
});
let has_more = visible_count() < all_articles.len();
// 监听滚动事件
let load_more = move |_| {
if loading() || !has_more { return; }
loading.set(true);
// 模拟加载延迟
spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
visible_count += 10;
loading.set(false);
});
};
rsx! {
div {
class: "space-y-4",
onscroll: load_more,
style: "max-height: 80vh; overflow-y: auto;",
for article in visible_articles.read().iter() {
ArticleCard { summary: article.clone() }
}
// 底部加载状态
div { class: "text-center py-4",
if loading() {
span { "加载中..." }
} else if has_more {
span { class: "text-gray-400", "向下滚动加载更多" }
} else {
span { class: "text-gray-400", "—— 已显示全部文章 ——" }
}
}
}
}
}
5. 虚拟滚动
当列表达到数千条时,直接渲染所有 DOM 元素会严重拖慢性能。虚拟滚动只渲染可见区域内的元素。
5.1 手动实现简易虚拟滚动
#[component]
fn VirtualList(items: Vec<ListItem>, item_height: usize, container_height: usize) -> Element {
let scroll_top = use_signal(|| 0usize);
let total_height = items.len() * item_height;
let visible_count = (container_height + item_height - 1) / item_height;
let start_index = scroll_top() / item_height;
let end_index = (start_index + visible_count + 2).min(items.len()); // +2 缓冲区
let visible_items = &items[start_index..end_index];
rsx! {
div {
class: "overflow-y-auto",
style: "height: {container_height}px",
onscroll: move |e| scroll_top.set(e.scroll_top() as usize),
// 占位容器,撑起滚动条
div {
style: "height: {total_height}px; position: relative;",
// 渲染可见项
for (i, item) in visible_items.iter().enumerate() {
let index = start_index + i;
div {
key: "{item.id}",
style: "position: absolute; top: {index * item_height}px; height: {item_height}px; left: 0; right: 0;",
"{item.content}"
}
}
}
}
}
}
5.2 使用窗口观察器(Intersection Observer)
对于更复杂的场景,可以用 Intersection Observer 检测元素是否可见:
// 注意:Dioxus 原生不提供 IntersectionObserver,
// 可以通过 WebSys 绑定或使用 onmounted 回调 + JS 方式实现
#[component]
fn LazyImage(src: String) -> Element {
let loaded = use_signal(|| false);
// 使用 IntersectionObserver 懒加载
use_effect(move || {
// 通过 web-sys 创建 IO
// 当图片进入视口时设置 loaded = true
});
rsx! {
if loaded() {
img { src: "{src}", class: "w-full h-auto" }
} else {
div { class: "w-full h-48 bg-gray-200 animate-pulse" }
}
}
}
6. 性能对比
| 方案 | 元素数量 | 初始渲染 | 滚动性能 | 实现复杂度 | |------|---------|---------|---------|-----------| | 基础 for 循环 | ≤ 100 | 快 | 快 | 低 | | 客户端分页 | ≤ 500 | 快 | 快 | 低 | | 无限滚动 | ≤ 1000 | 中 | 中 | 中 | | 虚拟滚动 | ≥ 10000 | 极快 | 极快 | 高 |
7. 实战:文章列表页优化
结合之前所学,优化博客的文章列表页:
#[component]
fn OptimizedArticleList() -> Element {
let articles = load_all_articles();
let search = use_signal(String::new);
let page = use_signal(|| 0);
let page_size = 8;
// 过滤
let filtered = use_memo(move || {
let q = search().to_lowercase();
if q.is_empty() {
articles.clone()
} else {
articles.iter().filter(|a| {
a.title.to_lowercase().contains(&q)
|| a.tags.iter().any(|t| t.contains(&q))
}).cloned().collect()
}
});
// 分页
let page_items = use_memo(move || {
let total = filtered().len();
let max_page = (total + page_size - 1) / page_size;
if max_page == 0 {
(Vec::new(), 0usize, 0usize)
} else {
let p = page().min(max_page - 1);
let start = p * page_size;
let end = (start + page_size).min(total);
(filtered()[start..end].to_vec(), p + 1, max_page)
}
});
let (items, current, total) = &*page_items.read();
rsx! {
div { class: "space-y-4",
// 搜索
input {
class: "w-full border rounded-lg px-4 py-2 mb-4",
placeholder: "搜索文章...",
value: "{search}",
oninput: move |e| search.set(e.value()),
}
// 结果
if items.is_empty() {
div { class: "text-center py-12 text-gray-400", "暂无搜索结果" }
} else {
for article in items {
ArticleCard { key: "{article.slug}", summary: article.clone() }
}
}
// 分页 + 信息
div { class: "flex items-center justify-between mt-6",
span { class: "text-sm text-gray-500",
"共 {filtered().len()} 篇"
}
if total > 1 {
div { class: "flex gap-2",
for p in 0..*total {
button {
class: "px-3 py-1 rounded border {if *current == p + 1 { \"bg-blue-500 text-white\" } else { \"\" }}",
onclick: move |_| page.set(p),
"{p + 1}"
}
}
}
}
}
}
}
}
key id="8--关于-属性的深入理解">8. 关于 属性的深入理解
Dioxus 的 diff 算法依靠 key 来判断列表元素是新增、删除还是移动:
// 第一次渲染:[A, B, C, D]
// 第二次渲染:[A, C, E, D](B 删除,E 新增)
// 有 key 时,diff 算法能准确匹配:
// A → A(复用)
// B →(删除)
// C → C(复用)
// D → D(复用)
//(新增)→ E
// 性能更好,且保持了元素的状态和滚动位置
9. 小结
for循环是 Dioxus 列表渲染的基础语法- 始终使用稳定的
key属性,避免使用数组索引 use_memo派生过滤/排序/分页结果,无需额外状态同步- 数据量大时采用分页或无限滚动
- 超大数据集(万级以上)需要使用虚拟滚动
- 按实际数据量选择合适的技术,不过度优化
dioxuslistvirtual-scrollperformancerendering