第二十章: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