第十五章:异步编程与数据获取

博客v1.0系列教程(Dioxus)博客 v1.0 系列教程 (Dioxus)

第十五章:异步编程与数据获取

1. Dioxus 异步基础

Dioxus 应用运行在浏览器(WASM)或服务端(Tokio)上,两种环境都支持 async/await。异步编程在前端开发中无处不在:API 请求、定时器、文件读取、WebSocket 通信。

1.1 三种异步模式

// 模式一:use_resource —— 声明式数据获取(推荐)
#[component]
fn ArticlePage(slug: String) -> Element {
    let article = use_resource(move || async move {
        reqwest::get(&format!("/api/articles/{slug}"))
            .await.ok()?
            .json::<ArticleDto>().await.ok()
    });

    match article() {
        Some(data) => render_article(&data),
        None => rsx! { "加载中..." },
    }
}

// 模式二:spawn —— 命令式异步任务
#[component]
fn ActionButton() -> Element {
    let mut loading = use_signal(|| false);

    rsx! {
        button {
            disabled: loading(),
            onclick: move |_| {
                loading.set(true);
                spawn(async move {
                    do_something().await;
                    loading.set(false);
                });
            },
            "执行"
        }
    }
}

// 模式三:use_effect —— 副作用
#[component]
fn Logger() -> Element {
    use_effect(move || {
        // 在组件挂载或依赖变化时执行
        spawn(async move {
            log_visit().await;
        });
    });
    // ...
}

2. use_resource 深入

2.1 基本用法

#[component]
fn ArticleList() -> Element {
    // use_resource 接收一个 async 闭包
    // 闭包中读取的 Signal 被追踪,变化时自动重新执行
    let articles = use_resource(move || async move {
        fetch_articles().await
    });

    match articles() {
        Some(Ok(list)) => rsx! {
            for article in list {
                ArticleCard { summary: article }
            }
        },
        Some(Err(e)) => rsx! {
            div { class: "text-red-500", "加载失败: {e}" }
        },
        None => rsx! {
            div { class: "text-center py-8", "加载中..." }
        },
    }
}

2.2 依赖追踪

当闭包中读取了 Signal,Signal 变化时会自动重新执行:

#[component]
fn FilteredList(category: Signal<String>, search: Signal<String>) -> Element {
    let items = use_resource(move || async move {
        // 读取 Signal 值——这些依赖被追踪
        let cat = category();
        let q = search();

        let url = format!("/api/articles?category={cat}&search={q}");
        reqwest::get(&url).await.ok()?.json().await.ok()
    });

    // 当 category 或 search 变化时,items 自动重新加载
    match items() {
        Some(data) => render_list(data),
        None => rsx! { "加载中..." },
    }
}

2.3 手动刷新

let mut articles = use_resource(move || async move {
    fetch_articles().await
});

// 调用 restart() 手动触发重新加载
rsx! {
    button {
        onclick: move |_| articles.restart(),
        "刷新"
    }
}

2.4 初始值

let articles = use_resource_with_initial(
    move || async move { fetch_articles().await },
    vec![article1, article2], // 初始值,立即显示
);

3. spawn 命令式并发

3.1 基础 spawn

use dioxus::prelude::*;

#[component]
fn AsyncAction() -> Element {
    let mut status = use_signal(|| "idle".to_string());

    rsx! {
        button {
            onclick: move |_| {
                status.set("processing".to_string());
                // spawn 启动一个异步任务
                spawn(async move {
                    // 模拟耗时操作
                    tokio::time::sleep(std::time::Duration::from_secs(2)).await;
                    status.set("done".to_string());
                });
            },
            "{status}"
        }
    }
}

3.2 竞态条件处理

多次快速点击按钮可能导致多个异步任务同时运行,后完成的任务覆盖先完成的结果:

