第十三章:列表渲染与虚拟滚动优化

博客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