Tauri 学习笔记

一份完整的 Tauri 学习笔记。重点讲"为什么这么设计"(费曼式 📌 讲解),覆盖从入门到打包发布的全链路知识。

怎么用这份笔记

  1. 学习/复习 → 顺序看正文,重点看 📌 类比和"为什么"
  2. 查配置 → 翻 附录 B 配置速查
  3. 排错 → 翻 附录 C 常见问题 FAQ
  4. 选型 → 翻 附录 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 整体架构

图 1 \xb7 Tauri 架构分层

每层说明

层 作用 技术栈
前端层 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 前后端通信模型

图 2 \xb7 前后端通信模型

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 · 窗口管理

图 4 \xb7 窗口管理模型

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 官方插件一览

图 3 \xb7 插件系统全景

插件 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 · 自动更新

图 6 \xb7 自动更新流程

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 · 安全模型

图 5 \xb7 安全模型

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 渲染一致性,生态依赖丰富,不在意包体积 :::
本页目录