编程语言

TypeScript教程(七)产品实战 —— 桌面应用(Electron / Tauri + Vue)

TypeScript 07:产品实战 —— 桌面应用(Electron / Tauri + Vue)

TS 编译成 JS,而 JS 可以借助 Electron 或 Tauri 运行在桌面上。你每天用的 VS Code、Slack、Postman、
Figma 桌面版、飞书/钉钉的部分模块都是这么做的。好处:一套 Vue 界面 + 一套 TS 逻辑 → Windows / macOS / Linux 三端。

1. 技术选型

方案 界面 "后端"(系统能力) 安装包大小 适合
Electron Chromium + Vue Node.js(纯 TS) 80~150 MB 功能复杂、要用大量 npm 包、团队只会 TS
Tauri 2 系统 WebView + Vue Rust(可很少写) 3~10 MB 追求体积/内存,工具类应用;还能出 iOS/Android
Wails(Go,见 Go 08 篇) 系统 WebView + Vue Go 8~15 MB 会 Go 的团队
C# WPF / WinUI / MAUI XAML .NET — 只做 Windows 或企业内网

核心概念(两者相同):

┌──────────── 渲染进程(Vue 页面,受限沙箱) ────────────┐
│   window.api.listRecords("2026-11")                     │
└──────────────────────────┬──────────────────────────────┘
                           │ IPC(进程间通信,类似调用本地 HTTP 接口)
┌──────────────────────────▼──────────────────────────────┐
│ 主进程(Node.js / Rust):读写文件、数据库、系统托盘、通知 │
└─────────────────────────────────────────────────────────┘

类比 Django:渲染进程 = 浏览器页面;主进程 = 后端 view;IPC = 接口。只不过全部跑在用户本机。

2. Electron + Vue:本地记账桌面版

功能:本地存储记账数据、从 CSV 导入、导出 CSV、菜单快捷键、系统通知。

2.1 创建项目

npm create @quick-start/electron@latest accounting-desktop -- --template vue-ts
cd accounting-desktop && npm install
npm run dev            # 启动开发模式(热更新)
src/
├── main/        # 主进程(Node.js)
├── preload/     # 预加载脚本:在两者之间"搭桥"
├── renderer/    # 渲染进程(就是一个 Vue 3 项目)
└── shared/      # 自己新建:主进程与页面共享的类型

2.2 先定义"接口契约"(共享类型)

// src/shared/api.ts
export interface AccountRecord {
  id: number; type: "income" | "expense"; category: string; amount: number; date: string;
}
export type RecordInput = Omit<AccountRecord, "id">;

export interface DesktopApi {
  listRecords(month: string): Promise<AccountRecord[]>;
  addRecord(input: RecordInput): Promise<AccountRecord>;
  importCsv(): Promise<number>;                   // 返回导入条数,用户取消返回 0
  exportCsv(month: string): Promise<string | null>; // 返回保存路径
  onMenuNewRecord(cb: () => void): () => void;      // 返回取消订阅函数
}

2.3 主进程:数据存储 + IPC 处理

// src/main/store.ts —— 简单起见用 JSON 文件;正式产品可换 better-sqlite3
import { readFile, writeFile } from "node:fs/promises";
import type { AccountRecord, RecordInput } from "../shared/api";

export class RecordStore {
  private records: AccountRecord[] = [];
  constructor(private readonly file: string) {}

  async load(): Promise<void> {
    try {
      this.records = JSON.parse(await readFile(this.file, "utf-8"));
    } catch {
      this.records = [];   // 首次启动文件不存在
    }
  }

  list(month: string): AccountRecord[] {
    return this.records.filter(r => r.date.startsWith(month))
      .sort((a, b) => b.date.localeCompare(a.date));
  }

  async add(input: RecordInput): Promise<AccountRecord> {
    const record = { ...input, id: Date.now() + Math.floor(Math.random() * 1000) };
    this.records.push(record);
    await writeFile(this.file, JSON.stringify(this.records, null, 2));
    return record;
  }
}
// src/main/index.ts
import { app, BrowserWindow, Menu, Notification, dialog, ipcMain } from "electron";
import { join } from "node:path";
import { readFile, writeFile } from "node:fs/promises";
import { RecordStore } from "./store";
import type { RecordInput } from "../shared/api";

// userData:Windows 在 %APPDATA%/应用名,macOS 在 ~/Library/Application Support/应用名
const store = new RecordStore(join(app.getPath("userData"), "records.json"));

function createWindow(): BrowserWindow {
  const win = new BrowserWindow({
    width: 1000,
    height: 700,
    webPreferences: {
      preload: join(__dirname, "../preload/index.js"),
      contextIsolation: true,   // 安全:页面拿不到 Node 能力,只能用 preload 暴露的 api
      nodeIntegration: false,
    },
  });
  if (process.env.ELECTRON_RENDERER_URL) {
    win.loadURL(process.env.ELECTRON_RENDERER_URL);   // 开发模式:Vite dev server
  } else {
    win.loadFile(join(__dirname, "../renderer/index.html"));
  }
  return win;
}

