Tauri 2.x 系列(二):项目结构与前端框架——Vue、React 如何成为桌面前台

核心目标:理解 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 后台、系统能力和安装包之间的完整调用链。


0. 问题场景:一个 Vue 项目为什么不能直接"套壳"完事

EMS Simulate 原本就有 Vue + FastAPI Web 架构。进入 Tauri 后,绝大多数页面和业务 API 可以复用,但以下能力发生了变化:

  • 浏览器下载要变成系统保存对话框;
  • 外链应由系统默认浏览器或指定应用打开;
  • 一个设备的报文查看器可以成为独立原生窗口;
  • 后台地址不再固定为 8991,而由当前 Tauri 实例动态分配;
  • 关闭窗口意味着需要清理 Python sidecar 和协议服务;
  • 开发期的 Vite 地址、内置 loading 页面、生产期 FastAPI 页面不是同一个 origin。

如果组件直接到处写 window.__TAURI__invoke() 和插件 import,项目很快会出现三类问题:

  1. 浏览器版无法运行或测试;
  2. Vue 组件卸载后仍保留系统监听器;
  3. 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;
}

这里有三个工程点:

  1. 用户取消 dialog 是正常分支,返回 false,不是异常;
  2. 路径由 dialog 产生,不代表 Rust 可以省略目标目录校验;
  3. 大文件转为数字数组会有序列化和内存成本,后续应评估二进制响应、临时文件或流式方案。

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-inlineunsafe-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 本篇验收清单

  • 能解释 devUrlfrontendDist
  • 能解释 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 收口。

官方资料:

下一篇将专门拆解 Command、Event、Channel 和 Rust 状态,并把后台启动、IEC 61850 进度、错误协议和取消任务统一成可测试契约。

相关推荐
@atweiwei21 小时前
用 Rust 构建 Agent 应用的高性能框架:langchainrust 架构全景
人工智能·架构·rust·langchain·llm·agent·ai编程
Source.Liu1 天前
【Dioxus】Dioxus CLI (dx) 命令笔记
笔记·rust·dioxus
梦醒沉醉1 天前
1、Rust参考手册——记法和词法结构
rust
SomeB1oody2 天前
【RustyML入门】6.3. 并行归约
开发语言·后端·机器学习·rust·教程
红尘散仙2 天前
TypeScript WIT Guest:类型约束与 ComponentizeJS 工程化
rust·typescript·webassembly
小灰灰搞电子2 天前
Rust+Slint 实现的“DNA双螺旋”加载动画源码分享
后端·rust·slint·加载动画
红尘散仙2 天前
从 dsh 的插件系统出发:为什么我要探索 WIT 与 Wasm Component Model
rust·typescript·webassembly