#[component]
fn SearchBox() -> Element {
    let mut query = use_signal(String::new);
    let mut results = use_signal(|| Vec::<SearchResult>::new());

    // 使用 use_resource 替代手动 spawn,自动取消旧请求
    let search_results = use_resource(move || async move {
        let q = query();
        if q.len() < 2 { return Vec::new(); }

        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
        fetch_search(&q).await.unwrap_or_default()
    });

    // 或者使用 spawn 手动处理竞态
    let mut version = use_signal(|| 0u64);

    let do_search = move |q: String| {
        version += 1;
        let my_version = version();

        spawn(async move {
            let result = fetch_search(&q).await;

            // 只在该版本仍是最新时更新
            if version() == my_version {
                results.set(result.unwrap_or_default());
            }
        });
    };

    rsx! {
        input {
            value: "{query}",
            oninput: move |e| {
                query.set(e.value());
                do_search(e.value());
            },
        }
    }
}

3.3 超时控制

fn fetch_with_timeout(url: &str, timeout_secs: u64) -> Result<Data, String> {
    spawn(async move {
        let result = tokio::time::timeout(
            std::time::Duration::from_secs(timeout_secs),
            reqwest::get(url),
        ).await;

        match result {
            Ok(Ok(resp)) => Ok(resp.json().await.unwrap()),
            Ok(Err(e)) => Err(format!("请求失败: {e}")),
            Err(_) => Err("请求超时".to_string()),
        }
    });
}

4. 错误处理模式

4.1 Result 类型封装

// 统一的 API 返回类型
#[derive(Clone, PartialEq)]
enum AsyncState<T> {
    Idle,
    Loading,
    Success(T),
    Error(String),
}

impl<T> AsyncState<T> {
    fn is_loading(&self) -> bool {
        matches!(self, AsyncState::Loading)
    }
}

fn use_api<T, F>(fetch: F) -> Signal<AsyncState<T>>
where
    F: Fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<T, String>>>> + 'static,
    T: 'static + Clone,
{
    let state = use_signal(|| AsyncState::<T>::Idle);

    use_effect(move || {
        state.set(AsyncState::Loading);
        spawn(async move {
            match fetch().await {
                Ok(data) => state.set(AsyncState::Success(data)),
                Err(e) => state.set(AsyncState::Error(e)),
            }
        });
    });

    state
}

4.2 错误展示组件

#[component]
fn ErrorBoundary(
    state: AsyncState<impl Into<Element>>,
    on_retry: Option<EventHandler>,
) -> Element {
    match state {
        AsyncState::Idle => rsx! { div {} },
        AsyncState::Loading => rsx! {
            div { class: "flex items-center justify-center py-8",
                div { class: "animate-spin w-8 h-8 border-4 rounded-full",
                    style: "border-color: var(--primary); border-top-color: transparent;",
                }
            }
        },
        AsyncState::Success(data) => rsx! { {data.into()} },
        AsyncState::Error(msg) => rsx! {
            div { class: "text-center py-8",
                div { class: "text-red-500 mb-2", "😥 {msg}" }
                if let Some(retry) = on_retry {
                    button {
                        onclick: move |_| retry.call(()),
                        "重试"
                    }
                }
            }
        },
    }
}

5. 数据缓存策略

5.1 简单内存缓存

use std::collections::HashMap;

static CACHE: std::sync::Mutex<Option<HashMap<String, CachedData>>> =
    std::sync::Mutex::new(None);

struct CachedData {
    data: Vec<ArticleSummary>,
    fetched_at: std::time::Instant,
}

fn get_cached(key: &str, ttl_secs: u64) -> Option<Vec<ArticleSummary>> {
    let cache = CACHE.lock().ok()?;
    let entry = cache.as_ref()?.get(key)?;
    if entry.fetched_at.elapsed().as_secs() < ttl_secs {
        Some(entry.data.clone())
    } else {
        None
    }
}

fn set_cache(key: String, data: Vec<ArticleSummary>) {
    if let Ok(mut cache) = CACHE.lock() {
        cache.get_or_insert_with(HashMap::new).insert(key, CachedData {
            data,
            fetched_at: std::time::Instant::now(),
        });
    }
}

5.2 SWR 模式(Stale-While-Revalidate)

先显示缓存数据,再在后台刷新:

