Tauri 学习笔记
一份完整的 Tauri 学习笔记。重点讲"为什么这么设计"(费曼式 📌 讲解),覆盖从入门到打包发布的全链路知识。
怎么用这份笔记
- 学习/复习 → 顺序看正文,重点看 📌 类比和"为什么"
- 查配置 → 翻 附录 B 配置速查
- 排错 → 翻 附录 C 常见问题 FAQ
- 选型 → 翻 附录 D 与 Electron 对比
1 · Tauri 概述与架构
1.1 Tauri 是什么
- Tauri 是一个用 Rust 构建的跨平台桌面/移动应用框架,让前端开发者用熟悉的 Web 技术(HTML/CSS/JS)写 UI,用 Rust 写后端逻辑
- 核心理念:不捆绑浏览器引擎,复用系统自带的 WebView,因此安装包极小(通常 3-10 MB vs Electron 的 80+ MB)
- 支持平台:Windows、macOS、Linux(桌面);Android、iOS(Tauri 2.0+ 移动端)
- 开源协议:Apache 2.0 / MIT 双协议
TIP
📌 打个比方:Electron 像买房时自带全套家具(捆绑 Chromium + Node.js),拎包入住但房子重。Tauri 像毛坯房配系统已有家具(系统 WebView),轻量但你得确认每套房子(每个系统)的家具风格一致。这就是为什么 Tauri 包小但需要处理 WebView 差异。
1.2 整体架构

每层说明
| 层 |
作用 |
技术栈 |
| 前端层 |
UI 渲染与用户交互 |
任意前端框架(React、Vue、Svelte…)或纯 HTML |
| IPC 通信层 |
前后端消息传递 |
invoke() 调用 Command,emit/listen 收发事件 |
| Rust 后端 |
系统级操作、业务逻辑 |
Rust + tauri crate |
| 系统 WebView |
渲染前端页面 |
Windows: WebView2 · macOS: WKWebView · Linux: WebKitGTK |
TIP
📌 为什么 Tauri 用系统 WebView 而不捆绑 Chromium?这是 Tauri 和 Electron 最根本的设计分歧。Electron 捆绑 Chromium 换来了"所有平台渲染一致"的确定性,代价是每个应用都带着 100+ MB 的浏览器。Tauri 选择复用系统 WebView——包小了,但要面对不同平台 WebView 的差异(如 Linux 上 WebKitGTK 对某些 CSS 特性的支持可能落后于 Windows 的 WebView2)。这是经典的"体积 vs 一致性"权衡。
1.3 Tauri vs Electron 核心对比
| 维度 |
Tauri |
Electron |
| 后端语言 |
Rust |
Node.js (JavaScript) |
| 浏览器引擎 |
系统 WebView |
捆绑 Chromium |
| 安装包大小 |
3-10 MB |
80-150 MB |
| 内存占用 |
较低 |
较高 |
| 安全模型 |
默认最小权限 + allowlist |
默认全开(需手动收紧) |
| 跨平台一致性 |
受系统 WebView 影响 |
Chromium 保证一致 |
| 学习曲线 |
需要基础 Rust |
纯 JS 生态 |
| 移动端支持 |
Tauri 2.0+ 支持 |
不支持 |
TIP
📌 怎么选?需要极致渲染一致性、团队全是 JS 开发者、不在意包体积 → Electron。追求轻量、安全、性能、需要调用系统能力、愿意学 Rust → Tauri。
1.4 版本演进
| 版本 |
关键特性 |
| 1.0 (2022) |
稳定桌面端 API,插件系统,allowlist 安全模型 |
| 2.0 (2024) |
移动端支持(Android/iOS),全新权限系统(Capabilities),插件 API 统一 |
本笔记以 Tauri 2.x 为主线,兼顾 1.x 差异说明。
2 · 环境准备与项目创建
2.1 前置依赖
Windows
# 1. 安装 Microsoft C++ Build Tools(必须)
# 下载: https://visualstudio.microsoft.com/visual-cpp-build-tools/
# 勾选"使用 C++ 的桌面开发"工作负载
# 2. 安装 WebView2(Win 11 自带,Win 10 可能需手动装)
# 下载: https://developer.microsoft.com/microsoft-edge/webview2/
# 3. 安装 Rust
winget install Rustlang.Rustup
# 或访问 https://rustup.rs 下载 rustup-init.exe
# 4. 验证
rustc --version
cargo --version
macOS
xcode-select --install
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustc --version
Linux
# Ubuntu/Debian
sudo apt update
sudo apt install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustc --version
TIP
📌 为什么 Linux 需要装这么多系统库?因为 Tauri 依赖 WebKitGTK(Linux 的 WebView),而 WebKitGTK 又依赖 GTK、SSL、X11 等系统库。这些是编译时链接的,不是运行时下载的。Windows 和 macOS 的 WebView 是系统预装的,所以依赖更少。
📌 为什么需要 C++ Build Tools(Windows)?Rust 编译器在 Windows 上使用 MSVC 工具链链接目标文件,需要 Microsoft 的链接器(link.exe)。没有它,cargo build 会报 "linker 'link.exe' not found"。
2.2 创建项目
# 方式一:使用 create-tauri-app(推荐)
npm create tauri-app@latest
# 按提示选择项目名称、前端语言、前端框架、包管理器
# 方式二:在已有前端项目中集成
cd my-existing-frontend
npm install -D @tauri-apps/cli
npx tauri init
2.3 项目目录结构
my-tauri-app/
├── src/ # 前端源码(React/Vue/...)
├── src-tauri/ # Rust 后端源码
│ ├── src/
│ │ ├── main.rs # 程序入口
│ │ └── lib.rs # 库入口(Tauri 2.0 结构)
│ ├── Cargo.toml # Rust 依赖配置
│ ├── tauri.conf.json # Tauri 核心配置文件
│ ├── capabilities/ # 权限配置(Tauri 2.0)
│ ├── icons/ # 应用图标
│ └── build.rs # 构建脚本
├── public/ # 前端静态资源
├── package.json # 前端依赖配置
└── vite.config.ts # Vite 配置
TIP
📌 为什么前端和后端分开放(src/ vs src-tauri/)?这是 Tauri 的核心设计哲学——前端和后端是完全独立的两个世界。前端用你熟悉的工具链构建,产出 HTML/CSS/JS;Tauri 在打包时把前端产物嵌入到 Rust 二进制中。开发时前端跑 dev server,Tauri 的 WebView 加载 dev server 的 URL;打包时 Tauri 加载前端 build 产物的本地文件。这种分离让你可以随时替换前端框架而不动后端。
2.4 启动开发
npm run tauri dev # 启动开发模式(前端 dev server + Tauri 窗口)
npm run tauri build # 构建生产包
3 · 项目结构详解
3.1 tauri.conf.json — 核心配置
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "MyApp",
"version": "1.0.0",
"identifier": "com.mycompany.myapp",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"title": "My App",
"width": 800,
"height": 600,
"resizable": true
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data: https:"
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/icon.ico"]
}
}
| 字段 |
作用 |
注意事项 |
productName |
应用显示名称 |
出现在窗口标题、安装包名中 |
identifier |
应用唯一标识符 |
必须用反向域名格式,如 com.company.app,影响 macOS Bundle ID |
build.frontendDist |
前端构建产物路径 |
打包时从这里读取前端文件 |
build.devUrl |
开发时前端 dev server 地址 |
必须与 Vite 端口一致 |
app.windows |
窗口配置数组 |
可配置多个窗口 |
app.security.csp |
内容安全策略 |
限制 WebView 能加载什么资源 |
bundle.targets |
打包目标格式 |
all / msi / nsis / dmg / deb 等 |
TIP
📌 identifier 为什么必须用反向域名?这是操作系统层面的约定。macOS 用它做 Bundle ID,Windows 用它做 AppID(注册到开始菜单),Linux 桌面用它做 .desktop 文件名。如果两个应用 identifier 相同,安装时可能互相覆盖。一旦发布就不要改 identifier,否则系统会认为是两个不同的应用。
3.2 Cargo.toml — Rust 依赖
[package]
name = "my-tauri-app"
version = "1.0.0"
edition = "2021"
[lib]
name = "my_tauri_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
3.3 main.rs / lib.rs — 入口结构
// src-tauri/src/lib.rs
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
// src-tauri/src/main.rs
fn main() {
my_tauri_app_lib::run()
}
TIP
📌 为什么 Tauri 2.0 拆成 lib.rs + main.rs?因为移动端(Android/iOS)的入口不是 main() 函数,而是由移动端框架通过 JNI/原生桥调用。把核心逻辑放在 lib.rs 的 run() 函数里,main.rs 只是桌面端的薄壳,移动端入口通过 #[cfg_attr(mobile, tauri::mobile_entry_point)] 宏注入。这样一份代码同时支持桌面和移动。
4 · 核心概念
4.1 前后端通信模型

