第8章 8.4 桌面端:Tauri 入门

上一章我们学会了用 qiankun 把多个 Vue3 子应用「拼」在一起,做了一个微前端架构的管理后台。听起来挺酷的——但你有没有想过,万一产品经理说「这个管理后台能不能做成桌面软件,让员工不用开浏览器就能用」?

好问题。这就是今天要聊的事儿。

🎯 开场 3 分钟:为什么要学这个?

先说个真实场景。我有个朋友在小公司当技术负责人,他们做了个内部工具网站,方便员工查数据、做审批。结果:

  • 员工A:「网址是什么来着?」、「浏览器打不开,换个电脑就不会用了」
  • 员工B:「我习惯桌面软件,双击就能用,不要网页」
  • 老板:「能不能打包成桌面应用?我不想看浏览器地址栏那一堆英文」

网页开发很香,但桌面软件在某些场景下确实更方便。双击即用、任务栏固定、系统级通知——这些都是网页给不了的。

痛点就一个:怎么用 Vue3 的技术栈,直接打包成桌面软件?

答案是 Tauri。学完这章,你能:

  • 用 Vue3 写前端界面
  • 用 Rust 写后端逻辑
  • 打包成一\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\n\n个 .exe(Windows)或 .app(Mac),双击就能跑

而且打包出来的软件,体积只有 Electron 的十分之一(Electron 打包出来 100MB 起,Tauri 可以做到 3-5MB)。


🧱 基础 25 分钟:核心概念

什么是 Tauri?

先说人话。想象你要开一家餐厅:

  • Electron = 你雇了一个人专门站在门口喊「欢迎光临」,这个人还自带一套豪华音响(不管需不需要,反正很重)
  • Tauri = 你只在门口放了一个小喇叭,有人来按门铃才响一下,轻便多了

Electron 用的是 Node.js + Chromium(就是整个 Chrome 浏览器),所以打包出来很大。Tauri 用的是 Rust(一种高效编程语言)+ 系统原生 WebView(Windows 用 Edge macOS 用 Safari),所以体积超小。

类比理解

对比项 Electron Tauri
引擎重量 重(带整个 Chrome) 轻(用系统自带)
打包体积 100MB+ 3-10MB
内存占用
开发语言 JS/TS Rust(后端)+ Web(前

Tauri 的核心架构是这样的:

┌─────────────────────────────────┐
│         你的桌面窗口            │
│  ┌───────────────────────────┐  │
│  │    Vue3 前端界面           │  │  ← 用 HTML/CSS/JS 写
│  │    (WebView 渲染)          │  │
│  └───────────────────────────┘  │
│              ↕ IPC 通信         │
│  ┌───────────────────────────┐  │
│  │    Rust 后端               │  │  ← 用 Rust 写,可调用系统 API
│  │    (处理文件/数据库/系统)   │  │
│  └───────────────────────────┘  │
└─────────────────────────────────┘

为什么选 Tauri 而不是 Electron?

我直接说结论:

  1. 体积小:用户下载你软件,不用等半天
  2. 内存省:员工电脑配置差,Electron 卡成PPT,Tauri 流畅
  3. 更安全:Rust 语言天然内存安全,不容易被病毒利用
  4. Vue 友好:Tauri 官方出了 @tauri-apps/cli,Vue3 一键集成

当然 Electron 也有优势(生态更成熟、包更多),但对 Vue3 开发者来说,Tauri 上手更快。

搭建 Tauri + Vue3 开发环境

第一步:安装 Node.js(略过,之前章节讲过)

第二步:安装 Rust

Tauri 的后端是 Rust,得先装 Rust 编译器。打开终端(Mac/Linux)或 PowerShell(Windows),运行:

# macOS / Linux
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# Windows(PowerShell)
# 先去 https://rustup.rs 下载安装包

装完验证一下:

rustc --version
# 输出类似:rustc 1.75.0
cargo --version
# 输出类似:cargo 1.75.0

第三步:创建 Tauri + Vue3 项目

# 先装 Tauri 脚手架
npm install -g @tauri-apps/cli

# 创建项目(选 Vue + TypeScript)
npm create tauri-app my-tauri-app
# ? Project name: my-tauri-app
# ? Choose which language to use: TypeScript
# ? Choose the UI framework: Vue
# ? Choose the package manager: npm

cd my-tauri-app
npm install

目录结构长这样

my-tauri-app/
├── src/              ← Vue3 前端代码
│   ├── App.vue
│   ├── main.ts
│   └── assets/
├── src-tauri/        ← Rust 后端代码
│   ├── src/
│   │   └── main.rs   ← Rust 入口
│   ├── Cargo.toml
│   └── tauri.conf.json
├── index.html
└── package.json

第四步:启动开发服务器

npm run tauri dev

第一次运行会编译 Rust 代码,慢一点(3-5分钟),之后就好了。编译成功后,你会看到一个桌面窗口弹出来,里面是你的 Vue3 应用。

前端怎么调用后端(Rust)?

这是 Tauri 最核心的概念:IPC 通信

简单说:Vue3 是「前端」,Rust 是「后端」,它俩隔了一层,怎么互相调用?

Tauri 给你提供了一个 @tauri-apps/api 包,里面有:

  • invoke() - 前端调用 Rust 函数
  • listen() - 前端监听 Rust 发的事件
  • emit() - 前端给 Rust 发事件

举个例子:前端想读取电脑上的一个文件。

Rust 端src-tauri/src/main.rs):

