核心目标:理解 Tauri 与前端构建工具之间的契约;掌握 Vue 3/React 的桌面能力接入、事件清理和浏览器兼容;看懂 EMS Simulate 的"loading bundle + FastAPI 托管 Vue"双层前端结构。
前置知识 :已阅读 Part 1,熟悉 Vue 或 React 的组件生命周期。
验证基线:EMS Simulate 5.0.0;Vue 3.5.26、Vue Router 4.5.x、Vite 5.4.21、TypeScript 5.7.3、Tauri JS API 2.11.0、Tauri CLI 2.11.2,Windows 11。最后复核日期:2026-08-26。
🚀 配套实战项目:EMS Simulate(能源管理系统模拟器)为了避免只讲零散 API,本系列统一使用我开发并持续维护的 EMS Simulate 作为贯穿案例:它是一款免费开源的工业协议仿真软件,支持 IEC 60870-5-104、IEC 61850、Modbus TCP/RTU、DL/T 645 等主流协议,可模拟 PCS 储能变流器、BMS 电池管理系统、电表等真实设备,并提供四遥(YC/YX/YK/YT)配置和报文实时查看。结合 Wireshark,读者可以直接观察协议报文,并验证 Tauri 界面、Python 后台、系统能力和安装包之间的完整调用链。
- 📦 GitHub 开源仓库(欢迎 Star ⭐)
- 📖 在线技术文档
- 🏪 Microsoft Store(Windows 10/11 免配置安装)
0. 问题场景:一个 Vue 项目为什么不能直接"套壳"完事
EMS Simulate 原本就有 Vue + FastAPI Web 架构。进入 Tauri 后,绝大多数页面和业务 API 可以复用,但以下能力发生了变化:
- 浏览器下载要变成系统保存对话框;
- 外链应由系统默认浏览器或指定应用打开;
- 一个设备的报文查看器可以成为独立原生窗口;
- 后台地址不再固定为
8991,而由当前 Tauri 实例动态分配; - 关闭窗口意味着需要清理 Python sidecar 和协议服务;
- 开发期的 Vite 地址、内置 loading 页面、生产期 FastAPI 页面不是同一个 origin。
如果组件直接到处写 window.__TAURI__、invoke() 和插件 import,项目很快会出现三类问题:
- 浏览器版无法运行或测试;
- Vue 组件卸载后仍保留系统监听器;
- Tauri API、FastAPI API 和页面状态混在一起,错误难以定位。
本篇的主线是:
text
业务组件
├─ EMS API Client → FastAPI
└─ Desktop Adapter → Tauri IPC / Plugins
1. Tauri 与前端框架的真正契约
1.1 Tauri 不关心你用 Vue 还是 React
Tauri 的 WebView 最终需要的是可加载的 Web 资源。典型生产链路为:
#mermaid-svg-fk1Crt0YqLVLQzUq{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fk1Crt0YqLVLQzUq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fk1Crt0YqLVLQzUq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fk1Crt0YqLVLQzUq .error-icon{fill:#552222;}#mermaid-svg-fk1Crt0YqLVLQzUq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fk1Crt0YqLVLQzUq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fk1Crt0YqLVLQzUq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fk1Crt0YqLVLQzUq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fk1Crt0YqLVLQzUq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fk1Crt0YqLVLQzUq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fk1Crt0YqLVLQzUq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fk1Crt0YqLVLQzUq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fk1Crt0YqLVLQzUq .marker.cross{stroke:#333333;}#mermaid-svg-fk1Crt0YqLVLQzUq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fk1Crt0YqLVLQzUq p{margin:0;}#mermaid-svg-fk1Crt0YqLVLQzUq .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-fk1Crt0YqLVLQzUq .cluster-label text{fill:#333;}#mermaid-svg-fk1Crt0YqLVLQzUq .cluster-label span{color:#333;}#mermaid-svg-fk1Crt0YqLVLQzUq .cluster-label span p{background-color:transparent;}#mermaid-svg-fk1Crt0YqLVLQzUq .label text,#mermaid-svg-fk1Crt0YqLVLQzUq span{fill:#333;color:#333;}#mermaid-svg-fk1Crt0YqLVLQzUq .node rect,#mermaid-svg-fk1Crt0YqLVLQzUq .node circle,#mermaid-svg-fk1Crt0YqLVLQzUq .node ellipse,#mermaid-svg-fk1Crt0YqLVLQzUq .node polygon,#mermaid-svg-fk1Crt0YqLVLQzUq .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fk1Crt0YqLVLQzUq .rough-node .label text,#mermaid-svg-fk1Crt0YqLVLQzUq .node .label text,#mermaid-svg-fk1Crt0YqLVLQzUq .image-shape .label,#mermaid-svg-fk1Crt0YqLVLQzUq .icon-shape .label{text-anchor:middle;}#mermaid-svg-fk1Crt0YqLVLQzUq .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fk1Crt0YqLVLQzUq .rough-node .label,#mermaid-svg-fk1Crt0YqLVLQzUq .node .label,#mermaid-svg-fk1Crt0YqLVLQzUq .image-shape .label,#mermaid-svg-fk1Crt0YqLVLQzUq .icon-shape .label{text-align:center;}#mermaid-svg-fk1Crt0YqLVLQzUq .node.clickable{cursor:pointer;}#mermaid-svg-fk1Crt0YqLVLQzUq .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fk1Crt0YqLVLQzUq .arrowheadPath{fill:#333333;}#mermaid-svg-fk1Crt0YqLVLQzUq .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fk1Crt0YqLVLQzUq .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fk1Crt0YqLVLQzUq .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fk1Crt0YqLVLQzUq .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fk1Crt0YqLVLQzUq .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fk1Crt0YqLVLQzUq .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fk1Crt0YqLVLQzUq .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fk1Crt0YqLVLQzUq .cluster text{fill:#333;}#mermaid-svg-fk1Crt0YqLVLQzUq .cluster span{color:#333;}#mermaid-svg-fk1Crt0YqLVLQzUq div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-fk1Crt0YqLVLQzUq .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fk1Crt0YqLVLQzUq rect.text{fill:none;stroke-width:0;}#mermaid-svg-fk1Crt0YqLVLQzUq .icon-shape,#mermaid-svg-fk1Crt0YqLVLQzUq .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fk1Crt0YqLVLQzUq .icon-shape p,#mermaid-svg-fk1Crt0YqLVLQzUq .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fk1Crt0YqLVLQzUq .icon-shape .label rect,#mermaid-svg-fk1Crt0YqLVLQzUq .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fk1Crt0YqLVLQzUq .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fk1Crt0YqLVLQzUq .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fk1Crt0YqLVLQzUq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Vue/React 源码
Vite build
dist 静态资源
Tauri bundle
系统 WebView
对应配置:
json
{
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:5173",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
}
}
四个字段不能混为一谈:
| 字段 | 生效阶段 | 职责 |
|---|---|---|
beforeDevCommand |
开发 | 启动 Vite 等开发服务器 |
devUrl |
开发 | WebView 加载开发页面的位置 |
beforeBuildCommand |
构建 | 生成生产静态资源 |
frontendDist |
构建/生产 | 打入 Tauri 的静态资源目录 |
1.2 EMS Simulate 为什么不使用常规单层结构
EMS Simulate 当前配置的 frontendDist 是 ./loading,不是 Vue 的 dist:
text
Tauri bundle
└── loading/index.html
Python/PyInstaller sidecar
└── www/
├── index.html
└── assets/
启动时先显示内置 loading 页面,后台 ready 后跳转到动态 http://127.0.0.1:5xxxx。这让 Web 版和桌面版能够复用同一 FastAPI/Vue 交付模型。
它带来的关键约束是:
- 业务页 origin 随实例端口变化;
- 新建窗口必须复用当前
window.location.origin; - CSP 的
connect-src要覆盖动态回环地址; - 前端不能把
8991散落成常量; - 后台重启若换端口,旧页面和 WebSocket 都要迁移;
- localhost 页面获得 Tauri IPC 权限时必须谨慎设计 capability。
1.3 这不是服务器端渲染
FastAPI 返回的是 Vite 构建后的静态 Vue SPA。页面渲染仍发生在 WebView 内,不是 FastAPI 为每个路由生成 HTML。因此 Tauri 官方对 SSR 的警告不能简单套到这个场景,但"服务端前端增加额外安全风险"的提醒仍然成立。
2. 推荐项目结构
2.1 常规 Tauri + Vue/React
text
app/
├── src/ # Vue/React
├── package.json
├── vite.config.ts
└── src-tauri/
├── capabilities/
├── src/lib.rs
├── Cargo.toml
└── tauri.conf.json
2.2 EMS Simulate 的真实结构
text
ems_simulate/
├── front/
│ ├── src/
│ │ ├── api/ # FastAPI 客户端
│ │ ├── components/ # 设备/测点/协议组件
│ │ ├── views/ # 页面
│ │ └── utils/tauri.ts # 桌面能力适配
│ ├── vite.config.ts
│ └── package.json
├── www/ # Vue 构建输出/打包输入
├── src/ # Python + FastAPI
├── src-tauri/
│ ├── loading/index.html # 最早可用 UI
│ ├── src/lib.rs
│ ├── src/backend/
│ └── tauri.conf.json
└── start_back_end.py
loading/index.html 必须保持小而稳定,不应该复制完整 Vue 应用。它只负责:
- 展示启动状态;
- 调用
get_backend_url; - 探测
/api/health/is_backend_ready; - 超时后显示可诊断错误;
- ready 后跳转业务页面。
3. Vue 3 主实现:集中桌面能力
3.1 先检测运行环境
EMS Simulate 使用:
ts
export function isTauri(): boolean {
return !!(window as any).__TAURI_INTERNALS__;
}
能力检测比判断 User-Agent 更可靠。浏览器版没有 Tauri IPC,此时应该走浏览器替代方案,而不是让页面启动就报错。
为了消除 any,可以补充全局类型:
ts
declare global {
interface Window {
__TAURI_INTERNALS__?: unknown;
}
}
export function isTauri(): boolean {
return window.__TAURI_INTERNALS__ !== undefined;
}
3.2 封装 invoke(),但不要停在"字符串转发器"
当前通用封装:
ts
export async function invoke<T>(
cmd: string,
args?: Record<string, unknown>,
): Promise<T> {
if (!isTauri()) throw new Error("当前不在 Tauri 环境");
const { invoke } = await import("@tauri-apps/api/core");
return invoke<T>(cmd, args);
}
它解决了动态 import 和环境判断,但仍允许业务组件传入任意 command 字符串。更稳妥的是再向上封装领域方法:
ts
export interface BackendStatus {
ready: boolean;
baseUrl: string | null;
}
export async function getBackendStatus(): Promise<BackendStatus> {
if (!isTauri()) {
return {
ready: await probeHealth(window.location.origin),
baseUrl: window.location.origin,
};
}
const [ready, baseUrl] = await Promise.all([
invoke<boolean>("is_backend_ready").catch(() => false),
invoke<string>("get_backend_url").catch(() => null),
]);
return { ready, baseUrl };
}
业务组件只知道 getBackendStatus(),不需要知道两个 Rust command 和异常合并规则。
3.3 浏览器下载与系统保存对话框
同一个"导出模型"动作在两个环境下有不同实现:
ts
export async function saveDownload(
blob: Blob,
filename: string,
): Promise<boolean> {
if (isTauri()) {
const { save } = await import("@tauri-apps/plugin-dialog");
const destination = await save({ defaultPath: filename });
if (!destination) return false;
const contents = Array.from(new Uint8Array(await blob.arrayBuffer()));
await invoke("save_file", { path: destination, contents });
return true;
}
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
return true;
}
这里有三个工程点:
- 用户取消 dialog 是正常分支,返回
false,不是异常; - 路径由 dialog 产生,不代表 Rust 可以省略目标目录校验;
- 大文件转为数字数组会有序列化和内存成本,后续应评估二进制响应、临时文件或流式方案。
3.4 独立报文窗口
EMS Simulate 根据设备名计算稳定 label:
ts
const label = `message-${hashDeviceName(deviceName)}`;
const existing = await WebviewWindow.getByLabel(label);
if (existing) {
await existing.show();
await existing.unminimize();
await existing.setFocus();
return;
}
创建新窗口时复用当前 origin:
ts
const baseUrl = `${window.location.origin}${window.location.pathname}`;
new WebviewWindow(label, {
url: `${baseUrl}#/message-view/${encodeURIComponent(deviceName)}`,
title: `报文查看 - ${deviceName}`,
width: 1100,
height: 650,
});
不能写死 http://127.0.0.1:8991,否则动态端口启用后,新窗口会指向错误实例。
3.5 Vue 生命周期中的监听清理
错误做法:
ts
onMounted(async () => {
const appWindow = getCurrentWindow();
await appWindow.onFocusChanged(({ payload }) => {
focused.value = payload;
});
});
页面每次重新挂载都会增加监听器。正确做法是保存 unlisten:
ts
import { onMounted, onUnmounted, ref } from "vue";
import type { UnlistenFn } from "@tauri-apps/api/event";
const focused = ref(false);
let unlistenFocus: UnlistenFn | undefined;
onMounted(async () => {
if (!isTauri()) return;
const { getCurrentWindow } = await import("@tauri-apps/api/window");
unlistenFocus = await getCurrentWindow().onFocusChanged(({ payload }) => {
focused.value = payload;
});
});
onUnmounted(() => {
unlistenFocus?.();
unlistenFocus = undefined;
});
异步注册还存在一个竞态:组件可能在 Promise resolve 前已经卸载。可加 disposed 标记:
ts
let disposed = false;
onMounted(async () => {
const unlisten = await registerListener();
if (disposed) unlisten();
else unlistenFocus = unlisten;
});
onUnmounted(() => {
disposed = true;
unlistenFocus?.();
});
4. React 对照:机制相同,生命周期写法不同
系列不重写一套 React EMS,而是用后台状态面板说明映射关系。
tsx
import { useEffect, useState } from "react";
import type { UnlistenFn } from "@tauri-apps/api/event";
export function BackendStatusPanel() {
const [focused, setFocused] = useState(false);
useEffect(() => {
let disposed = false;
let unlisten: UnlistenFn | undefined;
void (async () => {
if (!isTauri()) return;
const { getCurrentWindow } = await import("@tauri-apps/api/window");
const cleanup = await getCurrentWindow().onFocusChanged(({ payload }) => {
setFocused(payload);
});
if (disposed) cleanup();
else unlisten = cleanup;
})();
return () => {
disposed = true;
unlisten?.();
};
}, []);
return <span>{focused ? "窗口已聚焦" : "窗口未聚焦"}</span>;
}
Vue 与 React 的对应关系:
| 问题 | Vue 3 | React |
|---|---|---|
| 挂载 | onMounted |
useEffect body |
| 卸载 | onUnmounted |
effect cleanup |
| 响应状态 | ref/reactive |
useState/useReducer |
| 共享依赖 | provide/inject、Pinia | Context、Zustand、Redux |
| 异步监听竞态 | disposed 标记 | disposed 标记 |
React Strict Mode 在开发期可能执行"挂载 → 清理 → 再挂载"来暴露副作用问题。如果监听代码没有幂等 cleanup,开发环境中会更早暴露重复监听。
5. 推荐的前端分层
随着功能增长,可将单个 tauri.ts 拆分为:
text
front/src/platform/
├── environment.ts # isTauri
├── commands.ts # 后台状态、重启
├── windows.ts # 主窗口、报文窗口
├── files.ts # dialog、保存、打开目录
├── opener.ts # 外链策略
├── events.ts # 监听注册与清理
├── errors.ts # 统一错误映射
└── index.ts # 对业务公开的稳定接口
业务层的依赖方向:
#mermaid-svg-rTAFCf44SRtcm8sN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-rTAFCf44SRtcm8sN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-rTAFCf44SRtcm8sN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-rTAFCf44SRtcm8sN .error-icon{fill:#552222;}#mermaid-svg-rTAFCf44SRtcm8sN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-rTAFCf44SRtcm8sN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-rTAFCf44SRtcm8sN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-rTAFCf44SRtcm8sN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-rTAFCf44SRtcm8sN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-rTAFCf44SRtcm8sN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-rTAFCf44SRtcm8sN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-rTAFCf44SRtcm8sN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-rTAFCf44SRtcm8sN .marker.cross{stroke:#333333;}#mermaid-svg-rTAFCf44SRtcm8sN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-rTAFCf44SRtcm8sN p{margin:0;}#mermaid-svg-rTAFCf44SRtcm8sN .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-rTAFCf44SRtcm8sN .cluster-label text{fill:#333;}#mermaid-svg-rTAFCf44SRtcm8sN .cluster-label span{color:#333;}#mermaid-svg-rTAFCf44SRtcm8sN .cluster-label span p{background-color:transparent;}#mermaid-svg-rTAFCf44SRtcm8sN .label text,#mermaid-svg-rTAFCf44SRtcm8sN span{fill:#333;color:#333;}#mermaid-svg-rTAFCf44SRtcm8sN .node rect,#mermaid-svg-rTAFCf44SRtcm8sN .node circle,#mermaid-svg-rTAFCf44SRtcm8sN .node ellipse,#mermaid-svg-rTAFCf44SRtcm8sN .node polygon,#mermaid-svg-rTAFCf44SRtcm8sN .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-rTAFCf44SRtcm8sN .rough-node .label text,#mermaid-svg-rTAFCf44SRtcm8sN .node .label text,#mermaid-svg-rTAFCf44SRtcm8sN .image-shape .label,#mermaid-svg-rTAFCf44SRtcm8sN .icon-shape .label{text-anchor:middle;}#mermaid-svg-rTAFCf44SRtcm8sN .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-rTAFCf44SRtcm8sN .rough-node .label,#mermaid-svg-rTAFCf44SRtcm8sN .node .label,#mermaid-svg-rTAFCf44SRtcm8sN .image-shape .label,#mermaid-svg-rTAFCf44SRtcm8sN .icon-shape .label{text-align:center;}#mermaid-svg-rTAFCf44SRtcm8sN .node.clickable{cursor:pointer;}#mermaid-svg-rTAFCf44SRtcm8sN .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-rTAFCf44SRtcm8sN .arrowheadPath{fill:#333333;}#mermaid-svg-rTAFCf44SRtcm8sN .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-rTAFCf44SRtcm8sN .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-rTAFCf44SRtcm8sN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rTAFCf44SRtcm8sN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-rTAFCf44SRtcm8sN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rTAFCf44SRtcm8sN .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-rTAFCf44SRtcm8sN .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-rTAFCf44SRtcm8sN .cluster text{fill:#333;}#mermaid-svg-rTAFCf44SRtcm8sN .cluster span{color:#333;}#mermaid-svg-rTAFCf44SRtcm8sN div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-rTAFCf44SRtcm8sN .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-rTAFCf44SRtcm8sN rect.text{fill:none;stroke-width:0;}#mermaid-svg-rTAFCf44SRtcm8sN .icon-shape,#mermaid-svg-rTAFCf44SRtcm8sN .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rTAFCf44SRtcm8sN .icon-shape p,#mermaid-svg-rTAFCf44SRtcm8sN .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-rTAFCf44SRtcm8sN .icon-shape .label rect,#mermaid-svg-rTAFCf44SRtcm8sN .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rTAFCf44SRtcm8sN .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-rTAFCf44SRtcm8sN .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-rTAFCf44SRtcm8sN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Vue Views / Components
EMS Use Cases
FastAPI Client
Desktop Platform Adapter
@tauri-apps API / Plugins
规则:
- 设备 CRUD、测点和协议操作进入 FastAPI client;
- 窗口、保存对话框、外链、进程状态进入 platform adapter;
- 业务组件不判断
window.__TAURI__; - adapter 为浏览器模式提供明确替代或
Unsupported错误; - 不用"通用 invoke 字符串"作为业务层公开 API。
6. 路由、origin 与新窗口
6.1 Hash Router 的实际价值
EMS Simulate 报文窗口使用:
text
http://127.0.0.1:5xxxx/#/message-view/<device>
Hash 后的路由不会作为服务器路径发送给 FastAPI,因此静态服务器无需为每个 Vue route 配置 fallback。若使用 history 模式:
text
http://127.0.0.1:5xxxx/message-view/<device>
用户刷新时 FastAPI 必须把未知前端路径回退到 index.html,否则返回 404。
6.2 origin 不能假定稳定
origin 由 scheme、host、port 共同组成。动态端口意味着每次应用冷启动可能不同。因此:
- 当前实例内部用
window.location.origin; - loading 页从 Rust 获取真实 base URL;
- 不把 base URL编译进 Vue bundle;
- 后台重启尽量复用原端口;
- WebSocket 重连使用统一 runtime config。
6.3 CSP 不是"能连上就算正确"
为了支持动态 localhost,项目当前 CSP 放行回环地址通配端口。文章示例必须同时说明:
- 这是当前架构要求,不是可以复制到所有项目的默认配置;
unsafe-inline、unsafe-eval应逐项查明来源;- localhost 页面是否能调用 Tauri IPC 是独立安全边界;
- 动态端口不提供身份认证。
7. 失败实验与根因
7.1 release 白屏
| 证据 | 可能根因 |
|---|---|
| loading 都没显示 | frontendDist、WebView、CSP、入口路径 |
| loading 一直转 | sidecar、动态端口、health、IPC |
| 跳转后 404 | PyInstaller 未包含 www 或静态挂载错误 |
| HTML 有、JS 404 | Vite base/资源路径错误 |
| Vue 挂载后空白 | 前端运行时异常或路由错误 |
7.2 报文窗口打开旧实例
复现:把窗口 URL 写死为 8991,主后台运行在动态端口。
根因:窗口没有继承当前 origin。修复为基于 window.location 构造,或由统一 runtime config 提供。
7.3 一次事件触发多次回调
复现:多次进入/退出某个视图,每次 mounted 注册监听但不 unlisten。
根因:系统事件监听器生命周期长于组件。修复是配对 cleanup,并处理异步注册竞态。
7.4 浏览器模式启动即报错
复现:在模块顶层静态创建 Tauri window 或立即调用 plugin。
根因:模块加载时没有能力检测。解决方式是延迟动态 import,并通过 adapter 选择浏览器实现。
7.5 用户取消保存却显示失败
取消 dialog 返回 null/undefined 属于正常业务分支。不要将它包装成异常,也不要继续调用 save_file。
8. 测试与验收
8.1 浏览器 adapter 单元测试
ts
it("uses browser download outside Tauri", async () => {
// mock URL.createObjectURL 与 anchor.click
const saved = await saveDownload(new Blob(["ems"]), "model.json");
expect(saved).toBe(true);
});
8.2 监听 cleanup 测试
测试重点不是 Tauri 内部是否发事件,而是组件卸载是否调用返回的 UnlistenFn。
8.3 本篇验收清单
- 能解释
devUrl与frontendDist; - 能解释 EMS loading 和 Vue 页面为什么分开;
- 浏览器和 Tauri 模式均能启动;
- Vue/React 监听器在卸载后清理;
- 报文窗口复用当前动态 origin;
- 同一设备不会创建重复窗口;
- 保存取消是正常分支;
- 业务组件不直接散落 Tauri command 字符串;
- release 的 loading、Vue assets、REST、WebSocket 均验证。
9. 常见误区
9.1 "Tauri 支持 Vue,所以所有 Vue API 都会自动适配桌面"
Tauri 只负责承载 Web 资源。下载、外链、多窗口、关闭行为等仍需显式设计。
9.2 "React/Vue 状态可以替代 Rust 状态"
前端状态属于窗口/WebView;sidecar 句柄、动态端口和应用退出状态属于 Rust 进程,两者生命周期不同。
9.3 "所有请求都走 invoke 更安全"
不一定。EMS 业务已有成熟 FastAPI API。把大量 CRUD 机械转成 Rust command 只会复制协议。安全来自清晰边界、鉴权和校验,不来自 API 名称。
9.4 "动态 import 解决所有浏览器兼容问题"
动态 import 只延迟加载。调用者仍需定义浏览器替代行为、错误类型和测试。
9.5 "localhost 与 Tauri bundle 是同一种前端来源"
不是。origin、CSP、网络攻击面、启动依赖和离线行为都不同。
10. 本篇小结与官方资料
EMS Simulate 前端的核心边界是:
text
Vue 业务组件
├─ FastAPI client:设备、测点、协议、报文
└─ Desktop adapter:窗口、文件、外链、后台进程状态
Vue 和 React 都能成为 Tauri 前台,真正影响工程质量的不是框架名称,而是:
- 前端构建与 Tauri 配置是否一致;
- 浏览器/桌面双模式是否显式;
- 监听器是否跟随组件清理;
- origin、路由和新窗口是否适配动态后台;
- 高权限插件是否被业务级 adapter 收口。
官方资料:
- Frontend Configuration
- Create a Project
- Project Structure
- Configuration Reference
- WebviewWindow JavaScript API
- Calling Rust from the Frontend
- Capabilities
下一篇将专门拆解 Command、Event、Channel 和 Rust 状态,并把后台启动、IEC 61850 进度、错误协议和取消任务统一成可测试契约。