第二十章:Error Boundaries 与错误处理

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

第二十章:Error Boundaries 与错误处理

1. 为什么需要 Error Boundaries

在运行时,组件可能因为各种原因崩溃:

  • API 返回了意料之外的数据结构
  • 某个深层嵌套字段为 null
  • 第三方库抛出异常
  • WASM 内存访问错误

没有 Error Boundary 时:一个叶子组件崩溃 → 整个 VNode 树渲染失败 → 页面白屏。

有 Error Boundary 时:崩溃被捕获 → 仅该区域的 UI 降级为错误提示 → 其余功能正常运行。

2. Dioxus 中的错误处理机制

2.1 Result 与 Element 类型

Dioxus 的 Element 实际上是 Result<VNode, RenderError>

// Element 的定义
type Element = Result<VNode, RenderError>;

// 所以可以直接使用 ? 操作符
fn SafeComponent() -> Element {
    let data = fetch_data().map_err(|e| RenderError::Abort(e.to_string()))?;
    Ok(rsx! { "{data}" })
}

2.2 ? 操作符在组件中的使用

#[derive(Debug)]
enum AppError {
    Network(String),
    NotFound,
    Parse(String),
}

impl From<AppError> for RenderError {
    fn from(e: AppError) -> Self {
        RenderError::Abort(e.to_string())
    }
}

#[component]
fn ArticlePage(slug: String) -> Element {
    // 使用 ? 简洁处理错误
    let article = fetch_article(&slug)
        .await
        .map_err(|e| RenderError::Abort(e.to_string()))?;

    // 或者直接使用自定义错误类型
    // let article = fetch_article(&slug)?;

    Ok(rsx! {
        article {
            h1 { "{article.title}" }
            div { dangerous_inner_html: "{article.html}" }
        }
    })
}

3. 实现 Error Boundary

3.1 组件级错误边界

#[component]
fn ErrorBoundary(
    fallback: Option<Element>,
    children: Element,
) -> Element {
    let has_error = use_signal(|| false);
    let error_msg = use_signal(|| String::new());

    // 这里使用 try-catch 风格的渲染
    // 在 Dioxus 中通过 error_guard 实现
    rsx! {
        if has_error() {
            // 显示降级 UI
            fallback.clone().unwrap_or_else(|| rsx! {
                div { class: "p-6 text-center",
                    div { class: "text-4xl mb-2", "😵" }
                    h3 { class: "font-semibold mb-1", "出错了" }
                    p { class: "text-sm", style: "color: var(--tertiary);",
                        "{error_msg}"
                    }
                    button {
                        class: "mt-3 px-4 py-2 rounded-lg text-sm",
                        style: "background: var(--primary); color: white;",
                        onclick: move |_| {
                            has_error.set(false);
                            error_msg.set(String::new());
                        },
                        "重试"
                    }
                }
            })
        } else {
            {
                children
            }
        }
    }
}

// 使用
#[component]
fn UserProfile(user_id: String) -> Element {
    rsx! {
        ErrorBoundary {
            fallback: rsx! { div { "用户信息加载失败" } },
            UserInfo { user_id: user_id.clone() }
        }
    }
}

3.2 封装错误边界 Hook

fn use_error_boundary() -> (Signal<bool>, Signal<String>, impl Fn(), impl Fn()) {
    let has_error = use_signal(|| false);
    let error_msg = use_signal(|| String::new());

    let catch_error = {
        move |e: String| {
            console_log!("[ErrorBoundary] {e}");
            has_error.set(true);
            error_msg.set(e);
        }
    };

    let reset = move || {
        has_error.set(false);
        error_msg.set(String::new());
    };

    (has_error, error_msg, catch_error, reset)
}

#[component]
fn SafeSection(children: Element) -> Element {
    let (has_error, error_msg, catch, reset) = use_error_boundary();

    // 在子组件渲染时捕获 panic
    let rendered = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
        // 但实际 Dioxus 渲染无法直接 catch_unwind
        // 需要结合自定义组件实现
        children
    }));

    rsx! {
        if has_error() {
            ErrorFallback {
                message: error_msg(),
                on_retry: move |_| reset(),
            }
        } else {
            {children}
        }
    }
}

4. 数据获取错误处理

4.1 统一的 API 错误处理

#[derive(Clone, PartialEq)]
enum DataState<T> {
    Loading,
    Success(T),
    Error(DataError),
}

#[derive(Clone, PartialEq)]
struct DataError {
    code: u16,
    message: String,
    retryable: bool,
}

