一行 `#[specta::specta]`,让 Tauri IPC 有类型

一篇偏教程向的 tauri-specta 入门。适合写过 Tauri、前端使用 TypeScript,并且已经开始厌烦手写 invoke("xxx", { ... }) 的人。

这篇是 SwarmNote 跨端架构系列的第二篇。上一篇讲了为什么 SwarmNote 选择"桌面 Tauri + 移动 React Native + 共享 Rust 核心";这一篇先聊桌面端:怎么把 Tauri 的 IPC 变成一套由 Rust 生成的 TypeScript API。

一个小事故

故事从一个很小的改动开始。

你有一个 Tauri command:

rust 复制代码
#[tauri::command]
async fn open_workspace(path: String) -> AppResult<WorkspaceInfo> {
    // ...
}

前端这样调用:

ts 复制代码
await invoke<WorkspaceInfo>("open_workspace", { path });

严格说,这里不是"完全没有类型"。很多 Tauri 项目都会在前端手动维护一份类型:

ts 复制代码
type WorkspaceInfo = {
  id: string;
  name: string;
  path: string;
};

const info = await invoke<WorkspaceInfo>("open_workspace", { path });

这能让 info.name 有补全,也能避免把返回值当成 any。问题在于:这份 WorkspaceInfo 是前端自己写的,invoke<WorkspaceInfo>() 里的泛型也是一次类型断言,它不会反过来检查 Rust command 的真实签名。

后来你发现只传 path 不够了,又把 command 扩成更适合多窗口场景的版本:

rust 复制代码
#[tauri::command]
async fn open_workspace_window(
    path: String,
    bind_to_window: Option<String>,
    close_window: Option<String>,
) -> AppResult<OpenWorkspaceWindowResult> {
    // ...
}

Rust 能编译,前端 tsc 也可能过。因为前端那份类型还停留在旧版本:它只知道你手写的 WorkspaceInfo,不知道 Rust command 已经多了两个参数,也不知道返回值已经换成了 OpenWorkspaceWindowResult

然后某个按钮点下去,运行时才报错:参数少了,字段名不对,或者返回值不是你以为的形状。

这不是 Tauri 的问题。Tauri 原生 invoke 本来就是一个字符串入口:

ts 复制代码
const result = await invoke("open_workspace_window", {
  path,
  bindToWindow: null,
  closeWindow: null,
});

TypeScript 能检查你手写的那份类型,但它不知道这份类型和 Rust 是否仍然一致。也就是说,它不知道:

  • Rust 端有没有这个 command
  • 参数名是不是对的
  • 参数类型是不是对的
  • 返回值是不是你在 invoke<T>() 里写的那个 T
  • event payload 又是什么形状

写 demo 时没问题。command 多到十几个、几十个之后,真正麻烦的不是"前端完全没类型",而是类型事实源分裂:Rust 有一份真实类型,TypeScript 又手写一份影子类型。二者什么时候漂移,只能靠人记得。

tauri-specta 解决的就是这个问题。

tauri-specta 是什么

一句话:

tauri-specta 从 Rust command、event 和 DTO 里收集类型信息,生成前端可以直接 import 的 TypeScript bindings。

它通常和这三个 crate 一起出现:

Crate 作用
specta 给 Rust struct / enum 生成语言无关的类型 schema
specta-typescript 把 schema 导出成 TypeScript
tauri-specta 收集 Tauri commands / events,并生成 commands / events 调用对象

流程大概是这样:

