第8章 8.4 桌面端:Tauri 入门
上一章我们学会了用 qiankun 把多个 Vue3 子应用「拼」在一起,做了一个微前端架构的管理后台。听起来挺酷的——但你有没有想过,万一产品经理说「这个管理后台能不能做成桌面软件,让员工不用开浏览器就能用」?
好问题。这就是今天要聊的事儿。
🎯 开场 3 分钟:为什么要学这个?
先说个真实场景。我有个朋友在小公司当技术负责人,他们做了个内部工具网站,方便员工查数据、做审批。结果:
- 员工A:「网址是什么来着?」、「浏览器打不开,换个电脑就不会用了」
- 员工B:「我习惯桌面软件,双击就能用,不要网页」
- 老板:「能不能打包成桌面应用?我不想看浏览器地址栏那一堆英文」
网页开发很香,但桌面软件在某些场景下确实更方便。双击即用、任务栏固定、系统级通知——这些都是网页给不了的。
痛点就一个:怎么用 Vue3 的技术栈,直接打包成桌面软件?
答案是 Tauri。学完这章,你能:
- 用 Vue3 写前端界面
- 用 Rust 写后端逻辑
- 打包成一\n\n
\n\n
\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?
我直接说结论:
- 体积小:用户下载你软件,不用等半天
- 内存省:员工电脑配置差,Electron 卡成PPT,Tauri 流畅
- 更安全:Rust 语言天然内存安全,不容易被病毒利用
- 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>
发生了什么:
- 前端点击按钮,调用
invoke('read_file', { path: '...' }) - Tauri 把请求发给 Rust 后端
- Rust 执行
std::fs::read_to_string读取文件 - 结果返回给前端,
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 个核心点:
- Tauri 是什么:用 Rust + Vue3 打包桌面应用,体积小、性能好
- IPC 通信:前端用
invoke()调用 Rust 函数,参数用对象传 - 数据持久化:Rust 读写本地文件,实现数据保存
延伸学习资源:
- Tauri 官方文档(中文版)
- Tauri + Vue3 实战教程(官方给的模板)
- 《Rust 编程之道》(想深入 Rust 后端可以看看)
互动钩子:
你有没有想过「如果能把网页打包成桌面软件就好了」的时刻?是什么场景?评论区聊聊,老粉优先回复!
下一章我们要进入一个更有意思的环节——综合实战:仿掘金 Web 端。学完微前端和桌面端,是时候做一个完整的小项目了。敬请期待!

评论(0)