// 引入 Tauri 框架
use tauri::Command;

// 定义一个命令,前端可以调用
#[tauri::command]
fn read_file(path: String) -> Result<String, String> {
// 用 Rust 标准库读取文件
std::fs::read_to_string(&path)
    .map_err(|e| e.to_string())
}

// 主函数,注册这个命令
fn main() {
tauri::Builder::default()
    .invoke_handler(tauri::generate_handler![read_file])
    .run(tauri::generate_context!())
    .expect("启动 Tauri 出错了");
}

前端 Vue 端src/App.vue):

<script setup>
import { invoke } from '@tauri-apps/api/tauri'

// 调用 Rust 的 read_file 函数
async function loadFile() {
try {
const content = await invoke('read_file', { path: '/Users/xxx/test.txt' })
console.log('文件内容:', content)
} catch (e) {
console.error('读取失败:', e)
}
}
</script>

<template>
<button @click="loadFile">读取文件</button>
</template>

发生了什么

  1. 前端点击按钮,调用 invoke('read_file', { path: '...' })
  2. Tauri 把请求发给 Rust 后端
  3. Rust 执行 std::fs::read_to_string 读取文件
  4. 结果返回给前端,content 就是文件内容

类比理解:就像你去餐厅吃饭,前端是服务员,Rust 是后厨。服务员(invoke)喊一声「22号桌要一份宫保鸡丁」,后厨做完端出来,服务员再端给你。中间靠的是「点菜系统」(IPC)。

配置桌面窗口

打开 src-tauri/tauri.conf.json,可以配置窗口标题、尺寸、图标等:

{
"build": {
"devtools": true
},
"package": {
"productName": "我的第一个桌面应用"
},
"tauri": {
"windows": [
  {
    "title": "数据管理工具",
    "width": 1024,
    "height": 768,
    "resizable": true,
    "fullscreen": false
  }
]
}
}

改完保存,再跑 npm run tauri dev,窗口标题就变了。


🔥 实战 35 分钟:3 个递进的小项目

项目 1:Hello World 桌面版(5 分钟)

目标:弹出一个窗口,显示「Hello, Tauri!」。

完整代码

<!-- src/App.vue -->
<script setup>
import { invoke } from '@tauri-apps/api/tauri'
import { ref } from 'vue'

const message = ref('点击按钮,我会变')

async function sayHello() {
// 调用 Rust 的 greet 函数
const result = await invoke('greet', { name: '小明' })
message.value = result
}
</script>

<template>
<div class="container">
<h1>{{ message }}</h1>
<button @click="sayHello">打招呼</button>
</div>
</template>

<style scoped>
.container {
padding: 40px;
text-align: center;
}
button {
padding: 10px 20px;
background: #42b883;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>

Rust 端src-tauri/src/main.rs):

use tauri::Command;

#[tauri::command]
fn greet(name: String) -> String {
format!("你好,{}!欢迎使用 Tauri 🎉", name)
}

