错误处理与前端可读的序列化

命令不可能总成功:文件不存在、网络超时、权限不足。Tauri 的错误处理设计决定了「出错时用户看到什么」。本篇给出一套从 Rust 到前端的完整错误规范:自定义错误类型、序列化策略、前端统一拦截与用户友好提示。

一、为什么不能直接用字符串错误

最省事的做法是让命令返回 Result<T, String>,但很快会遇到问题:前端拿到的只是字符串,无法区分错误类型做不同处理;多语言时字符串硬编码在 Rust 里;调用链深处错误层层包装,上下文丢失。推荐做法是自定义错误枚举并序列化:

use serde::Serialize;

#[derive(Debug, thiserror::Error, Serialize)]
enum AppError {
    #[error("文件不存在:{0}")]
    NotFound(String),
    #[error("网络请求失败:{0}")]
    Network(String),
    #[error("权限不足")]
    Forbidden,
}

thiserror 能自动实现 Display,枚举本身带 Serialize 后前端可以拿到结构化错误。具体 API 以 thiserror 与 serde 文档为准。

错误类型 前端能区分类型 可读性 推荐度
String 否 一般 原型阶段
自定义枚举 是 好 生产推荐
anyhow 否 开发友好 仅内部

二、命令返回错误的规范

命令签名统一为 Result<T, AppError>,底层错误用 ? 或 map_err 转换:

#[tauri::command]
async fn read_file(path: String) -> Result<String, AppError> {
    std::fs::read_to_string(&path)
        .map_err(|e| match e.kind() {
            std::io::ErrorKind::NotFound => AppError::NotFound(path),
            std::io::ErrorKind::PermissionDenied => AppError::Forbidden,
            _ => AppError::Network(e.to_string()),
        })
}

对前端而言,rejected 的 Promise 拿到的就是序列化后的枚举值(对象形式,如 { Network: "..." },或按 serde 配置的 tag 形式),可以直接按类型分支处理。命令错误更基础的用法见「前端调用 Rust:invoke 与命令系统」。

三、前端统一拦截

在调用层统一处理错误,避免每个组件各写一遍。以 Vue 项目为例:

import { invoke } from '@tauri-apps/api/core'

export async function safeInvoke(name, args) {
  try {
    return await invoke(name, args)
  } catch (e) {
    const reason = typeof e === 'string' ? e : JSON.stringify(e)
    console.error(`命令 ${name} 失败:`, reason)
    throw new Error(reason)
  }
}

约定好错误形态后,组件里只关心「失败了、是什么失败、提示什么文案」,见下表的处理建议:

错误类型 建议交互
NotFound 提示并提供重新选择文件入口
Network 提示重试按钮
Forbidden 引导到权限设置
未预期错误 显示原始信息加反馈入口

四、事件与后台任务的错误传递

命令的 Result 只能返回一次,而长任务的失败应通过事件通知。统一约定一个失败事件,载荷包含任务 id 与错误体:

use tauri::{AppHandle, Emitter};

async fn sync_task(app: AppHandle) {
    match run_sync().await {
        Ok(_) => { let _ = app.emit("sync-done", ()); }
        Err(e) => { let _ = app.emit("sync-error", e); }
    }
}

前端监听 sync-error 后按同一套错误类型处理,整个应用的错误出口就完全统一了。事件系统的完整模式见「事件系统:Rust 主动通知前端」,后台任务结构见「异步命令与后台任务」。

五、日志与诊断

用户设备上出问题时,日志是唯一线索。推荐用 tracing 或 log crate 配置文件日志,按等级记录命令调用与错误:

use log::{error, info};

#[tauri::command]
async fn read_file(path: String) -> Result<String, AppError> {
    info!("读取文件:{}", path);
    match std::fs::read_to_string(&path) {
        Ok(content) => Ok(content),
        Err(e) => {
            error!("读取失败:{} {}", path, e);
            Err(AppError::NotFound(path))
        }
    }
}

日志文件的落盘路径与权限,配合 Capability 的 fs 作用域声明,涉及「权限模型:Capability 与作用域」。多语言文案管理则见「图标、资源与本地化」。

错误处理样板代码重复度高,适合让 AI 生成初稿,参考 Claude Code 指南;日志里批量检索错误模式离不开正则,见 正则 mastery;Rust 错误处理的语法基础见 Rust 入门。

六、错误监控与持续改进

应用上线后,错误才真正开始考验设计。建议在本地日志之外加一层上传机制:严重错误上报到收集服务,配上应用版本与操作系统信息。如果要自建收集端,最简单的形态是一张表:

字段 说明
version 应用版本
platform 操作系统与架构
error_type 错误枚举名
stack 简化调用栈
user_message 用户看到的话

注意上报频率要限流,避免崩溃循环刷爆服务端。用户隐私也要写进政策明确告知。错误数据的价值在于排优先级:哪些错误影响功能最多、最容易复现,就先修哪些。

错误处理做到最后是三件套:清晰的结构化错误、统一的拦截提示、闭环的监控反馈。三件套齐了,应用的稳定性就有了可度量的改进曲线。

错误处理看似琐碎,实则是产品体验的分水岭。用户不会记得应用正常工作时的流畅,但会记得崩溃时那句话说了什么。把结构化错误与统一拦截做扎实,这一层的体验分就稳稳拿到。

错误处理是产品里最容易被低估的体验设计。用户记得的不是应用有多快,而是出错时应用说了人话、给了出路。做到这一点不需要多高深的技巧,需要的只是把「结构化错误、统一拦截、闭环监控」三点执行到位,本篇给的就是这三点的落地清单。

给大家一个实战训练:翻一遍自己最近写的命令,把字符串错误换成结构化错误,再看前端展示是否更友好。这个小练习能立刻把本篇的价值变成肌肉记忆,也算给应用的用户体验做了一次小体检。

错误处理是少数「越早投入回报越大」的工程实践,因为它会在每个用户、每次故障前替你说话。把本篇的清单贴在团队的共享文档里,每次新增功能扫一眼,久而久之心智模型自然成型,应用的稳定性也随之进入正循环。

延伸阅读

  • 前端调用 Rust:invoke 与命令系统——Result 在 IPC 层的行为
  • 权限模型:Capability 与作用域——日志落盘涉及的目录权限
  • Rust 入门:枚举与错误的语法基础