Tauri 的前后端通信是基于消息的 IPC,不是共享内存或直接函数调用:
前端 (WebView) 后端 (Rust)
│ │
│ invoke("cmd_name", args) │
│ ──────────────────────────────→ │ #[tauri::command] fn cmd_name()
│ │ 执行 Rust 逻辑
│ ←────────────────────────────── │ return Result
│ Promise<result> │
│ │
│ listen("event_name") │
│ ←────────────────────────────── │ app.emit("event_name", payload)
│ callback(payload) │
两种通信方式:
| 方式 |
方向 |
特点 |
适用场景 |
| Command(命令) |
前端 → 后端 → 前端 |
请求-响应模式,类似 RPC |
调用后端函数获取结果 |
| Event(事件) |
双向 |
发布-订阅模式,可广播 |
后端主动通知前端、长任务进度 |
TIP
📌 打个比方:Command 像打电话——你拨号(invoke),对方接听处理,给你答复(return)。Event 像广播电台——后端开播(emit),前端调频收听(listen),随时收到新消息。需要返回值的用 Command(如读取文件内容),后端主动推送的用 Event(如下载进度)。
4.2 Command — 前端调用后端函数
定义 Command(Rust 端)
use serde::{Deserialize, Serialize};
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
#[derive(Serialize, Deserialize)]
struct User { id: u32, name: String }
#[derive(Serialize)]
struct UserResponse { success: bool, user: User }
#[tauri::command]
fn get_user(user_id: u32) -> Result<UserResponse, String> {
if user_id == 1 {
Ok(UserResponse { success: true, user: User { id: 1, name: "张三".into() } })
} else {
Err("用户不存在".into())
}
}
// 异步命令
#[tauri::command]
async fn fetch_data(url: String) -> Result<String, String> {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
Ok(format!("从 {} 获取的数据", url))
}
注册 Command
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet, get_user, fetch_data])
.run(tauri::generate_context!())
.expect("error");
调用 Command(前端)
import { invoke } from '@tauri-apps/api/core';
const greeting = await invoke<string>('greet', { name: '世界' });
try {
const result = await invoke<{ success: boolean; user: { id: number; name: string } }>(
'get_user', { userId: 1 }
);
} catch (error) {
console.error(error); // "用户不存在"
}
const data = await invoke<string>('fetch_data', { url: 'https://api.example.com' });
TIP
📌 参数命名规则:Rust 函数参数用 snake_case(如 user_id),前端调用时用 camelCase(如 userId)。Tauri 自动做转换——尊重各自社区的习惯。
📌 为什么 Command 应返回 Result?Result::Ok 对应 Promise resolve,Result::Err 对应 Promise reject。如果不返回 Result 而是直接 panic,前端会收到不友好的错误且无法 catch。生产代码中 Command 应始终返回 Result<T, E>。
4.3 Event — 事件系统
后端发送事件
use tauri::{Emitter, Manager};
#[tauri::command]
async fn start_download(app: tauri::AppHandle) -> Result<(), String> {
for i in 1..=100 {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
app.emit("download-progress", i).map_err(|e| e.to_string())?;
}
app.emit("download-complete", "文件已下载").map_err(|e| e.to_string())?;
Ok(())
}
前端监听事件
import { listen } from '@tauri-apps/api/event';
const unlisten = await listen<number>('download-progress', (event) => {
console.log(`进度: ${event.payload}%`);
});
await listen<string>('download-complete', (event) => {
console.log(event.payload);
});
// 组件卸载时取消监听(重要!)
unlisten();
前端 → 后端事件
import { emit } from '@tauri-apps/api/event';
await emit('frontend-event', { message: '来自前端的消息' });
use tauri::Listener;
// 后端监听
.setup(|app| {
app.listen("frontend-event", |event| {
println!("收到前端事件: {:?}", event.payload());
});
Ok(())
})
TIP
📌 为什么监听器需要手动 unlisten?listen 返回的是持久订阅,如果在 React 组件中 listen 但卸载后没取消,监听器还在——每次重新挂载就多一个,造成内存泄漏和重复回调。在 useEffect 的 cleanup 中调用 unlisten:
useEffect(() => {
const unlisten = listen('event', handler);
return () => { unlisten.then(fn => fn()); };
}, []);
4.4 前后端数据类型映射
| Rust 类型 |
TypeScript 类型 |
说明 |
String / &str |
string |
|
i32 / i64 / u32 / u64 |
number |
|
f32 / f64 |
number |
|
bool |
boolean |
|
Vec<T> |
T[] |
数组 |
Option<T> |
T | null |
可选值 |
HashMap<String, T> |
Record<string, T> |
对象 |
struct (with Serialize) |
对应 interface |
自定义结构体 |
Result<T, E> |
Promise<T> (reject 时是 E) |
|
5 · Rust 后端 Commands 深入
5.1 命令参数提取
use tauri::{AppHandle, State, Window};
// 普通参数:从前端 invoke 的 args 对象中提取
#[tauri::command]
fn add(a: i32, b: i32) -> i32 { a + b }
// 注入 AppHandle:操作应用级能力
#[tauri::command]
fn notify(app: AppHandle, msg: String) {
app.emit("notification", msg).unwrap();
}
// 注入 Window:操作当前窗口
#[tauri::command]
fn set_title(window: Window, title: String) {
window.set_title(&title).unwrap();
}
// 注入 State:访问全局共享状态
#[tauri::command]
fn get_value(state: State<'_, MyState>) -> String {
state.inner().value.clone()
}
TIP
📌 Tauri 怎么知道哪些参数从前端取、哪些自动注入?规则:普通类型参数(String、i32、struct 等)从前端 args 取,Tauri 特殊类型(AppHandle、State、Window 等)自动注入。不需要在前端传 appHandle——Tauri 编译时通过类型推断自动区分。
5.2 错误处理最佳实践
#[derive(Debug, Serialize)]
enum AppError {
NotFound(String),
PermissionDenied,
InternalError(String),
}
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
AppError::InternalError(e.to_string())
}
}
#[tauri::command]
fn read_config(path: String) -> Result<String, AppError> {
let content = std::fs::read_to_string(&path)?;
if content.is_empty() {
return Err(AppError::NotFound("配置为空".into()));
}
Ok(content)
}
TIP
📌 为什么错误类型也要 Serialize?Tauri 把 Result::Err 的值序列化为 JSON 发给前端。如果错误类型没实现 Serialize,编译时就报错——Rust 类型系统帮你提前发现问题。
📌 为什么用自定义错误而不是 String?String 错误丢失了结构信息,前端无法做条件判断(如"如果是 NotFound 就跳转到创建页面")。自定义错误类型让前端能精确匹配错误种类。
5.3 异步命令与线程模型
// async 命令在异步运行时上执行,不阻塞 UI 线程
#[tauri::command]
async fn long_task() -> Result<String, String> {
// CPU 密集型任务用 spawn_blocking
let result = tokio::task::spawn_blocking(|| {
std::thread::sleep(std::time::Duration::from_secs(2));
"计算完成".to_string()
}).await.map_err(|e| e.to_string())?;
Ok(result)
}
// 并发执行多个异步操作
#[tauri::command]
async fn fetch_all() -> Result<Vec<String>, String> {
let (a, b) = tokio::join!(
fetch_api("https://api.a.com"),
fetch_api("https://api.b.com"),
);
Ok(vec![a, b])
}
TIP
📌 什么时候用 async,什么时候用 spawn_blocking?async 命令跑在 tokio 异步运行时上,适合 I/O 密集型操作(网络请求、文件读写)。CPU 密集型操作(加密、压缩、大数据处理)会阻塞 tokio 工作线程,应该用 spawn_blocking 丢到专门线程池。经验法则:等待外部响应用 async,自己算用 spawn_blocking。
6 · 前后端通信(IPC)深入
6.1 IPC 的底层原理
Tauri 的 IPC 通过 WebView 的自定义协议 实现,不经过网络栈:
前端 invoke()
→ @tauri-apps/api 将参数序列化为 JSON
→ 通过 WebView 内部消息通道发送
→ Rust 端接收并反序列化
→ 路由到对应的 #[command] 函数
→ 结果序列化为 JSON 回传
→ 前端 Promise resolve/reject
TIP
📌 为什么不用 HTTP 做 IPC?HTTP 需要起本地服务器(Electron 就这么做),带来安全风险——其他程序也能访问这个端口。Tauri 的 IPC 直接在 WebView 引擎内部传递消息,不经过网络栈,无法被外部程序访问,安全性更高。
6.2 批量调用与性能
// 串行调用:100 次 IPC 往返
for (const id of ids) {
await invoke('get_item', { id });
}
// 并发调用
const items = await Promise.all(ids.map(id => invoke('get_item', { id })));
// 最佳:设计批量命令,一次 IPC 往返
const items = await invoke('get_items_batch', { ids });
TIP
📌 IPC 的性能瓶颈在哪?每次 IPC 有固定开销:JSON 序列化 + WebView 消息传递 + Rust 反序列化 + 结果序列化 + 回传。单次约 0.1-1ms,循环 1000 次就是 100ms-1s。批量操作永远优于循环单条调用——和数据库 N+1 问题一个道理。
6.3 大数据传输优化
// 大文件通过 IPC 返回:Base64 编码后体积翻倍
#[tauri::command]
fn read_file(path: String) -> Result<Vec<u8>, String> {
std::fs::read(&path).map_err(|e| e.to_string())
}
// 大文件用 tauri-plugin-fs 让前端直接读
// 前端: const data = await readFile('/path/to/file');
TIP
📌 为什么大文件不走 IPC?IPC 要把数据序列化成 JSON。10MB 二进制文件 Base64 后变 13.3MB 字符串,再包在 JSON 里,前端还要反序列化——内存翻倍且 CPU 开销大。用 tauri-plugin-fs 直接在前端读文件,数据通过更高效的通道传递,不走 JSON 序列化。
7 · 窗口管理