flowchart LR Rust["Rust
#[tauri::command]
#[specta::specta]"] Type["DTO
#[derive(specta::Type)]"] Builder["tauri-specta Builder
collect_commands / collect_events"] TS["src/lib/bindings.ts
commands / events / types"] UI["TypeScript 前端"] Rust --> Builder Type --> Builder Builder --> TS TS --> UI style TS fill:#fff4cc,stroke:#d97706,stroke-width:2px

接入之后,前端不再直接写 invoke

ts 复制代码
import { commands } from "@/lib/bindings";

const info = await commands.getWorkspaceInfo();
await commands.openWorkspaceWindow(path, null, null);

事件也不再直接写字符串:

ts 复制代码
import { events } from "@/lib/bindings";

const unlisten = await events.workspaceReady.listen((event) => {
  console.log(event.payload.path);
});

五分钟接入

下面用 SwarmNote 的真实集成方式讲,但尽量只保留通用 API 和关键取舍。

1. 加依赖

src-tauri/Cargo.toml

toml 复制代码
swarmnote-core = { path = "../crates/core", features = ["specta"] }
entity = { path = "../crates/entity", features = ["specta"] }

specta = { version = "=2.0.0-rc.25", features = ["derive", "uuid", "chrono", "serde_json"] }
specta-typescript = "=0.0.12"
tauri-specta = { version = "=2.0.0-rc.25", features = ["typescript", "derive"] }

两个小建议:

  • specta / tauri-specta 还在 rc 阶段,版本最好固定住。
  • 如果你的核心 crate 还要给其他平台复用,可以像 SwarmNote 一样把 specta 做成 feature,只在 Tauri crate 里打开。

2. 给 command 加 #[specta::specta]

原来:

rust 复制代码
#[tauri::command]
pub async fn get_workspace_info(...) -> AppResult<Option<WorkspaceInfo>> {
    // ...
}

改成:

rust 复制代码
#[tauri::command]
#[specta::specta]
pub async fn get_workspace_info(...) -> AppResult<Option<WorkspaceInfo>> {
    // ...
}

复杂一点的 command 也一样:

rust 复制代码
#[tauri::command]
#[specta::specta]
pub async fn open_workspace_window(
    app: AppHandle,
    path: String,
    bind_to_window: Option<String>,
    close_window: Option<String>,
    core: State<'_, Arc<AppCore>>,
    ws_map: State<'_, WorkspaceMap>,
) -> AppResult<OpenWorkspaceWindowResult> {
    // ...
}

生成到前端时,AppHandleState<_> 这些 Tauri 注入参数会被剔除。前端只需要传业务参数,生成出来的方法大致等价于:

ts 复制代码
openWorkspaceWindow(
  path: string,
  bindToWindow: string | null,
  closeWindow: string | null,
): Promise<OpenWorkspaceWindowResult>

3. 给跨边界类型加 specta::Type

只要一个类型会作为 command 参数、返回值或 event payload 穿过 IPC,就给它加 specta::Type

rust 复制代码
#[derive(Debug, Clone, Serialize, specta::Type)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum OpenWorkspaceWindowResult {
    BoundToCaller { info: WorkspaceInfo },
    FocusedExisting,
    NewWindow,
}

普通 struct 也是一样:

rust 复制代码
#[derive(Debug, Clone, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct SyncProgress {
    pub workspace_uuid: Uuid,
    pub peer_id: String,
    pub completed: u32,
    pub total: u32,
}

这里有一个链式规则:如果 A derive 了 specta::Type,但字段里有 B,那么 B 也要能被 specta 识别。整条类型链路都要打通。

4. 写一个完整的 builder

建议单独建一个 setup.rsbindings.rs,专门放 tauri-specta 的 builder。接入时不要只复制中间几行 .commands(...),完整模板长这样:

rust 复制代码
use tauri::Wry;
use tauri_specta::{
    collect_commands,
    collect_events,
    Builder as SpectaBuilder,
    ErrorHandlingMode,
};

use crate::{commands, events};

