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