7.1 窗口配置(tauri.conf.json)
{
"app": {
"windows": [{
"label": "main",
"title": "主窗口",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 600,
"resizable": true,
"center": true,
"decorations": true,
"transparent": false,
"alwaysOnTop": false
}]
}
}
| 属性 |
作用 |
默认值 |
label |
窗口唯一标识,代码中引用用 |
必填 |
title |
窗口标题栏文字 |
|
width / height |
初始尺寸 |
800 / 600 |
minWidth / minHeight |
最小尺寸 |
无限制 |
resizable |
是否可调整大小 |
true |
decorations |
是否显示标题栏和边框 |
true |
transparent |
窗口是否透明 |
false |
alwaysOnTop |
窗口置顶 |
false |
center |
启动时居中 |
false |
7.2 代码中操作窗口
Rust 端
use tauri::{WebviewWindowBuilder, WebviewUrl, Manager};
#[tauri::command]
async fn open_settings(app: AppHandle) -> Result<(), String> {
if app.get_webview_window("settings").is_some() {
app.get_webview_window("settings").unwrap().set_focus().ok();
return Ok(());
}
WebviewWindowBuilder::new(&app, "settings", WebviewUrl::App("settings.html".into()))
.title("设置")
.inner_size(600.0, 400.0)
.resizable(false)
.center()
.build()
.map_err(|e| e.to_string())?;
Ok(())
}
前端
import { getCurrentWindow } from '@tauri-apps/api/window';
const win = getCurrentWindow();
await win.minimize();
await win.toggleMaximize();
await win.close();
await win.setTitle('新标题');
7.3 窗口事件
const win = getCurrentWindow();
win.onResized(({ payload: { width, height } }) => {
console.log(`窗口尺寸: ${width}x${height}`);
});
win.onFocusChanged(({ payload: focused }) => {
console.log(focused ? '获得焦点' : '失去焦点');
});
win.onCloseRequested(async (event) => {
event.preventDefault();
const confirmed = await confirm('确定要退出吗?');
if (confirmed) await win.destroy();
});
TIP
📌 onCloseRequested 的 preventDefault 为什么有用?用户点关闭按钮时,默认直接关窗口。如果有未保存数据,需要先弹确认框。preventDefault 拦截默认关闭,给你机会做检查,确认后再手动 destroy()。
7.4 自定义标题栏(无边框窗口)
{ "app": { "windows": [{ "label": "main", "decorations": false }] } }
.titlebar {
height: 32px;
display: flex;
justify-content: flex-end;
-webkit-user-select: none;
-webkit-app-region: drag; /* 可拖动区域 */
}
.titlebar-button {
-webkit-app-region: no-drag; /* 按钮不可拖动 */
width: 46px; height: 32px;
border: none; background: transparent;
color: #fff; cursor: pointer;
}
import { getCurrentWindow } from '@tauri-apps/api/window';
const win = getCurrentWindow();
function TitleBar() {
return (
<div className="titlebar">
<button onClick={() => win.minimize()}>─</button>
<button onClick={() => win.toggleMaximize()}>□</button>
<button onClick={() => win.close()}>✕</button>
</div>
);
}
TIP
📌 -webkit-app-region: drag 是什么?WebView 提供的 CSS 属性,标记区域为"可拖动"——用户按住拖动就能移动窗口,模拟原生标题栏。按钮上设 no-drag 是因为按钮需要响应点击,如果也是 drag 区域,点击会被拖动行为吞掉。
8 · 状态管理
8.1 Rust 端全局状态
use std::sync::Mutex;
use tauri::State;
struct AppState {
counter: Mutex<i32>,
}
#[tauri::command]
fn get_counter(state: State<'_, AppState>) -> i32 {
*state.counter.lock().unwrap()
}
#[tauri::command]
fn increment(state: State<'_, AppState>) -> i32 {
let mut c = state.counter.lock().unwrap();
*c += 1;
*c
}
// 注册
tauri::Builder::default()
.manage(AppState { counter: Mutex::new(0) })
.invoke_handler(tauri::generate_handler![get_counter, increment])
.run(tauri::generate_context!()).unwrap();
TIP
📌 为什么用 Mutex 而不是 RefCell?RefCell 只适用于单线程。Tauri 的命令可能在不同线程上并发执行,必须用线程安全的 Mutex。用 RefCell 会在运行时 panic。
📌 std::sync::Mutex vs tauri::async::Mutex?std::sync::Mutex::lock() 阻塞当前线程——在 async 命令中会阻塞 tokio 工作线程。tauri::async::Mutex::lock().await 异步等待,不阻塞线程。规则:同步命令用 std Mutex,异步命令用 async Mutex。
8.2 跨窗口共享状态
#[tauri::command]
async fn update_theme(app: AppHandle, state: State<'_, AppState>, theme: String) -> Result<(), String> {
{ let mut config = state.config.lock().unwrap(); config.theme = theme.clone(); }
app.emit("theme-changed", &theme).map_err(|e| e.to_string())?;
Ok(())
}
9 · 插件系统
9.1 官方插件一览