pub fn specta_builder() -> SpectaBuilder<Wry> {
    SpectaBuilder::<Wry>::new()
        // 可选:如果你确认 u64 / i64 都在 JS 安全整数范围内,可以转成 number。
        .dangerously_cast_bigints_to_number()
        // 可选:保持和原生 invoke 接近的 try/catch 体验。
        .error_handling(ErrorHandlingMode::Throw)
        .commands(collect_commands![
            commands::identity::get_device_info,
            commands::identity::set_device_name,
            commands::workspace::open_workspace,
            commands::workspace::get_workspace_info,
            commands::workspace::open_workspace_window,
            commands::yjs::open_ydoc,
            commands::yjs::apply_ydoc_update,
        ])
        .events(collect_events![
            events::WorkspaceReady,
            events::ExternalUpdate,
            events::FileTreeChanged,
            events::SyncProgress,
        ])
}

如果你的项目暂时没有 typed events,可以先删掉 collect_events 相关 import 和 .events(...)。但只要用了 events.xxx.listen() 这类生成 API,就要把事件注册保留下来。

这几个点最容易漏:

  • SpectaBuilder<Wry> 里的 Wry 要从 tauri::Wry 引入。
  • collect_commands![] 里注册的 command 必须加过 #[specta::specta]
  • collect_events![] 里注册的事件必须 derive tauri_specta::Event
  • dangerously_cast_bigints_to_number()ErrorHandlingMode::Throw 不是必需项,按项目选择。
  • builder 最好通过 pub use setup::specta_builder; 暴露出来,方便测试里复用。

5. 接入 Tauri Builder

原来的 Tauri 写法通常是:

rust 复制代码
.invoke_handler(tauri::generate_handler![...])

换成:

rust 复制代码
let specta = setup::specta_builder();

tauri::Builder::default()
    .invoke_handler(specta.invoke_handler())
    .setup(move |app| {
        specta.mount_events(app);

        // ... 你原来的 setup 逻辑:
        // app.manage(...)
        // 初始化状态、窗口、托盘、插件等

        Ok(())
    })
    .run(tauri::generate_context!())
    .expect("error while running tauri application");

如果你的 Builder 里还有插件、状态和窗口逻辑,结构通常是这样:

