首页 / Tauri 2 入门教程 / 在命令里返回错误

Tauri 2 入门教程

在命令里返回错误

本教程共 48 篇 · 第 22 篇 · 更新于 2026-08-09 · 约 8 分钟阅读

TauricommandResultthiserror错误处理

本节目标:学完能让命令在出错时返回一个可被前端捕获的错误,而不是让整个程序崩溃,并学会定义自己的错误类型。

命令不是永远成功。读文件可能路径不存在,解析数据可能格式不对。问题在于:错误该往哪送?如果在 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——OkresolveErrreject,于是你能在 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 接收」。字符串方案快,自定义类型方案专业。下一章我们进入异步命令,看看耗时任务怎么写才不会卡住界面。