#[component]
fn ArticleList() -> Element {
    let articles = use_signal(|| Option::<Vec<ArticleSummary>>::None);

    // 首次加载
    use_effect(move || {
        spawn(async move {
            // 先尝试从缓存读取
            if let Some(cached) = get_cached("articles", 60) {
                articles.set(Some(cached));
            }
            // 再发起网络请求更新
            let fresh = fetch_articles().await;
            articles.set(Some(fresh.clone()));
            set_cache("articles".into(), fresh);
        });
    });

    match articles() {
        Some(list) => rsx! {
            for a in list { ArticleCard { summary: a } }
        },
        None => rsx! { "加载中..." },
    }
}

6. 实战:完整的文章加载组件

#[derive(Clone, PartialEq)]
struct ArticleDetail {
    title: String,
    html: String,
    toc: Vec<TocEntry>,
    prev: Option<ArticleLink>,
    next: Option<ArticleLink>,
}

#[component]
fn ArticleLoader(slug: String) -> Element {
    let article = use_resource(move || async move {
        // 从 API 获取文章数据
        let resp = reqwest::get(&format!("/api/article/{slug}"))
            .await.ok()?;

        if !resp.status().is_success() {
            return Err("文章不存在".to_string());
        }

        let data = resp.json::<ArticleDetail>().await.map_err(|e| e.to_string())?;
        Ok(data)
    });

    match article() {
        Some(Ok(detail)) => rsx! {
            article { class: "prose-content",
                h1 { "{detail.title}" }
                div { dangerous_inner_html: "{detail.html}" }
            }
            // 上下篇导航
            div { class: "flex justify-between mt-8",
                if let Some(ref p) = detail.prev {
                    a { href: "/article/{p.slug}", "← {p.title}" }
                }
                if let Some(ref n) = detail.next {
                    a { href: "/article/{n.slug}", "{n.title} →" }
                }
            }
        },
        Some(Err(msg)) => rsx! {
            div { class: "text-center py-16",
                h2 { "加载失败" }
                p { "{msg}" }
            }
        },
        None => rsx! {
            div { class: "flex justify-center py-16",
                // 骨架屏
                div { class: "w-full max-w-2xl space-y-4",
                    div { class: "h-8 bg-gray-200 rounded animate-pulse w-3/4" }
                    div { class: "h-4 bg-gray-200 rounded animate-pulse w-1/4" }
                    div { class: "h-64 bg-gray-200 rounded animate-pulse" }
                }
            }
        },
    }
}

7. 并发请求

7.1 并行加载

#[component]
fn Dashboard() -> Element {
    let data = use_resource(move || async move {
        // 并行发起多个请求
        let (articles, stats, recent) = tokio::join!(
            fetch_articles(),
            fetch_stats(),
            fetch_recent_comments(),
        );

        DashboardData {
            articles: articles.ok(),
            stats: stats.ok(),
            recent_comments: recent.ok(),
        }
    });

    // ...
}

7.2 串行依赖请求

#[component]
fn UserProfile(user_id: String) -> Element {
    let profile = use_resource(move || async move {
        // 先获取用户信息
        let user = fetch_user(&user_id).await.ok()?;

        // 再根据用户信息获取其文章
        let articles = fetch_user_articles(&user.id).await.ok().unwrap_or_default();

        Some((user, articles))
    });

    match profile() {
        Some((user, articles)) => rsx! { render_profile(user, articles) },
        None => rsx! { "加载中..." },
    }
}

8. 小结

  • use_resource 是声明式数据获取的首选,自动依赖追踪和重新执行
  • spawn 适合命令式异步任务,如表单提交后的副作用
  • 竞态条件通过版本号或 use_resource 的自动取消机制解决
  • 使用统一的 AsyncState<T> 枚举管理加载/成功/错误状态
  • 缓存策略(内存/SWR)减少不必要的网络请求
  • 骨架屏提升加载时的视觉体验
  • tokio::join! 并行发请求,减少等待时间
dioxusasyncfetchuse_resourcespawnloading