rust 复制代码
pub fn run() {
    let specta = setup::specta_builder();

    tauri::Builder::default()
        .plugin(tauri_plugin_opener::init())
        .invoke_handler(specta.invoke_handler())
        .setup(move |app| {
            // 如果注册了 typed events,这行要在 setup 里调用。
            specta.mount_events(app);

            // app.manage(AppState::new(...));
            // create_main_window(app)?;
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

mount_events(app) 只和 typed events 有关;invoke_handler() 才是替代原来 tauri::generate_handler![] 的部分。二者职责不要混在一起。

6. 导出 bindings.ts

官方示例里常见做法是在开发构建时直接 export:

rust 复制代码
#[cfg(debug_assertions)]
builder
    .export(Typescript::default(), "../src/lib/bindings.ts")
    .expect("Failed to export bindings");

SwarmNote 选择放到一个测试里:

rust 复制代码
use specta_typescript::Typescript;

#[test]
fn export_bindings() {
    let builder = swarmnote_lib::specta_builder();
    builder
        .export(
            Typescript::default().header("// AUTO-GENERATED by tauri-specta. DO NOT EDIT.\n"),
            "../src/lib/bindings.ts",
        )
        .expect("Failed to export specta TypeScript bindings");
}

然后运行:

bash 复制代码
cargo test -p swarmnote --test specta_export -- --nocapture

为什么用测试?因为 bindings.ts 是前端会监听的文件。如果每次 tauri dev 都重写它,Vite 可能反复热更新。放进测试里更适合 CI,也更容易检查生成文件有没有漂移。

7. 前端使用

生成文件大概长这样:

ts 复制代码
export const commands = {
  getWorkspaceInfo: () =>
    __TAURI_INVOKE<WorkspaceInfo | null>("get_workspace_info"),

  openWorkspaceWindow: (
    path: string,
    bindToWindow: string | null,
    closeWindow: string | null,
  ) =>
    __TAURI_INVOKE<OpenWorkspaceWindowResult>("open_workspace_window", {
      path,
      bindToWindow,
      closeWindow,
    }),
};

业务里直接用:

ts 复制代码
import { commands, type WorkspaceInfo } from "@/lib/bindings";

const info: WorkspaceInfo | null = await commands.getWorkspaceInfo();
await commands.openWorkspaceWindow(path, null, null);

从这一步开始,只要 Rust command 改了并重新导出 bindings,前端就会跟着报红。

Typed events

Tauri 的事件也有同样的问题:

rust 复制代码
app.emit("workspace-ready", info)?;
ts 复制代码
listen<WorkspaceInfo>("workspace-ready", (event) => {
  // payload 类型靠手写
});

tauri-specta 可以把事件也纳入生成。

1. 定义 event struct

SwarmNote 的 src-tauri/src/events.rs

rust 复制代码
#[derive(Debug, Clone, Serialize, specta::Type, tauri_specta::Event)]
#[serde(transparent)]
pub struct WorkspaceReady(pub WorkspaceInfo);

#[derive(Debug, Clone, Serialize, specta::Type, tauri_specta::Event)]
#[serde(rename_all = "camelCase")]
pub struct ExternalUpdate {
    pub doc_uuid: Uuid,
    pub update: Vec<u8>,
}

tauri_specta::Event 负责让事件进入 events 生成对象。serde(transparent) 很适合 newtype wrapper,可以让传输时的 payload 仍然保持原来的形状。

2. 注册 events

rust 复制代码
use tauri_specta::{collect_commands, collect_events, Builder as SpectaBuilder};

pub fn specta_builder() -> SpectaBuilder<Wry> {
    SpectaBuilder::<Wry>::new()
        .commands(collect_commands![...])
        .events(collect_events![
            events::WorkspaceReady,
            events::ExternalUpdate,
            events::FileTreeChanged,
            events::SyncProgress,
        ])
}

3. 后端发事件

rust 复制代码
use tauri_specta::Event;

WorkspaceReady(info).emit_to(&app, window_label)?;

或者全局 emit:

rust 复制代码
ExternalUpdate { doc_uuid, update }.emit(&app)?;

4. 前端监听

ts 复制代码
import { events } from "@/lib/bindings";

const unlisten = await events.workspaceReady.listen((event) => {
  event.payload.path; // typed
});

const unlisten2 = await events.externalUpdate.listen((event) => {
  event.payload.docUuid;
  event.payload.update;
});

事件名 "workspace-ready""external-update" 仍然存在,但被封装在生成的 bindings 里。业务代码不再直接碰字符串。

实战技巧

1. u64bigint

Specta 默认会把 Rust 64 位整数映射成 TS bigint。如果这些值会参与普通前端计算,用起来会不太顺手:

ts 复制代码
const n: bigint = value;
const next = n + 1; // 类型错误

如果你确认这些值都在 JS 安全整数范围内,可以打开:

rust 复制代码
SpectaBuilder::<Wry>::new()
    .dangerously_cast_bigints_to_number()

SwarmNote 这么做,是因为 IPC 里的 u64 / i64 / usize 主要是文档大小、进度、时间戳等,项目约束它们在安全范围内。

2. ErrorHandlingMode::Throw

SwarmNote 使用:

rust 复制代码
.error_handling(ErrorHandlingMode::Throw)

这样生成的 command 和原生 invoke 一样,失败时直接 throw:

ts 复制代码
try {
  await commands.openWorkspaceWindow(path, null, null);
} catch (e) {
  console.error(e);
}

如果你更喜欢 Rust-style 的返回值,也可以用 ErrorHandlingMode::Result。老项目迁移通常选 Throw,改动最小。

3. 自定义错误类型要对齐

SwarmNote 的 AppError 自己实现了 Serialize,前端实际收到的是:

json 复制代码
{ "kind": "InvalidPath", "message": "invalid path: ..." }

specta 看不到你手写的 Serialize 逻辑,所以 SwarmNote 手写了一个类型投影:

rust 复制代码
#[cfg(feature = "specta")]
#[derive(specta::Type)]
#[specta(inline)]
struct AppErrorPayload {
    kind: String,
    message: String,
}

#[cfg(feature = "specta")]
impl specta::Type for AppError {
    fn definition(types: &mut specta::Types) -> specta::datatype::DataType {
        <AppErrorPayload as specta::Type>::definition(types)
    }
}

原则很简单:运行时传输的形状是什么,生成出来的 TS 类型也应该是什么。

4. 第三方类型不友好时,边界用 String

Uuidchrono 这种类型,specta 有对应 feature 支持,问题不大。更麻烦的是某些第三方类型,比如 P2P 里的 PeerIdMultiaddr

做法通常是:IPC 边界用 String,进入 Rust 后再 parse。

rust 复制代码
#[tauri::command]
#[specta::specta]
pub async fn trigger_workspace_sync(
    workspace_uuid: Uuid,
    peer_id: String,
    core: State<'_, Arc<AppCore>>,
) -> AppResult<()> {
    let peer_id = peer_id.parse()?;
    // ...
}

不要为了让所有内部类型都穿过 IPC,把前端 API 搞得很难用。边界类型应该稳定、简单、可序列化。

适合谁

我觉得绝大多数 Tauri + TypeScript 项目都适合接入 tauri-specta。尤其是:

  • command 超过 3 个
  • 有复杂返回值
  • 前后端都在快速迭代
  • emit / listen 事件
  • 想让 Rust 成为类型事实源

不太需要的情况也有:你的 Tauri 只是一个非常薄的壳,只有一两个 invoke,项目也不会继续扩展。除此之外,接入成本通常是值得的。

小结

回头看,tauri-specta 不是一个很重的框架,它只是把 Tauri IPC 的几个环节串起来:

你要做的事 用到的 API
标记一个 Tauri command 需要生成类型 #[specta::specta]
让参数、返回值、事件 payload 能被导出 #[derive(specta::Type)]
收集所有可调用 command collect_commands![...]
收集所有 typed event collect_events![...]
替换 Tauri 原来的 generate_handler! builder.invoke_handler()
生成前端 bindings.ts builder.export(...)

所以它的核心价值不是"多了一套调用方式",而是把 Rust 已经知道的 command 签名、DTO 结构和事件 payload,变成 TypeScript 也能消费的同一份契约。

下一篇会接着这个故事讲移动端:桌面端的 Rust / TypeScript 类型漂移问题解决了,React Native 调 Rust 又该怎么办?那里就轮到 uniffi-bindgen-react-native 出场。

关键文件

  • SwarmNote builder: src-tauri/src/setup.rs
  • command 示例: src-tauri/src/commands/workspace.rs
  • typed events: src-tauri/src/events.rs
  • export 测试: src-tauri/tests/specta_export.rs
  • 生成产物: src/lib/bindings.ts

延伸阅读

相关推荐
lichenyang4534 小时前
HarmonyOS HMRouter 接入记录:从普通 Tab Demo 到路由跳转
前端
木斯佳4 小时前
前端八股文面经大全:腾讯WXG暑期前端一面(2026-05-15)·面经深度解析
前端·面试·笔试
canonical_entropy5 小时前
NOP Chaos Flux 架构演变史:从 AMIS 重写到现代低代码运行时
前端·aigc·ai编程
张元清5 小时前
useEffect 之外:专门处理异步、深比较和 SSR 的 Effect Hook
前端·javascript·面试
小小小小宇5 小时前
前端双Token机制无感刷新(二)
前端
XinZong6 小时前
OpenClaw 中最经典的 6 款skill,真正能进工作流的 skills
javascript·后端
zhangxingchao6 小时前
AI Agent 基础问题系统整理:从 LangChain、LangGraph、MCP 到 Agent 架构、记忆、工具调用与评估体系
前端·人工智能·后端
Moment6 小时前
AI 为什么总喜欢写防御性代码?
前端·后端·面试
浑手营销6 小时前
浑手科技案例分享:133个精准询盘短视频玩法
前端·人工智能·科技