| 插件 |
Rust crate |
JS 包 |
功能 |
shell |
tauri-plugin-shell |
@tauri-apps/plugin-shell |
执行外部命令、打开 URL |
fs |
tauri-plugin-fs |
@tauri-apps/plugin-fs |
文件读写 |
dialog |
tauri-plugin-dialog |
@tauri-apps/plugin-dialog |
文件选择、消息框 |
notification |
tauri-plugin-notification |
@tauri-apps/plugin-notification |
系统通知 |
clipboard |
tauri-plugin-clipboard-manager |
@tauri-apps/plugin-clipboard-manager |
剪贴板 |
global-shortcut |
tauri-plugin-global-shortcut |
@tauri-apps/plugin-global-shortcut |
全局快捷键 |
os |
tauri-plugin-os |
@tauri-apps/plugin-os |
系统信息 |
process |
tauri-plugin-process |
@tauri-apps/plugin-process |
进程控制 |
updater |
tauri-plugin-updater |
@tauri-apps/plugin-updater |
自动更新 |
sql |
tauri-plugin-sql |
@tauri-apps/plugin-sql |
数据库 |
store |
tauri-plugin-store |
@tauri-apps/plugin-store |
持久化键值存储 |
http |
tauri-plugin-http |
@tauri-apps/plugin-http |
HTTP 请求(绕过 CORS) |
log |
tauri-plugin-log |
@tauri-apps/plugin-log |
日志系统 |
deep-link |
tauri-plugin-deep-link |
@tauri-apps/plugin-deep-link |
自定义 URL 协议 |
9.2 使用插件
以 dialog 插件为例:
# 1. 安装 JS 包
npm install @tauri-apps/plugin-dialog
# 2. 安装 Rust crate
cargo add tauri-plugin-dialog
// 3. 注册插件
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.run(tauri::generate_context!()).expect("error");
// 4. 前端使用
import { open } from '@tauri-apps/plugin-dialog';
const filePath = await open({
multiple: false,
filters: [{ name: '图片', extensions: ['png', 'jpg', 'webp'] }],
});
9.3 权限配置(Tauri 2.0)
// src-tauri/capabilities/default.json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "默认权限",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"dialog:allow-open",
"fs:default",
"fs:allow-read-text-file",
"shell:allow-open"
]
}
TIP
📌 Tauri 2.0 的权限模型为什么变了?Tauri 1.x 用 allowlist 在 tauri.conf.json 里配置——全局白名单,粒度太粗。Tauri 2.0 改用 Capabilities + Permissions 模型:可以为不同窗口分配不同权限。比如主窗口有完整权限,子窗口只有最小权限。这是最小权限原则的更细粒度实现。
9.4 编写自定义插件
mod my_plugin {
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("my-plugin")
.invoke_handler(tauri::generate_handler![ping])
.setup(|_app, _api| {
println!("my-plugin 初始化");
Ok(())
})
.build()
}
#[tauri::command]
fn ping() -> String { "pong".to_string() }
}
// 注册
tauri::Builder::default()
.plugin(my_plugin::init())
.run(tauri::generate_context!()).expect("error");
10 · 文件系统与资源管理
10.1 使用 tauri-plugin-fs
import {
readTextFile, writeTextFile, readFile, writeFile,
exists, remove, readDir, mkdir, rename, copyFile
} from '@tauri-apps/plugin-fs';
await writeTextFile('/path/to/output.txt', 'Hello Tauri!');
const text = await readTextFile('/path/to/file.txt');
const fileExists = await exists('/path/to/file.txt');
const entries = await readDir('/path/to/dir');
for (const entry of entries) {
console.log(entry.name, entry.isDirectory, entry.isFile);
}
await mkdir('/path/to/new/dir', { recursive: true });
await remove('/path/to/file.txt');
await rename('/old/path', '/new/path');
await copyFile('/source.txt', '/dest.txt');
10.2 应用数据目录
use tauri::Manager;
#[tauri::command]
fn get_paths(app: AppHandle) -> Result<serde_json::Value, String> {
Ok(serde_json::json!({
"appData": app.path().app_data_dir().map_err(|e| e.to_string())?,
"appConfig": app.path().app_config_dir().map_err(|e| e.to_string())?,
"appCache": app.path().app_cache_dir().map_err(|e| e.to_string())?,
"appLog": app.path().app_log_dir().map_err(|e| e.to_string())?,
}))
}
| 目录 |
Windows |
macOS |
Linux |
appData |
%APPDATA%/<id> |
~/Library/Application Support/<id> |
~/.local/share/<id> |
appConfig |
同 appData |
~/Library/Preferences/<id> |
~/.config/<id> |
appCache |
%LOCALAPPDATA%/<id>/Cache |
~/Library/Caches/<id> |
~/.cache/<id> |
appLog |
appData/logs |
~/Library/Logs/<id> |
~/.local/state/<id>/log |
TIP
📌 为什么不用硬编码路径?不同操作系统的约定不同。Windows 放 %APPDATA%,macOS 放 ~/Library/Application Support/,Linux 放 ~/.local/share/。硬编码任何一个路径在其他平台上都是错的。用 Tauri 的 path() API 统一获取,自动适配三个平台。
10.3 资源文件(打包内置文件)
// tauri.conf.json
{
"bundle": {
"resources": ["resources/config.json", "resources/templates/*.html"]
}
}
use tauri::path::BaseDirectory;
#[tauri::command]
fn read_resource(app: AppHandle) -> Result<String, String> {
let path = app.path()
.resolve("resources/config.json", BaseDirectory::Resource)
.map_err(|e| e.to_string())?;
std::fs::read_to_string(path).map_err(|e| e.to_string())
}
TIP
📌 资源文件和普通文件的区别:资源文件在打包时嵌入安装包,安装后释放到应用安装目录,只读。如果需要可写配置,应该把默认配置作为资源打包,首次运行时复制到 appData 目录,之后从 appData 读写。
11 · 数据库集成
11.1 使用 tauri-plugin-sql
npm install @tauri-apps/plugin-sql
cargo add tauri-plugin-sql --features sqlite
use tauri_plugin_sql::{Migration, MigrationKind};
let migrations = vec![
Migration {
version: 1,
description: "create users table",
sql: "CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
);",
kind: MigrationKind::Up,
},
];
tauri::Builder::default()
.plugin(
tauri_plugin_sql::Builder::default()
.add_migrations("sqlite:app.db", migrations)
.build(),
)
.run(tauri::generate_context!()).expect("error");
import Database from '@tauri-apps/plugin-sql';
const db = await Database.load('sqlite:app.db');
// 插入
const result = await db.execute(
'INSERT INTO users (name, email) VALUES ($1, $2)',
['张三', 'zhangsan@example.com']
);
// 查询
const users = await db.select('SELECT * FROM users WHERE name = $1', ['张三']);
// 更新 / 删除
await db.execute('UPDATE users SET name = $1 WHERE id = $2', ['李四', 1]);
await db.execute('DELETE FROM users WHERE id = $1', [1]);
TIP
📌 什么时候用 tauri-plugin-sql,什么时候在 Rust 里用 rusqlite?tauri-plugin-sql 让前端直接执行 SQL,适合简单 CRUD。但如果业务逻辑复杂(多表关联、事务嵌套),建议在 Rust 端用 rusqlite 封装业务逻辑,只通过 Command 暴露高层接口——不要让前端直接拼 SQL。
📌 迁移为什么重要?数据库 schema 会随版本演进。迁移机制记录每次 schema 变更,应用启动时自动执行未应用的迁移。永远不要手动改 schema,始终通过迁移文件变更。
11.2 使用 tauri-plugin-store(轻量键值存储)
import { LazyStore } from '@tauri-apps/plugin-store';
const store = new LazyStore('settings.json');
await store.set('theme', 'dark');
await store.set('fontSize', 14);
const theme = await store.get<string>('theme'); // 'dark'
await store.delete('theme');
await store.save(); // 持久化到磁盘
await store.clear();
TIP
📌 什么时候用 store,什么时候用 sql?store 是键值存储,适合简单配置(主题、字体大小、窗口位置)。sql 是关系型数据库,适合有结构化查询需求的数据。能用一个 JSON 对象表示的用 store,需要查询/过滤/关联的用 sql。
12 · 系统集成
12.1 系统托盘(Tray)
use tauri::tray::{TrayIconBuilder, MouseButton, MouseButtonState, TrayIconEvent};
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
tauri::Builder::default()
.setup(|app| {
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
let show = MenuItem::with_id(app, "show", "显示主窗口", true, None::<&str>)?;
let separator = PredefinedMenuItem::separator(app)?;
let menu = Menu::with_items(app, &[&show, &separator, &quit])?;
let _tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.tooltip("我的应用")
.menu(&menu)
.on_menu_event(|app, event| {
match event.id.as_ref() {
"quit" => app.exit(0),
"show" => {
if let Some(w) = app.get_webview_window("main") {
w.show().ok(); w.set_focus().ok();
}
}
_ => {}
}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up, ..
} = event {
if let Some(w) = tray.app_handle().get_webview_window("main") {
w.show().ok(); w.set_focus().ok();
}
}
})
.build(app)?;
Ok(())
})
.run(tauri::generate_context!()).expect("error");
12.2 全局快捷键
npm install @tauri-apps/plugin-global-shortcut
cargo add tauri-plugin-global-shortcut
use tauri_plugin_global_shortcut::{Code, Modifiers, Shortcut, ShortcutState};
tauri::Builder::default()
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(|app, shortcut, event| {
if event.state == ShortcutState::Pressed {
if shortcut.mods == Modifiers::CONTROL | Modifiers::SHIFT
&& shortcut.key == Code::KeyD
{
if let Some(w) = app.get_webview_window("main") {
if w.is_visible().unwrap_or(false) { w.hide().ok(); }
else { w.show().ok(); w.set_focus().ok(); }
}
}
}
})
.build(),
)
.setup(|app| {
let shortcut = Shortcut::new(
Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyD
);
app.global_shortcut().register(shortcut)?;
Ok(())
})
.run(tauri::generate_context!()).expect("error");
import { register, unregister } from '@tauri-apps/plugin-global-shortcut';
await register('CommandOrControl+Shift+D', () => {
console.log('快捷键被按下');
});
await unregister('CommandOrControl+Shift+D');
TIP
📌 全局快捷键和前端快捷键的区别:前端快捷键(addEventListener('keydown'))只在应用窗口获得焦点时生效。全局快捷键在任何应用获得焦点时都生效——即使你的应用在后台或托盘中。适用于"按 Ctrl+Shift+D 呼出应用"。
📌 CommandOrControl 是什么?跨平台修饰符:macOS 上映射为 Command(⌘),Windows/Linux 上映射为 Ctrl。写一份快捷键定义适配所有平台。
12.3 系统通知
import {
isPermissionGranted, requestPermission, sendNotification
} from '@tauri-apps/plugin-notification';
let granted = await isPermissionGranted();
if (!granted) {
const permission = await requestPermission();
granted = permission === 'granted';
}
if (granted) {
await sendNotification({ title: '下载完成', body: '文件已保存到 ~/Downloads/' });
}
TIP
📌 为什么通知需要请求权限?macOS 和 Windows 都有通知权限管理——用户可以在系统设置中禁用某应用的通知。requestPermission() 触发系统弹窗。在发送通知前始终检查权限,否则在权限被拒时静默失败。
12.4 剪贴板
import { writeText, readText } from '@tauri-apps/plugin-clipboard-manager';
await writeText('要复制的内容');
const text = await readText();
12.5 打开外部链接
import { open } from '@tauri-apps/plugin-shell';
await open('https://github.com'); // 系统默认浏览器
await open('/path/to/document.pdf'); // 系统默认程序
TIP
📌 为什么不能用 window.open()?Tauri 的 WebView 没有完整浏览器功能——window.open() 要么不工作,要么在 WebView 内部打开(而不是系统浏览器)。用 tauri-plugin-shell 的 open() 调用系统 API 用默认浏览器打开链接。
13 · 自动更新

