一篇偏教程向的
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 调用对象 |
流程大概是这样:
#[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> {
// ...
}
生成到前端时,AppHandle、State<_> 这些 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.rs 或 bindings.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![]里注册的事件必须 derivetauri_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. u64 和 bigint
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
Uuid、chrono 这种类型,specta 有对应 feature 支持,问题不大。更麻烦的是某些第三方类型,比如 P2P 里的 PeerId、Multiaddr。
做法通常是: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