在命令里返回错误
本教程共 48 篇 · 第 22 篇 · 更新于 2026-08-09 · 约 8 分钟阅读
本节目标:学完能让命令在出错时返回一个可被前端捕获的错误,而不是让整个程序崩溃,并学会定义自己的错误类型。
命令不是永远成功。读文件可能路径不存在,解析数据可能格式不对。问题在于:错误该往哪送?如果在 Rust 里直接 panic! 或 unwrap(),最坏情况下整个应用会崩。正确做法是用 Rust 的 Result<T, E> 把错误交还给前端,让前端决定怎么提示用户。
为什么不能直接 panic 或 unwrap
在 Rust 里,遇到不可恢复的问题可以 panic!,但那意味着线程甚至进程直接中断。在 Tauri 命令里:
- 同步命令里
panic,会拖垮整个应用; - 异步命令里
panic,虽然不一定立刻崩进程,但前端那个Promise永远不返回,界面就「卡死」在没有回应的状态。
所以准则很明确:命令内部永远不要靠 unwrap()/expect() 掩盖可能的失败。失败应该被表达成 Result 返回出去。
// ❌ 不推荐:文件不存在时应用崩溃
#[tauri::command]
fn read_file_bad() -> String {
std::fs::read_to_string("不存在.txt").unwrap()
}
用 Result<T, E> 返回错误
Tauri 命令的返回值可以是 Result<T, E>:成功时返回 Ok(值),失败时返回 Err(错误信息)。前端那边的 invoke 是个 Promise——Ok 时 resolve,Err 时 reject,于是你能在 catch 里拿到错误。
#[tauri::command]
fn login(user: String, password: String) -> Result<String, String> {
if user == "tauri" && password == "tauri" {
Ok("登录成功".into())
} else {
Err("用户名或密码错误".into())
}
}
import { invoke } from '@tauri-apps/api/core';
invoke('login', { user: 'tauri', password: '123' })
.then((msg) => console.log(msg))
.catch((err) => console.error('出错了:', err));
这条规则对所有进出命令的数据都成立:错误信息本身也必须能被序列化,也就是说 E 这个类型得实现 serde::Serialize,才能变成 JSON 送到前端。
简单方案:用 map_err 转成字符串
Rust 标准库或第三方库的错误类型,绝大多数没实现 serde::Serialize。最省事的办法,是用 map_err 在错误冒泡时把它变成字符串:
use std::fs::File;
use std::io::Read;
#[tauri::command]
fn read_config() -> Result<String, String> {
let mut file = File::open("config.txt").map_err(|e| e.to_string())?;
let mut buf = String::new();
file.read_to_string(&mut buf).map_err(|e| e.to_string())?;
Ok(buf)
}
这里的 ? 把错误往上抛,而 map_err(|e| e.to_string()) 在抛之前先把它转成字符串。前端 catch 里拿到的就是一段可读的英文描述。对于小工具、个人项目,这套写法已经够用。
不过要留意:一旦命令里有多处可能失败,map_err(|e| e.to_string())? 就会重复写好几遍,既不美观也不好统一错误信息格式。它适合「只有一两处出错、先跑通再说」的场景。等逻辑变多,就该升级到下一节的自定义错误类型,把「出错时返回什么」集中管理起来。
Tip字符串错误虽简单,但前端拿到的只是文本,没法做「错误类型 → 对应提示」的结构化判断。当你想让前端区分「网络错误」和「解析错误」时,就该上自定义错误类型了。
进阶方案:自定义错误类型并实现 Serialize
用 thiserror 这个库,可以把一个枚举优雅地变成错误类型,还能自动实现 serde::Serialize。先加依赖:
cd src-tauri
cargo add thiserror
然后定义错误枚举,并手写 Serialize(因为错误信息要送前端,必须可序列化):
use thiserror::Error;
#[derive(Debug, Error)]
enum MyError {
#[error(transparent)]
Io(#[from] std::io::Error),
}
impl serde::Serialize for MyError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
#[tauri::command]
fn read_config() -> Result<String, MyError> {
let text = std::fs::read_to_string("config.txt")?; // ? 自动转成 MyError
Ok(text)
}
#[from] 让 ? 能把 std::io::Error 自动变成 MyError;#[error(transparent)] 表示直接沿用底层错误消息。serialize_str 这里选择把错误转成字符串,但你可以改得更结构化——比如给每种错误分配一个 code 字段,方便前端映射成对应的中文提示。
自定义错误类型最大的好处,是让「所有可能出什么错」一目了然地列在枚举里。别人(以及半年后的你自己)读代码时,不用翻遍函数体就能知道这个命令会抛哪几种错,重构和排查都省事得多。当你有多个命令共享同一套错误分类时,这套投入的回报尤其明显。
Note如果你想要前端的错误对象是
{ kind: 'io', message: '...' }这种带类型的结构,可以在Serialize实现里手动构造一个带kind/message字段的临时值再序列化。这样前端的 TypeScript 就能精确匹配,体验更专业。
前端如何捕获并处理错误
无论错误是字符串还是自定义类型,前端都统一用 try/catch(或 Promise.catch)接住:
import { invoke } from '@tauri-apps/api/core';
type AppError = { kind: 'io' | 'utf8'; message: string };
try {
const text = await invoke<string>('read_config');
console.log('读到了:', text);
} catch (e) {
const err = e as AppError; // 按你约定的结构使用
console.error('读取失败:', err?.message ?? e);
}
关键点:invoke 返回的 Promise,只有 Ok 才会 resolve 出值;一旦 Rust 侧返回 Err,Promise 立刻 reject,错误对象就是 Rust 序列化后的那串数据。
Warning不要把所有失败都
unwrap了事。一个健壮的命令应当把「业务上可预期的失败」(文件不存在、参数非法)用Err返回;只有「应用起不来就没意义」的致命初始化(比如连不上本地数据库)才适合expect。分清这两类,能省下日后大量排查时间。
小结
命令返回错误,核心就是「用 Result<T, E> 表达失败 + 让 E 可序列化 + 前端 catch 接收」。字符串方案快,自定义类型方案专业。下一章我们进入异步命令,看看耗时任务怎么写才不会卡住界面。