function registerIpc(win: BrowserWindow): void {
  ipcMain.handle("records:list", (_e, month: string) => store.list(month));
  ipcMain.handle("records:add", (_e, input: RecordInput) => store.add(input));

  ipcMain.handle("records:import", async () => {
    const { canceled, filePaths } = await dialog.showOpenDialog(win, {
      properties: ["openFile"],
      filters: [{ name: "CSV", extensions: ["csv"] }],
    });
    if (canceled || !filePaths[0]) return 0;

    const lines = (await readFile(filePaths[0], "utf-8")).trim().split(/\r?\n/).slice(1);
    for (const line of lines) {
      const [date, type, category, amount] = line.split(",");
      await store.add({ date, type: type as RecordInput["type"], category, amount: Number(amount) });
    }
    new Notification({ title: "导入完成", body: `共导入 ${lines.length} 条记录` }).show();
    return lines.length;
  });

  ipcMain.handle("records:export", async (_e, month: string) => {
    const { canceled, filePath } = await dialog.showSaveDialog(win, {
      defaultPath: `records-${month}.csv`,
    });
    if (canceled || !filePath) return null;
    const rows = store.list(month).map(r => [r.date, r.type, r.category, r.amount].join(","));
    await writeFile(filePath, ["date,type,category,amount", ...rows].join("\n"));
    return filePath;
  });
}

function buildMenu(win: BrowserWindow): void {
  Menu.setApplicationMenu(Menu.buildFromTemplate([
    { role: "fileMenu" },
    {
      label: "记账",
      submenu: [{
        label: "新增记录",
        accelerator: "CmdOrCtrl+N",
        click: () => win.webContents.send("menu:new-record"),   // 主进程 → 页面
      }],
    },
    { role: "viewMenu" },   // 包含"切换开发者工具",调试很方便
  ]));
}

app.whenReady().then(async () => {
  await store.load();
  const win = createWindow();
  registerIpc(win);
  buildMenu(win);
});

app.on("window-all-closed", () => {
  if (process.platform !== "darwin") app.quit();   // macOS 习惯:关窗口不退出
});

2.4 preload:只暴露需要的能力

// src/preload/index.ts
import { contextBridge, ipcRenderer } from "electron";
import type { DesktopApi } from "../shared/api";

const api: DesktopApi = {
  listRecords: (month) => ipcRenderer.invoke("records:list", month),
  addRecord: (input) => ipcRenderer.invoke("records:add", input),
  importCsv: () => ipcRenderer.invoke("records:import"),
  exportCsv: (month) => ipcRenderer.invoke("records:export", month),
  onMenuNewRecord: (cb) => {
    const handler = () => cb();
    ipcRenderer.on("menu:new-record", handler);
    return () => ipcRenderer.removeListener("menu:new-record", handler);
  },
};

contextBridge.exposeInMainWorld("api", api);
// src/renderer/src/env.d.ts —— 让 Vue 里的 window.api 有类型提示
import type { DesktopApi } from "../../shared/api";
declare global {
  interface Window { api: DesktopApi }
}

⚠️ 常见错误:直接 exposeInMainWorld("ipcRenderer", ipcRenderer),等于把整个系统权限交给页面,有安全风险。

2.5 Vue 页面

<!-- src/renderer/src/App.vue -->
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from "vue";
import type { AccountRecord } from "../../shared/api";

const month = ref(new Date().toISOString().slice(0, 7));
const records = ref<AccountRecord[]>([]);
const showForm = ref(false);

async function load() {
  records.value = await window.api.listRecords(month.value);
}

async function importCsv() {
  const count = await window.api.importCsv();
  if (count > 0) await load();
}

async function exportCsv() {
  const path = await window.api.exportCsv(month.value);
  if (path) alert(`已导出到 ${path}`);
}

let off: (() => void) | undefined;
onMounted(() => { off = window.api.onMenuNewRecord(() => (showForm.value = true)); });
onUnmounted(() => off?.());
watch(month, load, { immediate: true });
</script>

<template>
  <header>
    <input v-model="month" type="month" />
    <button @click="importCsv">导入 CSV</button>
    <button @click="exportCsv">导出 CSV</button>
  </header>
  <!-- RecordForm 组件可复用 05 篇的表单,提交时调用 window.api.addRecord -->
  <ul>
    <li v-for="r in records" :key="r.id" data-testid="record-row">
      {{ r.date }} {{ r.category }} {{ r.type === "income" ? "+" : "-" }}{{ r.amount }}
    </li>
  </ul>
</template>

2.6 打包与发布

