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

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

项目背景与架构设计

为什么选择 Tauri?

我需要开发一个 Windows 桌面收银系统,核心需求是:

  1. 所有静态资源从本地加载,不走网络,节省带宽流量开销
  2. 业务接口仍然走网络在线 API
  3. 打印机程序必须持续可用,达到基本 100% 的可靠性
  4. 消息推送不能因页面挂起而中断

基于这些需求,我选择了 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 管理其生命周期。

启动时:

  1. 清理残留的打印服务进程
  2. 启动新的打印服务进程
  3. 记录 PID 用于后续管理

运行时:

  1. 每 10 秒健康检查(进程存在 + 端口可访问)
  2. 发现异常立即重启(不等待重试)

退出时:

  1. 优雅关闭打印服务
  2. 强制清理残留进程
  3. 刷新系统托盘

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 后台运行 始终活跃
无僵尸图标 配置清理 + 强制刷新 已修复
安全退出 清理所有资源 已实现

经验总结

  1. Tauri 2.x 和 1.x 差异巨大:window.TAURI_INTERNALS 是 2.x 的关键
  2. 权限配置必须做:capabilities/default.json 是 listen 工作的前提
  3. 关键服务要独立管理:打印服务作为独立进程,由 Tauri 管理生命周期
  4. 健康检查要立即响应:发现异常立即重启,不等待
  5. WebSocket 要移到后端:避免浏览器挂起影响连接
  6. 托盘只在一个地方创建:避免重复图标
相关推荐
剪刀石头布啊1 天前
gird网格布局
前端
瞬时出道1 天前
AI 回答为什么能一个字一个字蹦出来?前端搞懂 SSE 这一篇就够了
vue.js
啃火龙果的兔子1 天前
Google Chrome(谷歌浏览器)常用快捷键
前端·chrome
剪刀石头布啊1 天前
js真的是单线程实现异步并发么?
前端
剪刀石头布啊1 天前
js中改变this指向的操作有哪些
前端
剪刀石头布啊1 天前
vue、react 列表中使用 index 作为 key 的话,修改某一个元素后会发生什么
前端
方方洛1 天前
glowglow:语言无关的语法高亮器是怎么实现的
前端·前端框架
java_nnnn1 天前
JavaEE进阶-HTML基础认识
前端·java-ee·html
鬼手点金1 天前
Claude Code示范案例-常用快捷命令
java·服务器·前端·计算机视觉·前向传播
CopyCode1 天前
我排查了一下午,发现项目打包体积翻倍的元凶是它
前端·性能优化