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. 托盘只在一个地方创建:避免重复图标
相关推荐
qq_570398571 小时前
Three.js基础使用-案例
开发语言·javascript·ecmascript
右耳朵猫AI1 小时前
Node.js周刊2026W36 | NestJS 12发布、Remix 3 RC、pnpm 12 Rust重写、Node.js 26.8.0
开发语言·rust·node.js
AINative软件工程1 小时前
编程
前端·llm
晴天161 小时前
peerDependencies 全面解析:前端依赖生态的核心机制与实战指南
前端·npm
I'mChloe1 小时前
群晖部署 image-matting:把人像去背景做成一个随开随用的 Web 工具
android·前端
程序员蜡笔熊1 小时前
Vite 8 换芯实测:Rolldown 替掉双引擎,构建快 3.19 倍
前端·javascript·vue
右耳朵猫AI1 小时前
Web前端周刊2026W36 | pnpm 12 Rust 重写、Remix 3 RC、Node.js 26.8.0、htmx 4.0 大版本
前端·rust·node.js
平头哥~2 小时前
Day 21 | animation 与 keyframes:把动画从 A 到 B 变成多点编排
前端·css·css3·css学习
mldong3 小时前
Go 开发者也有自己的轻量工作流引擎了:go get 一行,5 分钟跑通一条审批流
后端·go