npm run build:win     # 生成 .exe 安装包(模板已集成 electron-builder)
npm run build:mac     # 生成 .dmg(需在 Mac 上构建,正式分发需苹果签名公证)
npm run build:linux   # AppImage / deb
  • 自动更新:electron-updater,把安装包放到 GitHub Releases 或自己的静态服务器。
  • 数据库:better-sqlite3(原生模块,需 electron-builder install-app-deps 重新编译)。
  • 崩溃上报:@sentry/electron。

2.7 用 Playwright 测试 Electron 应用(QA 福利)

// e2e/app.spec.ts
import { test, expect, _electron as electron } from "@playwright/test";

test("启动后显示月份选择器", async () => {
  const app = await electron.launch({ args: ["out/main/index.js"] });   // 先 npm run build
  const window = await app.firstWindow();
  await expect(window.locator("input[type=month]")).toBeVisible();

  // 还能直接在主进程里执行代码做断言
  const userData = await app.evaluate(({ app }) => app.getPath("userData"));
  expect(userData).toContain("accounting-desktop");
  await app.close();
});

3. Tauri 2 + Vue:轻量工具(几乎不写 Rust)

Tauri 的"后端"是 Rust,但官方插件已经把文件、数据库、对话框、通知等能力暴露给 JS,
大部分逻辑可以直接用 TS 写。

npm create tauri-app@latest accounting-lite   # 选择 TypeScript → Vue
cd accounting-lite && npm install
npm run tauri add sql        # SQLite 插件(同时会自动配置权限 capabilities)
npm run tauri add dialog
npm run tauri dev

需先安装 Rust 工具链:curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

3.1 纯 TS 操作 SQLite

// src/db.ts
import Database from "@tauri-apps/plugin-sql";

export interface AccountRecord {
  id: number; type: "income" | "expense"; category: string; amount: number; date: string;
}

let db: Database | undefined;

async function getDb(): Promise<Database> {
  if (!db) {
    db = await Database.load("sqlite:accounting.db");   // 存在应用数据目录
    await db.execute(`CREATE TABLE IF NOT EXISTS records (
      id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, category TEXT, amount REAL, date TEXT)`);
  }
  return db;
}

export async function listRecords(month: string): Promise<AccountRecord[]> {
  return (await getDb()).select<AccountRecord[]>(
    "SELECT * FROM records WHERE date LIKE $1 ORDER BY date DESC", [`${month}%`]);
}

export async function addRecord(r: Omit<AccountRecord, "id">): Promise<void> {
  await (await getDb()).execute(
    "INSERT INTO records (type, category, amount, date) VALUES ($1, $2, $3, $4)",
    [r.type, r.category, r.amount, r.date]);
}

3.2 需要高性能时,写一个 Rust 命令

例如解析一个几百 MB 的日志文件:

// src-tauri/src/lib.rs
#[derive(serde::Serialize)]
struct Summary { lines: usize, errors: usize }

#[tauri::command]
fn summarize_log(path: String) -> Result<Summary, String> {
    let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
    let errors = text.lines().filter(|l| l.contains("ERROR")).count();
    Ok(Summary { lines: text.lines().count(), errors })
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_sql::Builder::new().build())
        .plugin(tauri_plugin_dialog::init())
        .invoke_handler(tauri::generate_handler![summarize_log])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
// Vue 中调用
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";

interface Summary { lines: number; errors: number }

async function analyze() {
  const path = await open({ filters: [{ name: "Log", extensions: ["log", "txt"] }] });
  if (!path) return;
  const summary = await invoke<Summary>("summarize_log", { path });
  console.log(`共 ${summary.lines} 行,ERROR ${summary.errors} 行`);
}

3.3 打包

npm run tauri build          # 生成当前平台安装包,体积通常不到 10 MB
npm run tauri android init   # Tauri 2 还能输出 Android / iOS 应用(同一份 Vue 代码)

4. 桌面产品要考虑的事情(QA 视角)

关注点 测试要点
多平台差异 路径分隔符、快捷键 Cmd/Ctrl、菜单位置、字体渲染
本地数据 首次启动无数据、数据文件损坏、升级后数据迁移
自动更新 更新中断、降级、无网络
安装/卸载 卸载后是否残留用户数据(是否应保留)
安全 页面是否能访问 Node/系统能力;加载外部 URL 的限制
性能 冷启动时间、内存占用、长时间运行内存泄漏

5. 练习

  1. 完成 Electron 版本地记账,加上"系统托盘图标 + 每晚 21:00 提醒记账"(Tray + Notification)。
  2. 用 Playwright 的 _electron 为它写 2 个 E2E 用例。
  3. 用 Tauri 做一个"日志分析小工具":选择文件 → 统计各级别日志数量 → ECharts 柱状图。
  4. 对比两个安装包的体积、启动时间、内存占用。

文章评论 (0)