13.1 配置 Updater
npm install @tauri-apps/plugin-updater
cargo add tauri-plugin-updater
cargo add tauri-plugin-process # 用于重启
// tauri.conf.json
{
"plugins": {
"updater": {
"pubkey": "你的公钥内容",
"endpoints": [
"https://your-server.com/updates/{{target}}/{{arch}}/{{current_version}}"
],
"dialog": true
}
}
}
13.2 生成签名密钥
npx tauri signer generate -w ~/.tauri/myapp.key
# 输出公钥写入 tauri.conf.json 的 pubkey
# 私钥保存到 ~/.tauri/myapp.key(打包时用,绝对不能泄露)
13.3 更新服务器响应格式
{
"version": "2.0.0",
"notes": "修复了若干 bug,新增暗黑模式",
"pub_date": "2026-01-15T10:00:00Z",
"platforms": {
"windows-x86_64": {
"signature": "dW50cnVzdGVk...",
"url": "https://your-server.com/releases/v2.0.0/myapp-setup.exe"
},
"darwin-aarch64": {
"signature": "dW50cnVzdGVk...",
"url": "https://your-server.com/releases/v2.0.0/myapp-aarch64.app.tar.gz"
},
"linux-x86_64": {
"signature": "dW50cnVzdGVk...",
"url": "https://your-server.com/releases/v2.0.0/myapp.AppImage.tar.gz"
}
}
}
当前版本已最新则返回 HTTP 204 No Content。
13.4 前端检查更新
import { check } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
async function checkForUpdates() {
const update = await check();
if (update?.available) {
const confirmed = confirm(
`发现新版本 ${update.version}!\n\n${update.body}\n\n是否现在更新?`
);
if (confirmed) {
let downloaded = 0, contentLength = 0;
await update.downloadAndInstall((event) => {
switch (event.event) {
case 'Started':
contentLength = event.data.contentLength ?? 0;
break;
case 'Progress':
downloaded += event.data.chunkLength;
console.log(`进度: ${downloaded}/${contentLength}`);
break;
case 'Finished':
console.log('下载完成');
break;
}
});
await relaunch();
}
} else {
console.log('当前已是最新版本');
}
}
TIP
📌 为什么要签名?更新是从互联网下载可执行文件——如果有人劫持更新服务器或 DNS,就能让用户安装恶意软件。签名机制确保:即使 URL 被篡改,下载的文件签名验证不通过,Tauri 拒绝安装。私钥绝对不能泄露或提交到 Git。
📌 macOS 和 Linux 的更新方式为什么不同?Windows 下载新的安装包直接运行。macOS 和 Linux 是增量更新——下载 .tar.gz 补丁包,解压覆盖当前应用文件。所以 URL 指向 .app.tar.gz / .AppImage.tar.gz,不是完整安装包。
14 · 打包与分发
14.1 构建生产包
# 构建当前平台的所有安装包格式
npm run tauri build
# 指定打包格式
npm run tauri build -- --bundles msi # Windows MSI
npm run tauri build -- --bundles nsis # Windows NSIS
npm run tauri build -- --bundles dmg # macOS DMG
npm run tauri build -- --bundles deb # Linux DEB
npm run tauri build -- --bundles appimage # Linux AppImage
14.2 各平台打包格式
| 平台 |
格式 |
说明 |
| Windows |
.msi |
Windows Installer,系统级安装,支持组策略部署 |
|
.exe (NSIS) |
轻量安装包,安装速度快,支持自定义安装页面 |
| macOS |
.dmg |
磁盘映像,用户拖拽到 Applications 安装 |
|
.app |
直接可运行的应用包 |
| Linux |
.deb |
Debian/Ubuntu 系包 |
|
.rpm |
Red Hat/Fedora 系包 |
|
.AppImage |
免安装,下载即运行,便携 |
|
.deb + .rpm |
覆盖大部分发行版 |
14.3 应用图标
# 生成各平台所需图标(从一张源图自动生成)
npx tauri icon path/to/source-icon.png
生成的图标放在 src-tauri/icons/ 目录:
32x32.png / 128x128.png — Linux
icon.ico — Windows
icon.icns — macOS
Square*Logo.png / StoreLogo.png — Windows Store
14.4 签名
Windows 代码签名
// tauri.conf.json
{
"bundle": {
"windows": {
"certificateThumbprint": "证书指纹",
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.sectigo.com"
}
}
}
# 或通过环境变量
$env:TAURI_SIGNING_PRIVATE_KEY = "路径或Base64"
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = "密码"
npm run tauri build
macOS 签名与公证
# 设置环境变量
export APPLE_SIGNING_IDENTITY="Developer ID Application: Your Name (XXXXXXXXXX)"
export APPLE_ID="your@email.com"
export APPLE_PASSWORD="app-specific-password" # 非账号密码,在 appleid.apple.com 生成
export APPLE_TEAM_ID="XXXXXXXXXX"
npm run tauri build
TIP
📌 为什么要代码签名?未签名的应用在 Windows 上会弹出 SmartScreen 警告,在 macOS 上会被 Gatekeeper 拦截("无法验证开发者")。签名后用户安装时不会看到吓人的警告,且签名证明应用未被篡改。正式发布的应用必须签名。
📌 macOS 公证(Notarization)是什么?苹果要求所有分发到 App Store 之外的 macOS 应用必须经过苹果服务器扫描恶意软件。tauri build 在签名后自动提交公证请求,通常 5-15 分钟完成。没有公证,用户首次打开会看到"无法打开,因为无法验证开发者"。
15 · CI/CD 自动化
15.1 GitHub Actions 多平台构建
# .github/workflows/release.yml
name: Release
on:
push:
tags: ['v*']
jobs:
build:
strategy:
matrix:
include:
- platform: macos-latest
args: '--target aarch64-apple-darwin'
- platform: macos-latest
args: '--target x86_64-apple-darwin'
- platform: ubuntu-22.04
args: ''
- platform: windows-latest
args: ''
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Install Linux dependencies
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt update
sudo apt install -y libwebkit2gtk-4.1-dev libxdo-dev libssl-dev \
libayatana-appindicator3-dev librsvg2-dev
- name: Install frontend dependencies
run: npm install
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# macOS 签名
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# 更新签名
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
tagName: ${{ github.ref_name }}
releaseName: '${{ github.ref_name }}'
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
TIP
📌 为什么 macOS 要构建两个 target?Apple Silicon(M1/M2)和 Intel CPU 架构不同。aarch64-apple-darwin 是 ARM 架构(M 系列芯片),x86_64-apple-darwin 是 x86 架构(Intel 芯片)。Tauri 支持构建 Universal Binary(合并两种架构),但 CI 上分别构建再合并更可靠。
16 · 安全模型