fn use_api_data<T>(url: String) -> Signal<DataState<T>>
where
    T: Clone + PartialEq + serde::de::DeserializeOwned + 'static,
{
    let state = use_signal(|| DataState::<T>::Loading);

    use_effect(move || {
        state.set(DataState::Loading);
        spawn(async move {
            match reqwest::get(&url).await {
                Ok(resp) => {
                    let status = resp.status();
                    if status.is_success() {
                        match resp.json::<T>().await {
                            Ok(data) => state.set(DataState::Success(data)),
                            Err(e) => state.set(DataState::Error(DataError {
                                code: 0,
                                message: format!("数据解析失败: {e}"),
                                retryable: false,
                            })),
                        }
                    } else {
                        state.set(DataState::Error(DataError {
                            code: status.as_u16(),
                            message: format!("请求失败 ({status})"),
                            retryable: status.is_server_error(),
                        }));
                    }
                }
                Err(e) => state.set(DataState::Error(DataError {
                    code: 0,
                    message: format!("网络错误: {e}"),
                    retryable: true,
                })),
            }
        });
    });

    state
}

4.2 重试机制

#[component]
fn RetryableFetch() -> Element {
    let slug = use_signal(|| "".to_string());
    let retry_count = use_signal(|| 0u32);
    let max_retries = 3;

    let article = use_resource(move || async move {
        let s = slug();
        let mut last_error = None;

        for attempt in 0..max_retries {
            match fetch_article(&s).await {
                Ok(data) => return Ok(data),
                Err(e) => {
                    console_log!("重试 {}/{}: {e}", attempt + 1, max_retries);
                    last_error = Some(e);
                    if attempt < max_retries - 1 {
                        tokio::time::sleep(
                            std::time::Duration::from_millis(1000 * (attempt + 1))
                        ).await;
                    }
                }
            }
        }
        Err(last_error.unwrap_or_else(|| "重试耗尽".to_string()))
    });

    match article() {
        Some(Ok(data)) => rsx! { render_article(&data) },
        Some(Err(msg)) => rsx! {
            div { class: "text-center py-8",
                p { "加载失败: {msg}" }
                button {
                    onclick: move |_| article.restart(),
                    "重新加载"
                }
            }
        },
        None => rsx! { "加载中..." },
    }
}

5. 全局异常处理

5.1 WASM Panic 处理

// 在 main.rs 中设置全局 panic hook
fn main() {
    // 捕获 WASM 中的 panic,防止白屏
    std::panic::set_hook(Box::new(|info| {
        let message = info.to_string();
        console_log!("[PANIC] {message}");

        // 显示错误到页面
        if let Some(window) = web_sys::window() {
            let doc = window.document().unwrap();
            if let Some(root) = doc.get_element_by_id("main") {
                root.set_inner_html(&format!(
                    "<div style='padding:2rem;text-align:center;color:#ef4444;'>
                        <h2>应用发生错误</h2>
                        <pre style='font-size:12px;margin-top:1rem;'>{}</pre>
                    </div>",
                    message
                ));
            }
        }
    }));

    dioxus::launch(App);
}

5.2 未捕获的 Promise 错误

// index.html 中
window.addEventListener('unhandledrejection', function(event) {
    console.error('未捕获的 Promise 错误:', event.reason);
    // 可以展示全局错误提示
});

6. 降级 UI 策略

6.1 组件级降级

#[component]
function SafeRender<T>(
    data: Option<T>,
    loading: Element,
    error: Element,
    children: impl Fn(T) -> Element,
) -> Element {
    match data {
        Some(val) => children(val),
        None => loading,
    }
}

// 使用
SafeRender {
    data: article(),
    loading: rsx! { Skeleton {} },
    error: rsx! { div { "加载失败" } },
    children: move |data: ArticleDto| rsx! {
        h1 { "{data.title}" }
        div { dangerous_inner_html: "{data.html}" }
    },
}

6.2 布局级降级

#[component]
fn ThreeColumnLayout() -> Element {
    rsx! {
        div { class: "lg:grid lg:grid-cols-[220px_1fr_240px] lg:gap-8",
            // 左侧栏崩溃不影响主内容
            ErrorBoundary {
                fallback: rsx! { aside { class: "hidden lg:block", "侧边栏异常" } },
                LeftSidebar {}
            }
            // 主内容区
            main {
                ErrorBoundary {
                    fallback: rsx! { div { "内容加载失败" } },
                    Outlet::<Route> {}
                }
            }
            // 右侧栏
            ErrorBoundary {
                fallback: rsx! { aside { class: "hidden lg:block", "" } },
                RightSidebar {}
            }
        }
    }
}