fn main() {
tauri::Builder::default()
    .invoke_handler(tauri::generate_handler![greet])
    .run(tauri::generate_context!())
    .expect("启动出错了");
}

运行结果

点击按钮前:显示「点击按钮,我会变」
点击按钮后:显示「你好,小明!欢迎使用 Tauri 🎉」

解释:这就是最基础的「前端调用 Rust」模式。记住 invoke('函数名', { 参数 }) 这个格式就行。


项目 2:文件浏览器(15 分钟)

目标:选择一个文件夹,显示里面有哪些文件。

这个项目用到 Tauri 的对话框 API(让用户选文件夹)和 Rust 的文件操作。

安装依赖

npm install @tauri-apps/api @tauri-apps/plugin-dialog

Vue 端src/App.vue):

<script setup>
import { invoke } from '@tauri-apps/api/tauri'
import { open } from '@tauri-apps/plugin-dialog'
import { ref } from 'vue'

const files = ref([])
const folderPath = ref('')

async function selectFolder() {
// 打开文件夹选择对话框
const selected = await open({
directory: true,
multiple: false,
title: '选择一个文件夹'
})

if (selected) {
folderPath.value = selected
// 调用 Rust 读取目录内容
const result = await invoke('list_dir', { path: selected })
files.value = result
}
}
</script>

<template>
<div class="container">
<h2>📁 文件浏览器</h2>
<button @click="selectFolder">选择文件夹</button>

<div v-if="folderPath" class="result">
  <p>当前路径:{{ folderPath }}</p>
  <ul>
    <li v-for="file in files" :key="file">
      📄 {{ file }}

    </li>
  </ul>
</div>
</div>
</template>

<style scoped>
.container { padding: 20px; max-width: 600px; margin: 0 auto; }
button { padding: 8px 16px; background: #42b883; color: white; border: none; border-radius: 4px; cursor: pointer; margin: 10px 0; }
.result { margin-top: 20px; text-align: left; }
ul { list-style: none; padding: 0; }
li { padding: 8px; border-bottom: 1px solid #eee; }
</style>

Rust 端src-tauri/src/main.rs):

use tauri::Command;
use std::fs;

#[tauri::command]
fn list_dir(path: String) -> Result<Vec<String>, String> {
// 读取目录
let entries = fs::read_dir(&path)
    .map_err(|e| e.to_string())?;

// 提取文件名
let names: Vec<String> = entries
    .filter_map(|e| e.ok())
    .map(|e| e.file_name().to_string_lossy().into_owned())
    .collect();

Ok(names)
}

fn main() {
tauri::Builder::default()
    .invoke_handler(tauri::generate_handler![list_dir])
    .run(tauri::generate_context!())
    .expect("启动出错了");
}

运行结果

点击「选择文件夹」→ 弹出系统对话框 → 选一个文件夹
→ 显示该文件夹下所有文件名

解释
- open({ directory: true }) 打开的是文件夹选择对话框
- Rust 遍历目录,把文件名收集成 Vec 返回
- 前端用 v-for 循环显示列表


项目 3:待办清单桌面版(15 分钟)

目标:做一个待办清单,能添加、完成、删除任务,数据保存到本地文件。

这是三个项目里最「真实」的一个——有增删改查,有数据持久化。

Vue 端src/App.vue):

<script setup>
import { invoke } from '@tauri-apps/api/tauri'
import { ref, onMounted } from 'vue'

const todos = ref([])
const newTask = ref('')

// 页面加载时读取数据
onMounted(async () => {
await loadTodos()
})

async function loadTodos() {
try {
const data = await invoke('load_todos')
todos.value = JSON.parse(data)
} catch (e) {
console.error('读取失败', e)
}
}

async function addTodo() {
if (!newTask.value.trim()) return

const todo = {
id: Date.now(),
text: newTask.value,
done: false
}

todos.value.push(todo)
newTask.value = ''

await saveTodos()
}

async function toggleTodo(id) {
const todo = todos.value.find(t => t.id === id)
if (todo) {
todo.done = !todo.done
await saveTodos()
}
}

async function deleteTodo(id) {
todos.value = todos.value.filter(t => t.id !== id)
await saveTodos()
}