16.1 CSP(内容安全策略)
// tauri.conf.json
{
"app": {
"security": {
"csp": "default-src 'self'; img-src 'self' asset: https://*; script-src 'self'; style-src 'self' 'unsafe-inline'"
}
}
}
| 指令 |
含义 |
default-src 'self' |
默认只允许加载同源资源 |
img-src 'self' data: https: |
图片允许同源、data URI、HTTPS |
script-src 'self' |
脚本只允许同源(禁止 inline script) |
style-src 'self' 'unsafe-inline' |
样式允许同源和 inline(前端框架常需要) |
TIP
📌 CSP 为什么重要?WebView 加载的是前端代码——如果前端有 XSS 漏洞,攻击者可以注入 <script> 标签加载外部恶意脚本。CSP 告诉 WebView"只允许从哪里加载什么类型的资源",即使有 XSS,恶意脚本也被 CSP 拦截。这是 Tauri 安全的最后一道防线。
16.2 Capabilities 权限系统(Tauri 2.0)
// src-tauri/capabilities/default.json
{
"identifier": "default",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:default",
"core:webview:default",
"fs:default",
"dialog:default"
]
}
// 可以为不同窗口定义不同权限
// src-tauri/capabilities/settings-window.json
{
"identifier": "settings",
"windows": ["settings"],
"permissions": [
"core:default",
"store:default"
]
}
TIP
📌 最小权限原则:每个窗口只授予它需要的最小权限。主窗口可能需要文件系统、对话框、通知等完整权限。但"关于"窗口只需要读取应用信息——不需要文件系统权限。如果"关于"窗口被 XSS 攻击,攻击者也无法访问文件系统,因为该窗口没有 fs 权限。
16.3 危险 API 与安全实践
// 危险:直接执行用户输入的命令
#[tauri::command]
fn run_command(cmd: String) -> Result<String, String> {
std::process::Command::new("sh").arg("-c").arg(&cmd).output()
.map_err(|e| e.to_string())
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
}
// 安全:白名单方式
#[tauri::command]
fn run_allowed_command(cmd: String, args: Vec<String>) -> Result<String, String> {
let allowed = ["git", "npm", "cargo"];
if !allowed.contains(&cmd.as_str()) {
return Err(format!("不允许执行命令: {}", cmd));
}
std::process::Command::new(&cmd).args(&args).output()
.map_err(|e| e.to_string())
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
}
TIP
📌 为什么永远不要直接执行用户输入?这和 SQL 注入是一个道理——如果前端传来的字符串直接作为 shell 命令执行,攻击者可以通过 XSS 构造恶意命令(如 rm -rf /)。始终用白名单验证命令名,参数也要做校验。
17 · 移动端支持
17.1 添加移动端目标
# 安装移动端工具链
cargo install tauri-cli --version "^2.0.0"
# 初始化 Android 项目
npx tauri android init
# 初始化 iOS 项目(仅 macOS 上可用)
npx tauri ios init
# 开发
npx tauri android dev
npx tauri ios dev
# 构建
npx tauri android build
npx tauri ios build
17.2 平台条件编译
// 使用 cfg 区分平台
#[cfg(mobile)]
fn mobile_only() {
// 仅移动端执行的代码
}
#[cfg(desktop)]
fn desktop_only() {
// 仅桌面端执行的代码
}
// 在 Command 中区分
#[tauri::command]
fn get_platform() -> String {
#[cfg(target_os = "android")]
return "android".to_string();
#[cfg(target_os = "ios")]
return "ios".to_string();
#[cfg(target_os = "windows")]
return "windows".to_string();
#[cfg(target_os = "macos")]
return "macos".to_string();
#[cfg(target_os = "linux")]
return "linux".to_string();
}
TIP
📌 为什么移动端开发需要 macOS?iOS 构建需要 Xcode 和 Apple 的工具链,这些只在 macOS 上运行。Android 构建需要 Android SDK/NDK,可以在任何平台上安装。开发 iOS 必须用 Mac,这是 Apple 的限制,不是 Tauri 的。
18 · 性能优化
18.1 前端优化
// 1. 懒加载非关键路由
const Settings = lazy(() => import('./pages/Settings'));
// 2. 虚拟列表处理大数据
import { FixedSizeList } from 'react-window';
<FixedSizeList height={600} itemCount={10000} itemSize={35} width={800}>
{({ index, style }) => <div style={style}>行 {index}</div>}
</FixedSizeList>
// 3. 防抖/节流频繁事件
import { debounce } from 'lodash';
const debouncedSearch = debounce((query: string) => {
invoke('search', { query });
}, 300);
18.2 Rust 端优化
// 1. 用 spawn_blocking 处理 CPU 密集型任务
#[tauri::command]
async fn process_image(path: String) -> Result<Vec<u8>, String> {
tokio::task::spawn_blocking(move || {
// 图像处理...
Ok(vec![])
}).await.map_err(|e| e.to_string())?
}
// 2. 批量操作减少 IPC 次数
#[tauri::command]
async fn batch_insert(items: Vec<Item>) -> Result<usize, String> {
// 一次插入所有,而不是前端循环调用单条插入
let count = items.len();
// batch insert...
Ok(count)
}
// 3. 用 Arc 共享只读数据,避免克隆
use std::sync::Arc;
struct Cache {
data: Arc<HashMap<String, String>>,
}
#[tauri::command]
fn get_cached(state: State<'_, Cache>, key: String) -> Option<String> {
state.data.get(&key).cloned() // 只 clone 需要的值
}
18.3 包体积优化
# Cargo.toml — Release profile 优化
[profile.release]
opt-level = "s" # 优化体积(可选 "z" 更激进)
lto = true # 链接时优化,减小二进制
codegen-units = 1 # 单编译单元,更好的优化
strip = true # 去除调试符号
panic = "abort" # abort 而非 unwind,减小体积
TIP
📌 为什么 codegen-units = 1 能减小体积?Rust 默认把 crate 拆成多个编译单元并行编译(快),但每个单元独立优化,看不到全局信息。设为 1 后编译变慢,但编译器能看到全部代码做全局优化——去除更多无用代码,体积更小。Release 构建用 1,开发用默认值。
📌 为什么 panic = "abort" 减小体积?默认 panic = "unwind" 会生成栈展开(stack unwinding)代码,用于 panic 时清理资源。这些代码占体积。设为 abort 后 panic 直接终止进程,不需要展开代码。代价是不能 catch panic,但 Release 版本中 panic 本来就是致命错误。
19 · 调试技巧
19.1 开发者工具
# dev 模式下自动开启开发者工具
npm run tauri dev
# 在生产构建中开启开发者工具(调试用)
# tauri.conf.json 中设置
{
"app": {
"withGlobalTauri": true
}
}
19.2 Rust 日志
use tauri_plugin_log::{Builder, Level};
tauri::Builder::default()
.plugin(
Builder::default()
.level(Level::Info)
.build(),
)
.run(tauri::generate_context!()).expect("error");
// 在代码中使用
use log::{info, warn, error};
#[tauri::command]
fn do_something() -> Result<(), String> {
info!("开始执行操作");
warn!("这是一个警告");
error!("这是一个错误");
Ok(())
}
19.3 前端日志
import { debug, info, warn, error } from '@tauri-apps/plugin-log';
await info('来自前端的信息');
await error('来自前端的错误');
19.4 常见调试场景
| 问题 |
排查方法 |
| Command 找不到 |
检查 generate_handler! 是否注册了命令名 |
| IPC 参数不匹配 |
检查前端 camelCase vs Rust snake_case |
| 窗口白屏 |
检查 devUrl / frontendDist 路径是否正确 |
| 打包后资源 404 |
检查 bundle.resources 配置和路径 |
| 权限被拒 |
检查 capabilities/*.json 是否包含所需权限 |
| macOS 签名失败 |
检查证书是否过期、环境变量是否正确 |
| Linux WebView 崩溃 |
检查 WebKitGTK 版本和系统依赖 |
附录 A · 命令速查
CLI 命令
| 命令 |
作用 |
npm create tauri-app@latest |
创建新项目 |
npm run tauri dev |
启动开发模式 |
npm run tauri build |
构建生产包 |
npm run tauri build -- --bundles <format> |
指定打包格式 |
npx tauri icon <path> |
生成各平台图标 |
npx tauri signer generate -w <path> |
生成更新签名密钥 |
npx tauri android init |
初始化 Android 项目 |
npx tauri ios init |
初始化 iOS 项目 |
npx tauri android dev |
Android 开发模式 |
npx tauri ios dev |
iOS 开发模式 |
前端 API 速查
| API |
作用 |
invoke('cmd', args) |
调用 Rust Command |
listen('event', cb) |
监听事件 |
emit('event', payload) |
发送事件 |
getCurrentWindow() |
获取当前窗口实例 |
getAllWindows() |
获取所有窗口 |
附录 B · 配置速查
tauri.conf.json 常用字段
| 字段路径 |
作用 |
productName |
应用名称 |
version |
应用版本 |
identifier |
唯一标识(反向域名) |
build.frontendDist |
前端构建产物路径 |
build.devUrl |
开发服务器地址 |
build.beforeDevCommand |
dev 前执行命令 |
build.beforeBuildCommand |
build 前执行命令 |
app.windows[].label |
窗口标识 |
app.windows[].title |
窗口标题 |
app.windows[].width/height |
窗口尺寸 |
app.windows[].decorations |
是否显示标题栏 |
app.windows[].transparent |
窗口透明 |
app.security.csp |
内容安全策略 |
bundle.targets |
打包格式 |
bundle.icon |
图标文件列表 |
bundle.resources |
打包内置资源 |
plugins.updater.pubkey |
更新公钥 |
plugins.updater.endpoints |
更新检查 URL |
附录 C · 常见问题 FAQ
Q: tauri dev 启动后窗口白屏?
A: 检查前端 dev server 是否正常启动,devUrl 端口是否与 Vite 配置一致。打开开发者工具查看 Console 报错。
Q: tauri build 报错 "linker not found"?
A: Windows 需安装 MSVC C++ Build Tools;Linux 需安装 build-essential。
Q: Command 调用报 "command not found"?
A: 确认命令已在 generate_handler! 宏中注册,且函数有 #[tauri::command] 属性。
Q: 前端 invoke 参数名对不上?
A: Rust 用 snake_case,前端用 camelCase。如 Rust 的 user_id,前端传 userId。
Q: 打包后应用找不到资源文件?
A: 资源文件需在 bundle.resources 中声明,代码中用 BaseDirectory::Resource 解析路径。
Q: macOS 构建报 "no signing certificate"?
A: 需要 Apple Developer 证书。开发阶段可跳过签名:npm run tauri build -- --no-bundle。
Q: Linux 上 WebView 渲染异常?
A: WebKitGTK 版本可能过旧。Ubuntu 22.04+ 需安装 libwebkit2gtk-4.1-dev。
Q: 安装包太大?
A: 检查 Cargo.toml 的 [profile.release] 配置(见 §18.3),启用 lto、strip、opt-level = "s"。
附录 D · 与 Electron 对比
| 维度 |
Tauri |
Electron |
| 后端语言 |
Rust |
Node.js |
| 浏览器引擎 |
系统 WebView |
Chromium(捆绑) |
| 安装包大小 |
3-10 MB |
80-150 MB |
| 内存占用 |
30-80 MB |
100-300 MB |
| 启动速度 |
快(< 1s) |
较慢(1-3s) |
| 安全模型 |
默认最小权限 |
默认全开 |
| 跨平台一致性 |
受系统 WebView 影响 |
Chromium 保证一致 |
| 原生 API |
通过 Rust 插件 |
通过 Node.js 模块 |
| 移动端 |
2.0+ 支持 |
不支持 |
| 自动更新 |
内置(签名验证) |
需 electron-updater |
| 学习曲线 |
需学 Rust 基础 |
纯 JS |
| 生态成熟度 |
成长中 |
非常成熟 |
| 代表应用 |
部分新项目 |
VS Code, Slack, Discord |
:::tip
📌 选型建议:
- 选 Tauri:追求轻量/安全/性能,需要移动端,愿意学 Rust,应用以表单/列表/设置为主
- 选 Electron:团队全是 JS 开发者,需要复杂 Canvas/WebGL 渲染一致性,生态依赖丰富,不在意包体积
:::