7. 实战:完整的错误处理框架

// error.rs
use dioxus::prelude::*;

#[derive(Clone, PartialEq)]
pub enum AppError {
    NotFound(String),
    Network(String),
    AuthRequired,
    ServerError(u16, String),
    ParseError(String),
    Unknown(String),
}

impl AppError {
    pub fn message(&self) -> String {
        match self {
            AppError::NotFound(resource) => format!("{resource} 未找到"),
            AppError::Network(msg) => format!("网络错误: {msg}"),
            AppError::AuthRequired => "请先登录".to_string(),
            AppError::ServerError(code, msg) => format!("服务器错误 ({code}): {msg}"),
            AppError::ParseError(msg) => format!("数据解析错误: {msg}"),
            AppError::Unknown(msg) => msg.clone(),
        }
    }

    pub fn is_retryable(&self) -> bool {
        matches!(self, AppError::Network(_) | AppError::ServerError(_, _))
    }

    pub fn status_code(&self) -> u16 {
        match self {
            AppError::NotFound(_) => 404,
            AppError::AuthRequired => 401,
            _ => 500,
        }
    }
}

// 错误展示组件
#[component]
pub fn ErrorDisplay(error: AppError, on_retry: Option<EventHandler>) -> Element {
    let icon = match &error {
        AppError::NotFound(_) => "🔍",
        AppError::Network(_) => "🌐",
        AppError::AuthRequired => "🔒",
        AppError::ServerError(_, _) => "🔧",
        _ => "❌",
    };

    rsx! {
        div { class: "flex flex-col items-center justify-center py-16 px-4",
            div { class: "text-5xl mb-4", "{icon}" }
            h2 { class: "text-lg font-semibold mb-2",
                style: "color: var(--text);",
                "{error.message()}"
            }
            if error.is_retryable() {
                if let Some(retry) = on_retry {
                    button {
                        class: "mt-4 px-6 py-2 rounded-lg text-sm font-medium transition-all
                                hover:opacity-90",
                        style: "background: var(--primary); color: white;",
                        onclick: move |_| retry.call(()),
                        "重试"
                    }
                }
            } else {
                a {
                    class: "mt-4 px-6 py-2 rounded-lg text-sm",
                    style: "background: var(--hover); color: var(--secondary);",
                    href: "/",
                    "返回首页"
                }
            }
        }
    }
}

8. 错误日志与上报

// 错误上报服务
struct ErrorReporter;

impl ErrorReporter {
    fn report(error: &AppError, context: &str) {
        let msg = format!(
            "[{}] {} | context: {} | url: {}",
            chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
            error.message(),
            context,
            web_sys::window()
                .and_then(|w| w.location().href().ok())
                .unwrap_or_default(),
        );

        console_log!("{msg}");

        // 生产环境中上报到服务器
        // spawn(async move {
        //     reqwest::Client::new()
        //         .post("/api/logs")
        //         .json(&LogEntry { message: msg })
        //         .send().await;
        // });
    }
}

// 在 ErrorBoundary 中使用
fn catch_error(error: AppError, component_name: &str) {
    ErrorReporter::report(&error, component_name);
}

9. 错误处理决策树

组件渲染失败
├── 是数据问题?
│   ├── 是 → 显示降级 UI + 重试按钮
│   └── 否 → 检查代码逻辑
├── 是否可重试?
│   ├── 是 → 自动重试(指数退避)
│   └── 否 → 提示用户操作
├── 是否影响全局?
│   ├── 是 → 显示全局错误页
│   └── 否 → 局部降级处理
└── 是否记录日志?
    ├── 是 → 上报到服务器
    └── 否 → console 输出

10. 小结

  • Error Boundary 防止单组件崩溃导致整页白屏
  • Element = Result<VNode, RenderError>,组件可以使用 ? 传播错误
  • 数据获取使用 DataState<T> 统一管理 Loading/Success/Error 状态
  • 区分可重试错误(网络、5xx)和不可重试错误(数据格式错、404)
  • 全局 panic hook 捕获未预期的崩溃,展示友好提示
  • 错误日志上报到服务端,便于排查生产问题
  • 降级 UI 让应用在部分功能不可用时仍能使用
dioxuserrorboundarycrashfallbackresilience