async function saveTodos() {
// 调用 Rust 保存到文件
await invoke('save_todos', {
data: JSON.stringify(todos.value)
})
}
</script>

<template>
<div class="container">
<h2>✅ 待办清单</h2>

<div class="input-row">
  <input v-model="newTask" placeholder="输入新任务..." @keyup.enter="addTodo" />
  <button @click="addTodo">添加</button>
</div>

<ul class="todo-list">
  <li v-for="todo in todos" :key="todo.id" :class="{ done: todo.done }">
    <input type="checkbox" :checked="todo.done" @change="toggleTodo(todo.id)" />
    <span>{{ todo.text }}</span>
    <button class="delete-btn" @click="deleteTodo(todo.id)">删除</button>
  </li>
</ul>

<p class="count">共 {{ todos.length }} 个任务,完成 {{ todos.filter(t => t.done).length }} 个</p>
</div>
</template>

<style scoped>
.container { padding: 20px; max-width: 500px; margin: 0 auto; font-family: sans-serif; }
h2 { text-align: center; color: #333; }
.input-row { display: flex; gap: 10px; margin-bottom: 20px; }
input { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 4px; }
button { padding: 10px 20px; background: #42b883; color: white; border: none; border-radius: 4px; cursor: pointer; }
.todo-list { list-style: none; padding: 0; }
.todo-list li { display: flex; align-items: center; gap: 10px; padding: 10px; border-bottom: 1px solid #eee; }
.todo-list li.done span { text-decoration: line-through; color: #999; }
.delete-btn { background: #e74c3c; padding: 5px 10px; font-size: 12px; }
.count { text-align: center; color: #666; margin-top: 20px; }
</style>

Rust 端src-tauri/src/main.rs):

use tauri::Command;
use std::fs;
use std::path::PathBuf;

fn get_data_path() -> PathBuf {
// 数据保存在用户目录下的 todos.json
let mut path = dirs::data_local_dir().unwrap();
path.push("todos.json");
path
}

#[tauri::command]
fn load_todos() -> Result<String, String> {
let path = get_data_path();
if path.exists() {
    fs::read_to_string(&path).map_err(|e| e.to_string())
} else {
    Ok("[]".to_string())
}
}

#[tauri::command]
fn save_todos(data: String) -> Result<(), String> {
let path = get_data_path();
fs::write(&path, data).map_err(|e| e.to_string())
}

fn main() {
tauri::Builder::default()
    .invoke_handler(tauri::generate_handler![load_todos, save_todos])
    .run(tauri::generate_context!())
    .expect("启动出错了");
}

需要添加 dirs 依赖src-tauri/Cargo.toml):

[dependencies]
dirs = "5"

运行结果

启动后显示空列表
输入「买菜」回车 → 列表多了一项「买菜」
点击复选框 → 「买菜」划线表示完成
点击删除 → 这一项消失
关闭程序再打开 → 数据还在(持久化)

解释
- dirs::data_local_dir() 拿到用户数据目录(~/Library/Application Support/xxx
- 数据以 JSON 格式保存在 todos.json 文件里
- 下次打开时调用 load_todos 读取,数据就回来了


💪 进阶 20 分钟:常见坑 + 性能小贴士

坑 1:Rust 函数名必须用 snake_case

❌ 错误

#[tauri::command]
fn GetFile(path: String) -> String { ... }

// 前端调用
invoke('GetFile', { path: '...' })

✅ 正确

#[tauri::command]
fn get_file(path: String) -> String { ... }

// 前端调用
invoke('get_file', { path: '...' })

Tauri 会自动把 Rust 的 snake_case 转成前端的 camelCase


坑 2:第一次运行卡住,别慌

❌ 错误:第一次 npm run tauri dev 跑了 2 分钟没反应,以为坏了,关掉重装。

✅ 正确
Tauri 第一次要编译整个 Rust 工具链,快的 3 分钟,慢的 10 分钟(取决于电脑和网络)。耐心等,看到 Compiling tauri... 就说明在干活。


坑 3:Windows 下找不到命令

❌ 错误:PowerShell 里输入 npm run tauri dev 报错「命令不存在」。

✅ 正确
Windows 下要用 PowerShell 或 CMD,不能用 Git Bash。如果非要用 Git Bash:

npx tauri dev

坑 4:改了 Rust 代码要重启

❌ 错误:改了 main.rs,窗口里没变化。

✅ 正确
Tauri dev 模式支持热重载,但有时候 Rust 代码改了需要手动停掉再跑:

# 停掉当前的 npm run tauri dev
# 再重新跑
npm run tauri dev

坑 5:权限不够,读不了文件

❌ 错误:Rust 读取 /system/ 路径报错「Permission denied」。

✅ 正确
Tauri 默认有安全沙箱,只能访问有限路径。如果要访问任意文件,需要在 tauri.conf.json 里配置权限:

{
"tauri": {
"security": {
  "dangerousDisableAssetCspModification": false
},
"bundle": {
  "active": true
}
}
}

更具体的文件权限要在 src-tauri/capabilities 目录配置,这里不展开。


性能小贴士:批量操作别频繁调用 invoke

假设你要在前端循环里调用 Rust 100 次:

// ❌ 慢:100 次 IPC 调用
for (const id of ids) {
await invoke('process_item', { id })
}

// ✅ 快:一次调用传数组
await invoke('process_items', { ids })

Rust 处理批量数据比 IPC 开销便宜得多,尽量减少调用次数。


调试技巧:console.log 也能用

Tauri 的 WebView 内置了开发者工具,可以 F12 打开,控制台里能看到 console.log

console.log('调试信息', someVariable)
console.error('错误', error)

也可以在 Rust 端打印:

println!("调试: {:?}", some_value);

终端里会看到输出。


✏️ 练习题

练习 1(2 分钟):改名字

把项目 1 里的打招呼对象从「小明」改成「小红」。

  • 输入:点击「打招呼」按钮
  • 预期输出:显示「你好,小红!欢迎使用 Tauri 🎉」

练习 2(3 分钟):加个判断

在项目 2 里,如果用户没选任何文件夹(点了取消),不要显示空的「当前路径」。

  • 输入:打开对话框后点「取消」
  • 预期输出:不显示任何结果(而不是显示空路径)

练习 3(10 分钟):换个数据源

用项目 2 的「读取目录」功能,但这次读取你电脑上的 ~/Downloads 文件夹(Mac)或 C:\Users\你的用户名\Downloads(Windows),显示下载文件夹里的文件列表。

  • 输入:选择下载文件夹
  • 预期输出:列出下载文件夹里的文件

练习 4(15 分钟):任务串联

把项目 2(读取文件夹)和项目 3(待办清单)串起来:做一个功能,扫描一个文件夹,把文件夹里的文件名自动变成待办事项。

  • 输入:选择一个文件夹
  • 预期输出:文件夹里每个文件变成一条待办任务

练习 5(5 分钟):报错分析

如果前端这样写,会报什么错?

const content = await invoke('read_file', { Path: '/test.txt' })
  • 提示:注意 Rust 端的参数名和前端传的参数名

作业:做一个「剪贴板监控器」桌面工具

需求描述
做一个桌面小工具,监控你的剪贴板(复制的内容),自动记录下来,让你随时查看历史。

功能点
1. 实时监控剪贴板,有新内容自动记录
2. 显示剪贴板历史列表(最多保存 50 条)
3. 点击任意一条,可以「重新复制」到剪贴板
4. 数据保存在本地文件,关闭再打开不丢失

加分项
1. 支持搜索历史记录
2. 窗口最小化到系统托盘

验收标准
- 能跑起来,不报错
- 复制一段文字,能在列表里看到
- 关闭程序再打开,历史记录还在

提交方式:评论区贴代码或 GitHub 链接


📚 总结 + 资源

这一章我们学了 3 个核心点:

  1. Tauri 是什么:用 Rust + Vue3 打包桌面应用,体积小、性能好
  2. IPC 通信:前端用 invoke() 调用 Rust 函数,参数用对象传
  3. 数据持久化:Rust 读写本地文件,实现数据保存

延伸学习资源

互动钩子

你有没有想过「如果能把网页打包成桌面软件就好了」的时刻?是什么场景?评论区聊聊,老粉优先回复!

下一章我们要进入一个更有意思的环节——综合实战:仿掘金 Web 端。学完微前端和桌面端,是时候做一个完整的小项目了。敬请期待!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。