Tauri 2.x + Vue 3 桌面应用开发实战:从踩坑到完美落地

项目背景与架构设计
为什么选择 Tauri?
我需要开发一个 Windows 桌面收银系统,核心需求是:
- 所有静态资源从本地加载,不走网络,节省带宽流量开销
- 业务接口仍然走网络在线 API
- 打印机程序必须持续可用,达到基本 100% 的可靠性
- 消息推送不能因页面挂起而中断
基于这些需求,我选择了 Tauri 作为桌面框架。它让前端资源完全本地化,同时保留了 Web 开发的灵活性。
架构与常规 Tauri 应用的区别
常规 Tauri 应用通常将前后端紧密耦合,我的做法是:
-
前端项目独立开发:Vben Admin 项目单独维护,打包后放入 Tauri 的 dist 目录
-
Tauri 只作为壳:负责系统集成、打印服务管理、WebSocket 长连接
-
打印服务独立进程:Go 编写的 HTTP 服务,由 Tauri 管理生命周期
Tauri 壳(Rust 后端)
├── WebSocket 管理器(自动重连、心跳、事件转发)
├── 打印服务管理(启动、健康检查、异常重启)
├── 托盘图标 + 窗口管理
└── 下载监听
WebView 容器(加载本地 dist)
└── Vue 3 应用(Vben Admin)
print-service.exe(Go HTTP 服务,独立进程)
核心设计原则:
- 静态资源走本地 -> 零带宽消耗
- API 请求走网络 -> 数据实时在线
- 关键服务独立进程 -> 互不影响
- Rust 管理长连接 -> 避免浏览器冻结
一、Tauri 项目配置
1.1 目录结构
my-tauri-app/
├── src-tauri/
│ ├── src/
│ │ ├── main.rs
│ │ └── wss.rs
│ ├── capabilities/
│ │ └── default.json
│ ├── icons/
│ ├── print-service.exe
│ └── tauri.conf.json
├── dist/
└── package.json
1.2 tauri.conf.json
json
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "MyApp",
"version": "0.1.0",
"identifier": "com.my.app",
"build": {
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "我的应用",
"width": 1200,
"height": 800,
"resizable": true,
"url": "index.html",
"alwaysOnTop": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "nsis",
"icon": ["icons/icon.ico"],
"resources": ["print-service.exe"],
"windows": {
"nsis": {
"languages": ["SimpChinese"]
}
}
}
}
注意:不要在 tauri.conf.json 中配置 trayIcon,否则会与 Rust 代码创建的托盘重复,导致出现两个图标。
1.3 capabilities/default.json
没有正确配置权限,前端 listen 会报 event.listen not allowed。
json
{
"identifier": "default",
"description": "默认能力",
"windows": ["main"],
"permissions": [
"core:default",
"core:event:allow-listen",
"core:event:allow-unlisten"
]
}
1.4 Cargo.toml 关键依赖
toml
[dependencies]
tauri = { version = "2.5.0", features = ["tray-icon"] }
tokio = { version = "1.0", features = ["full"] }
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
futures-util = "0.3"
base64 = "0.21"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = "0.4"
dirs = "6.0.0"
notify = "8.0.0"
windows = { version = "0.58.0", features = ["Win32_UI_WindowsAndMessaging"] }
window-vibrancy = "0.6.0"
二、WebSocket 服务:从浏览器迁移到 Rust
2.1 为什么要迁移
浏览器的 WebSocket 连接存在一个问题:页面被挂起或进入后台时,WebSocket 连接可能被中断。对于收银系统来说,订单消息丢失是不可接受的。
解决方案:将 WebSocket 连接移到 Rust 后端,通过 Tauri 事件系统转发给前端。这样即使前端页面被挂起,Rust 后端仍然保持连接。
2.2 Rust WebSocket 管理器(wss.rs)
rust
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use tokio_tungstenite::{connect_async, tungstenite::Message};
use tauri::Emitter;
pub struct WSSManager {
config: WSSConfig,
token: Arc<Mutex<Option<String>>>,
is_running: Arc<Mutex<bool>>,
is_connected: Arc<Mutex<bool>>,
reconnect_delay: Arc<Mutex<u64>>,
sender: Arc<Mutex<Option<tokio::sync::mpsc::UnboundedSender<String>>>>,
}
impl WSSManager {
pub fn connect(&self, app_handle: tauri::AppHandle, token: String) {
if *self.is_running.lock().unwrap() {
*self.is_running.lock().unwrap() = false;
std::thread::sleep(std::time::Duration::from_millis(500));
}
*self.is_running.lock().unwrap() = true;
}
async fn handle_connection(...) -> Result<(), String> {
loop {
tokio::select! {
msg = ws_stream.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
let preview = text.chars().take(200).collect::<String>();
let _ = app_handle.emit("wss-message", &text);
}
Some(Ok(Message::Binary(data))) => {
use base64::Engine as _;
let base64_str = base64::engine::general_purpose::STANDARD.encode(&data);
let _ = app_handle.emit("wss-message-binary", &serde_json::json!({
"data": base64_str,
"length": data.len()
}));
}
Some(Ok(Message::Ping(_))) => {
let _ = ws_stream.send(Message::Pong(vec![])).await;
}
Some(Ok(Message::Close(_))) => {
return Err("连接已关闭".to_string());
}
None => {
return Err("连接已断开".to_string());
}
}
}
}
}
}
}
2.3 自动重连与心跳保活
Rust 后端负责维护 WebSocket 连接的稳定性:
- 自动重连:断开后指数退避重连(1s -> 2s -> 4s -> ... -> 30s)
- 心跳保活:12 秒发送一次 Ping,防止连接被服务端断开
- 事件转发:所有消息通过 Tauri 事件系统原封不动转发给前端
2.4 发送事件给前端
rust
app_handle.emit("wss-status", &serde_json::json!({
"status": "connected",
"message": "已连接"
}))?;
app_handle.emit("wss-message", &text)?;
Tauri 2.x 使用 emit,不是 emit_all。
2.5 前端接收事件
typescript
import { listen } from '@tauri-apps/api/event';
await listen('wss-status', (event) => {
console.log('状态:', event.payload);
});
await listen('wss-message', (event) => {
console.log('收到消息:', event.payload);
});
三、打印服务:100% 可靠性保障
3.1 打印服务管理
打印服务是一个独立的 Go HTTP 服务,由 Tauri 管理其生命周期。
启动时:
- 清理残留的打印服务进程
- 启动新的打印服务进程
- 记录 PID 用于后续管理
运行时:
- 每 10 秒健康检查(进程存在 + 端口可访问)
- 发现异常立即重启(不等待重试)
退出时:
- 优雅关闭打印服务
- 强制清理残留进程
- 刷新系统托盘
3.2 进程管理代码
rust
fn cleanup_print_service(force: bool) {
let _ = Command::new("taskkill")
.args(&["/im", "print-service.exe"])
.output();
std::thread::sleep(std::time::Duration::from_millis(800));
let check_output = Command::new("tasklist")
.args(&["/fi", "imagename eq print-service.exe", "/nh"])
.output()
.unwrap_or_else(|_| {
return std::process::Output {
stdout: Vec::new(),
stderr: Vec::new(),
status: std::process::ExitStatus::default(),
};
});
let stdout = String::from_utf8_lossy(&check_output.stdout);
let is_running = stdout.contains("print-service.exe");
if is_running && force {
let _ = Command::new("taskkill")
.args(&["/f", "/im", "print-service.exe"])
.output();
}
unsafe {
let _ = SendNotifyMessageW(HWND_BROADCAST, WM_TIMER, None, None);
}
}
fn health_check_loop(app_handle: tauri::AppHandle) {
std::thread::spawn(move || {
loop {
std::thread::sleep(std::time::Duration::from_secs(10));
if !check_printer_health() {
log_message("检测到异常,立即重启");
cleanup_print_service(true);
std::thread::sleep(std::time::Duration::from_millis(1000));
start_print_service(&app_handle);
}
}
});
}
3.3 可靠性保障
| 保障措施 | 实现方式 |
|---|---|
| 进程监控 | 每 10 秒检查进程是否存在 |
| 端口监控 | 检查 3021 端口是否可访问 |
| 异常重启 | 发现异常立即重启,不等待 |
| 双重清理 | 启动和退出时都清理残留进程 |
| 托盘刷新 | 强制刷新避免僵尸图标 |
四、前端 Tauri 适配
4.1 Tauri 环境检测
Tauri 2.x 和 1.x 检测方式不同:
typescript
// Tauri 1.x 方式(Tauri 2.x 中无效)
function isTauri() {
return !!(window as any)?.__TAURI__;
}
// Tauri 2.x 正确方式
function isTauri() {
return !!(window as any)?.__TAURI_INTERNALS__;
}
4.2 invoke 与 listen 的混合方案
在实践中,@tauri-apps/api 的 invoke 在某些场景下不可用,而 listen 可用。因此采用:
- invoke:使用原生方式(window.TAURI_INTERNALS.invoke)
- listen:使用官方包(@tauri-apps/api/event)
typescript
import { listen } from '@tauri-apps/api/event';
async function invokeTauri(cmd: string, args?: any): Promise<any> {
const internals = (window as any).__TAURI_INTERNALS__;
if (!internals || typeof internals.invoke !== 'function') {
throw new Error('Tauri invoke 不可用');
}
return internals.invoke(cmd, args || {});
}
4.3 完整的 WebSocket 客户端
typescript
export class WebSocketClient {
private useTauriMode: boolean = false;
constructor(url: string = 'wss://your-server.com/ws') {
this.useTauriMode = isTauri();
if (this.useTauriMode) {
this.setupTauriListeners();
} else {
this.setupWebSocket();
}
}
private async setupTauriListeners(): Promise<void> {
try {
await listen('wss-status', (event) => {
const { status, message } = event.payload;
this.status = status;
});
await listen('wss-message', (event) => {
const rawData = event.payload;
this.handleIncomingMessage(rawData);
});
await invokeTauri('connect_wss', {
request: { token: localStorage.getItem('token') || '' }
});
} catch (error) {
console.error('Tauri 事件订阅失败', error);
}
}
}
五、托盘图标:避免重复和僵尸
5.1 避免重复图标
错误做法:在 tauri.conf.json 中配置 trayIcon,同时在 Rust 中创建托盘。
json
正确做法:只在 Rust 代码中创建托盘。
5.2 清除僵尸图标
进程被强制结束时,托盘图标可能残留。在清理进程中强制刷新:
rust
unsafe {
let _ = SendNotifyMessageW(HWND_BROADCAST, WM_TIMER, None, None);
}
六、踩坑记录与解决方案
坑1:TLS 支持未编译
错误:URL error: TLS support not compiled in
解决:在 Cargo.toml 中启用 native-tls 特性
toml
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
坑2:emit_all 不存在
错误:no method named emit_all
解决:Tauri 2.x 改用 emit
rust
// Tauri 1.x
app_handle.emit_all("event", &data)?;
// Tauri 2.x
app_handle.emit("event", &data)?;
坑3:listen 权限被拒绝
错误:event.listen not allowed
解决:在 capabilities/default.json 中配置权限
json
"permissions": [
"core:event:allow-listen",
"core:event:allow-unlisten"
]
坑4:UTF-8 字符截断 panic
错误:byte index 200 is not a char boundary
解决:使用 .chars().take(n) 安全截断
rust
// 危险方式
&text[..text.len().min(200)]
// 安全方式
let preview = text.chars().take(200).collect::<String>();
坑5:base64::encode 已废弃
错误:use of deprecated function base64::encode
解决:
rust
use base64::Engine as _;
let base64_str = base64::engine::general_purpose::STANDARD.encode(&data);
坑6:window.TAURI 是 undefined
解决:Tauri 2.x 使用 window.TAURI_INTERNALS
typescript
function isTauri() {
return !!(window as any)?.__TAURI_INTERNALS__;
}
七、打包与发布
bash
pnpm tauri build
安装包位置:src-tauri/target/release/bundle/nsis/MyApp_0.1.0_x64-setup.exe
八、最终成果
| 需求 | 实现方式 | 结果 |
|---|---|---|
| 静态资源本地加载 | Tauri 加载本地 dist | 零带宽消耗 |
| 在线 API | 前端 axios 请求 | 数据实时在线 |
| 打印服务 100% 可靠 | 进程管理 + 健康检查 + 立即重启 | 100% 可靠 |
| WebSocket 100% 可靠 | Rust 后端管理 + 事件转发 | 100% 可靠 |
| 应用不被冻结 | alwaysOnTop: true + Rust 后台运行 | 始终活跃 |
| 无僵尸图标 | 配置清理 + 强制刷新 | 已修复 |
| 安全退出 | 清理所有资源 | 已实现 |
经验总结
- Tauri 2.x 和 1.x 差异巨大:window.TAURI_INTERNALS 是 2.x 的关键
- 权限配置必须做:capabilities/default.json 是 listen 工作的前提
- 关键服务要独立管理:打印服务作为独立进程,由 Tauri 管理生命周期
- 健康检查要立即响应:发现异常立即重启,不等待
- WebSocket 要移到后端:避免浏览器挂起影响连接
- 托盘只在一个地方创建:避免重复图标