完整实战:Vue 桌面笔记应用
从 Vue 草稿到受控 IPC、本地保存与错误恢复,提供全部源码和可下载实验。
建议先读:Vue 与 Electron 的开发、构建和桥接工作流用户目录、配置写入与本地数据可靠性IPC 请求、事件、取消与错误合同
本页内容
这个项目把前面的知识接成什么#
我们做一个真正具有桌面边界的本地笔记程序。你使用熟悉的 Vue 编写界面,在 Electron 窗口里输入标题和正文,点击保存,由主进程把数据写到应用的数据目录。关闭应用以后再次启动,能重新读到上次保存的版本。整个业务过程不需要账号、模型服务或远程后端。
这个练习的难点不是文本框,而是让每一层承担正确职责。Vue 知道用户正在编辑什么;preload 提供少量明确的方法;主进程决定谁能调用这些方法、数据放在哪里;存储模块负责校验、排队、冲突和损坏文件保护。读完这一章,你应该能够从一次按钮点击追踪到磁盘变化,也能从磁盘错误反向找到界面上的提示。
先完成Node 与 Electron 学习路线中的基础章节。本章把全部文件集中展示,适合在理解单个机制后组装;如果你还不知道主进程是什么,直接复制整个目录运行,只能得到一个会启动的程序,难以判断哪里出了问题。
下载完整桌面笔记实验源码。压缩包包含这里展示的全部源文件、依赖锁文件、说明和检查程序。正文不展开机器生成的 package-lock.json,下载包保留完整文件,可直接使用 npm ci。阅读教材仍可完全离线;第一次安装实验依赖及 Electron 二进制需要联网。
先看一次保存经过哪些边界#
用户输入标题和正文
↓ Vue 保存当前草稿,以及草稿基于的 revision
window.desktopNotes.save({ title, body, expectedRevision })
↓ preload 只转发到固定 notes:save 频道
主进程检查:指定窗口 + 主 frame + 允许的页面 URL
↓ 运行时校验字段;renderer 不能提供文件路径
存储模块:进入写队列 → 重新读磁盘版本 → 比较 expectedRevision
↓ 写同目录临时文件 → sync → close → rename
主进程返回 { ok: true, value: note }
↓ Vue 接收新的 revision,把当前草稿标记为已保存
这里有三种不同的“完成”。按钮被点击,只表示产生意图;IPC Promise 有响应,表示收到了主进程的结果;只有结果中的 ok 为 true,才表示存储步骤成功完成。你不能在发送请求时就把界面标成“已保存”,否则磁盘无权限时,用户看到的事实会与磁盘事实相反。
跨进程还会出现更微妙的情况:主进程可能已完成写入,但窗口在接收回复前消失。对于调用者,这时结果未知,不能自动推断为没有写入。本例保留当前草稿,并提示重新读取确认。更复杂的多步骤业务可以增加操作编号和查询接口;这里先用版本号把最小的一致性问题讲清楚。
环境与运行命令#
本项目锁定 Vue 3.5.42、Vite 8.3.0、Electron 44.3.0。建议使用受支持的 Node 24 LTS 作为学习环境。本次安装、纯 Node 检查与 Vite 构建在 Windows 的 Node 22.22.0、npm 10.9.4 上实际完成;Electron 无窗口运行检查返回其内嵌 Node 24.20.0。终端 Node 与 Electron 内嵌 Node 是两个运行环境,不要求版本相等。
把源码解压到普通的学习目录,在该目录打开终端。以下命令中的 npm 在 Windows PowerShell 受脚本执行策略影响时,可以写成 npm.cmd。每次出现“在项目目录运行”,指的是能看到 package.json 的目录,而不是任意终端当前所在位置。
# 依照锁文件安装本项目依赖;首次下载需要网络。
npm.cmd ci
# 先构建 Vue 页面,再启动 Electron。退出窗口后该命令结束。
npm.cmd start
开发时需要两个终端。第一个终端保持 Vite 运行,第二个终端启动 Electron。启动顺序有意义:若页面服务尚未就绪,桌面窗口加载页面会失败。端口固定为 5187,并使用 strictPort;端口被占用时明确报错,不悄悄换到另一端口,否则主进程允许的地址与实际页面不一致。
npm.cmd run dev:web
npm.cmd run dev:desktop
只在浏览器中打开 Vite 地址,会看到“网页预览”的提示,本地保存不可用。这不是接口故障,因为普通浏览器没有执行 Electron 的 preload。理解这个现象比在浏览器里模拟出一个假的 desktopNotes 全局对象更有价值:同一套界面可以渲染,不等于它拥有相同的系统能力。
项目结构与每个文件的用途#
desktop-notes/
├─ package.json 脚本、入口与固定依赖
├─ package-lock.json npm 生成的依赖锁文件,完整放在下载包
├─ index.html Vue 页面的 HTML 入口
├─ vite.config.mjs 构建、资源路径、开发/构建 CSP
├─ main.cjs 应用、窗口、协议、IPC 和能力授权
├─ preload.cjs 只暴露三个业务方法
├─ lib/
│ ├─ policy.cjs 页面地址和静态资源白名单
│ └─ repository.cjs 固定文件存储和一致性规则
├─ src/
│ ├─ main.js 挂载 Vue
│ ├─ App.vue 草稿、状态、表单和纯文本预览
│ └─ desktop.d.ts 编辑器可读的桥接契约
├─ scripts/
│ ├─ launch.cjs 启动本项目 Electron 子进程
│ └─ runtime-check.cjs 无窗口读取实际内嵌版本
├─ checks/
│ ├─ policy.cjs 地址授权边界检查
│ └─ repository.cjs 真实临时文件读写检查
├─ .gitignore 排除依赖、产物和临时检查目录
└─ README.md 操作说明与实验边界
只有 Vue 页面由 Vite 编译。main.cjs、preload.cjs 和 lib 中的文件保持 CommonJS,由 Electron 在对应环境中加载。这是有意选择的最小构建结构,先减少“哪一个构建器处理哪份代码”的干扰。等你能解释这条链路,再阅读工程章节中主进程打包、源码映射和原生依赖的取舍。
package.json:应用入口与网页入口分开#
main 字段指向 Electron 应用的入口文件。它不会自动把 Vue 的 src/main.js 当成应用主进程。scripts 中的 start 明确先执行 build,再通过启动脚本运行 Electron;因此 npm start 读取的是当前源码构建的页面,不依赖一个恰好存在的旧 dist。
{
"name": "handbook-desktop-notes",
"version": "1.0.0",
"description": "Node 与 Electron 教材的本地桌面笔记实验",
"private": true,
"main": "main.cjs",
"scripts": {
"dev:web": "vite --host 127.0.0.1 --port 5187 --strictPort",
"dev:desktop": "node scripts/launch.cjs dev",
"build": "vite build",
"start": "npm run build && node scripts/launch.cjs app",
"check:runtime": "node scripts/launch.cjs check",
"test": "node --test checks/repository.cjs checks/policy.cjs"
},
"dependencies": { "vue": "3.5.42" },
"devDependencies": { "electron": "44.3.0", "vite": "8.3.0", "@vitejs/plugin-vue": "6.0.8" }
}
HTML 与 Vite:资源从哪里来#
HTML 中的 CSP 是本项目构建插件的输入标记;插件在开发响应和正式构建时都会替换成完整策略。最终构建的 HTML 不应残留该标记。我们没有使用远程字体、CDN 脚本或在线图片,因此断开网络后已经安装好的实验仍可使用。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="__CSP__">
<title>桌面笔记实验</title>
</head>
<body><div id="app"></div><script type="module" src="/src/main.js"></script></body>
</html>
base 为相对路径,让构建资源可以位于 app://notes/index.html 对应的目录中。开发模式下,Vite 会注入样式并通过本机 WebSocket 实现热更新,所以开发 CSP 允许这些具体行为。构建页面把样式输出为本地文件,策略随之收紧。开发策略中的 unsafe-inline 只用于样式,不是允许任意内联脚本。
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig(({ command }) => {
const development = command === 'serve';
// Vite 开发模式注入样式并使用本机 WebSocket;构建产物收紧为本地资源。
const policy = [
"default-src 'self'", "script-src 'self'",
development ? "style-src 'self' 'unsafe-inline'" : "style-src 'self'",
development ? "connect-src 'self' ws://127.0.0.1:5187" : "connect-src 'self'",
"img-src 'self' data:", "object-src 'none'", "frame-src 'none'",
"base-uri 'none'", "form-action 'none'",
].join('; ');
return {
base: './',
plugins: [vue(), { name: 'fixed-csp', transformIndexHtml: html => html.replace('__CSP__', policy) }],
build: { outDir: 'dist', emptyOutDir: true },
};
});
主进程:把系统权限变成业务能力#
主进程不会向页面提供 readFile(path) 这样的通用入口,因为任意路径意味着任意本地文件的读取机会。它只提供读取笔记、保存笔记和读取运行版本。笔记的位置由 app.getPath('userData') 决定,页面提交的数据只包含业务字段。增加功能时应增加具体能力和校验,而不是把整个 Node 模块交给页面。
页面地址策略#
页面信任与文件服务是两件事。isTrustedPage 判断“谁可以请求桌面能力”:开发只接受固定本机地址,普通启动只接受指定自定义协议页面。assetPath 判断“协议可以服务什么”:只接受入口 HTML 和 assets 下的构建资源,返回相对路径。它不把 URL 直接拼接成任意磁盘路径。
对 URL 应当先解析,再比较 protocol、host、port 和 pathname。字符串包含判断会错误接受 evil-example.com 中的 example.com,简单前缀判断也容易混淆凭证、端口和路径。自定义协议在 Node 的 URL 实现中可能得到 null origin,因此本例显式比较各组成部分;不能把所有 origin 为 null 的地址都视为同一个可信来源。
function isTrustedPage(raw, development) {
try {
const url = new URL(raw);
if (url.username || url.password || url.search) return false;
if (development) {
return url.protocol === 'http:' && url.hostname === '127.0.0.1'
&& url.port === '5187' && url.pathname === '/';
}
// 自定义协议在 Node URL 中不一定有非 null 的 origin,因此逐项比较。
return url.protocol === 'app:' && url.host === 'notes' && url.pathname === '/index.html';
} catch { return false; }
}
function assetPath(raw) {
try {
const url = new URL(raw);
if (url.protocol !== 'app:' || url.host !== 'notes' || url.username || url.password || url.search) return null;
if (url.pathname === '/index.html') return 'index.html';
// 只服务 Vite 产物:不允许任意文件读取,也不允许路径穿越。
if (/^\/assets\/[A-Za-z0-9._-]+$/.test(url.pathname)) return url.pathname.slice(1);
return null;
} catch { return null; }
}
module.exports = { isTrustedPage, assetPath };
应用与窗口入口#
registerSchemesAsPrivileged 必须在 app ready 之前调用,真正处理请求的 protocol.handle 在 ready 之后注册。程序使用默认 session,协议和权限策略也注册在这份 session 上;如果以后给窗口设置独立 partition,需要在对应 session 中重新处理协议,而不是以为全局代码会自动覆盖所有会话。
trusted 同时检查 webContents、主 frame 和实际页面地址。只检查频道名称没有意义,因为 renderer 可以发送同名请求;只检查 webContents 也不完整,因为同一窗口中的子 frame 不应继承所有主页面能力。这里的判断发生在 main,而不是依赖页面自觉调用正确接口。
const { app, BrowserWindow, ipcMain, protocol, net, session } = require('electron');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const { createRepository } = require('./lib/repository.cjs');
const { isTrustedPage, assetPath } = require('./lib/policy.cjs');
// 协议特权必须在 app ready 之前注册;只用于这个应用自己的本地界面。
protocol.registerSchemesAsPrivileged([{ scheme: 'app', privileges: {
standard: true, secure: true, supportFetchAPI: true, corsEnabled: true,
} }]);
const development = !app.isPackaged && process.argv.includes('--dev');
const gotLock = app.requestSingleInstanceLock();
let window;
const messages = {
INVALID_INPUT: '标题需为 1–80 个码点,正文不超过 20000 个码点;请求格式必须正确。',
REVISION_CONFLICT: '本地笔记版本已经变化。当前输入已保留,请先复制草稿,再重新读取。',
CORRUPT_STORAGE: '本地文件格式损坏,已停止写入以保留原文件。请先备份并排查。',
REVISION_LIMIT: '笔记版本已超出实验支持范围。',
FORBIDDEN: '该页面不能调用桌面能力。',
STORAGE_ERROR: '读写本地文件失败,请检查磁盘权限和主进程日志。',
};
function trusted(event) {
return window && !window.isDestroyed() && event.sender === window.webContents
&& event.senderFrame === window.webContents.mainFrame
&& isTrustedPage(event.senderFrame.url, development);
}
function handle(channel, operation) {
ipcMain.handle(channel, async (event, input) => {
if (!trusted(event)) return { ok: false, error: { code: 'FORBIDDEN', message: messages.FORBIDDEN } };
try { return { ok: true, value: await operation(input) }; }
catch (error) {
const code = Object.hasOwn(messages, error.code) ? error.code : 'STORAGE_ERROR';
console.error('desktop-operation-failed', { channel, code, systemCode: error.code });
return { ok: false, error: { code, message: messages[code] } };
}
});
}
function createWindow() {
window = new BrowserWindow({ width: 940, height: 840, minWidth: 640, minHeight: 560,
webPreferences: { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, sandbox: true, nodeIntegration: false },
});
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
window.webContents.on('will-navigate', (event, target) => { if (!isTrustedPage(target, development)) event.preventDefault(); });
window.on('closed', () => { window = null; });
const entry = development ? 'http://127.0.0.1:5187/' : 'app://notes/index.html';
window.loadURL(entry).catch(error => console.error('page-load-failed', error.code));
}
if (!gotLock) app.quit();
else {
app.on('second-instance', () => {
if (window && !window.isDestroyed()) { if (window.isMinimized()) window.restore(); window.focus(); }
});
app.whenReady().then(() => {
const repository = createRepository(path.join(app.getPath('userData'), 'learning-notes'));
protocol.handle('app', request => {
const relative = assetPath(request.url);
if (!relative) return new Response('Not found', { status: 404 });
return net.fetch(pathToFileURL(path.join(__dirname, 'dist', relative)).href);
});
session.defaultSession.setPermissionRequestHandler((_contents, _permission, callback) => callback(false));
session.defaultSession.setPermissionCheckHandler(() => false);
handle('notes:load', () => repository.load());
handle('notes:save', input => repository.save(input));
handle('runtime:versions', () => ({ electron: process.versions.electron, node: process.versions.node, chrome: process.versions.chrome }));
createWindow();
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
}).catch(error => { console.error('startup-failed', error.message); app.quit(); });
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
}
窗口关闭和应用退出存在平台差异。示例在 Windows、Linux 的全部窗口关闭时退出;macOS 按常见行为保留应用,激活后没有窗口则重新创建。单实例锁避免两个应用主进程同时操作同一个小型 JSON 仓库,但它不是数据库事务,也不能防止其他程序手动修改文件。
本例禁止新窗口、未允许的页面导航和系统权限请求。它只需要本地文本编辑,因此没有必要开放摄像头、麦克风或外部网页。如果未来增加外部链接,应做协议与目标白名单,再在主进程使用专门方法打开;不要删除当前检查来让所有跳转“先跑起来”。
preload:一张很小的能力清单#
preload 在渲染进程的隔离上下文中执行,不是独立进程。contextBridge 把指定的方法提供给页面。sandbox 为 true 时 preload 的 Node 能力受限;这个文件只需要 Electron 提供的 contextBridge 和 ipcRenderer,不尝试加载文件系统或任意本地模块。
下面没有暴露通用 send(channel, payload),也没有暴露 ipcRenderer 本身。调用方不能通过改一个频道字符串变成系统命令执行器。每个方法对应一项可说明、可检查的业务能力,这会让前后端契约一样清晰地出现在桌面边界上。
const { contextBridge, ipcRenderer } = require('electron');
// 页面只能调用这三个具体能力,不能指定频道、文件路径或主进程函数。
contextBridge.exposeInMainWorld('desktopNotes', {
load: () => ipcRenderer.invoke('notes:load'),
save: input => ipcRenderer.invoke('notes:save', input),
versions: () => ipcRenderer.invoke('runtime:versions'),
});
| 页面方法 | 参数 | 成功结果 value | 可能失败 |
|---|---|---|---|
| load() | 无 | schemaVersion、revision、title、body | 文件格式损坏、权限或磁盘错误 |
| save(input) | title、body、expectedRevision,必须恰好这些字段 | 已保存笔记及增加后的 revision | 输入无效、版本冲突、文件损坏、写入错误 |
| versions() | 无 | electron、node、chrome 版本字符串 | 通信失败或页面来源被拒绝 |
所有方法都返回 Promise。业务失败使用 { ok: false, error: { code, message } },通信本身失败仍可能让 Promise reject,因此 Vue 既要检查 ok,也要有 try/catch。把所有错误都写成一个 catch,会丢失“业务被拒绝”和“根本没收到可信回复”的区别。
存储:为什么一行 writeFile 还不够#
我们保存的文档包括 schemaVersion 和 revision。schemaVersion 描述磁盘结构,未来格式迁移时使用;revision 描述这份笔记被成功修改的次数,防止旧草稿覆盖新状态。两个版本回答不同的问题,不能混成一个字段。磁盘没有文件时,返回 revision 为零的默认笔记;只有保存成功后才出现 revision 为一的实际文件。
保存必须在队列内重新读取磁盘,再比较 expectedRevision。如果在排队之前检查版本,两个请求可能都看到版本零,并都认为自己有权保存;进入队列只会让两个错误写入按顺序发生。这里把“读当前值、校验版本、生成下一版、写入”放在同一串行任务中,确保同一仓库实例内第二个请求看见第一步已经提交的结果。
Promise 链是队列的一种最小实现:每次任务接在 tail 后面,新的 tail 捕获该任务的失败,但返回给调用者的仍是原始 job。这样调用者会收到错误,队列又能继续处理后续请求。如果直接把 rejected 的 job 作为下一次任务的前置,后续 then 永远不会执行,第一次失败就会把整个仓库锁死。
const fs = require('node:fs/promises');
const path = require('node:path');
const { randomUUID } = require('node:crypto');
class AppError extends Error {
constructor(code) { super(code); this.code = code; }
}
function validateInput(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new AppError('INVALID_INPUT');
const keys = ['title', 'body', 'expectedRevision'];
if (Object.keys(input).length !== keys.length || !keys.every(key => Object.hasOwn(input, key))) throw new AppError('INVALID_INPUT');
if (typeof input.title !== 'string' || typeof input.body !== 'string') throw new AppError('INVALID_INPUT');
const title = input.title.trim();
if ([...title].length < 1 || [...title].length > 80 || [...input.body].length > 20000) throw new AppError('INVALID_INPUT');
if (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0) throw new AppError('INVALID_INPUT');
return { title, body: input.body, expectedRevision: input.expectedRevision };
}
function validateStored(value) {
if (!value || value.schemaVersion !== 1 || !Number.isSafeInteger(value.revision) || value.revision < 1) throw new AppError('CORRUPT_STORAGE');
try { validateInput({ title: value.title, body: value.body, expectedRevision: value.revision }); }
catch { throw new AppError('CORRUPT_STORAGE'); }
return { schemaVersion: 1, revision: value.revision, title: value.title, body: value.body };
}
function createRepository(directory) {
// directory 由 main 决定,renderer 从来不能传入目录或文件名。
const filename = path.join(directory, 'note.json');
let tail = Promise.resolve();
async function readDisk() {
let text;
try { text = await fs.readFile(filename, 'utf8'); }
catch (error) {
if (error.code === 'ENOENT') return { schemaVersion: 1, revision: 0, title: '第一篇笔记', body: '' };
throw error;
}
let value;
try { value = JSON.parse(text); } catch { throw new AppError('CORRUPT_STORAGE'); }
return validateStored(value);
}
function load() { return tail.then(readDisk); }
function save(raw) {
const job = tail.then(async () => {
const input = validateInput(raw);
const previous = await readDisk();
if (input.expectedRevision !== previous.revision) throw new AppError('REVISION_CONFLICT');
if (previous.revision === Number.MAX_SAFE_INTEGER) throw new AppError('REVISION_LIMIT');
const next = { schemaVersion: 1, revision: previous.revision + 1, title: input.title, body: input.body };
await fs.mkdir(directory, { recursive: true });
const temporary = path.join(directory, `note-${randomUUID()}.tmp`);
let handle;
let created = false;
try {
handle = await fs.open(temporary, 'wx', 0o600);
created = true; // 只有创建成功,失败清理时才有权删除这个临时文件。
await handle.writeFile(JSON.stringify(next, null, 2), 'utf8');
await handle.sync();
await handle.close(); handle = null;
// 先写完同目录临时文件再替换;绝不先删除现有 note.json。
await fs.rename(temporary, filename);
} catch (error) {
if (handle) await handle.close().catch(() => {});
if (created) await fs.unlink(temporary).catch(() => {});
throw error;
}
return next;
});
// 某次保存失败不应让队列永久停在 rejected 状态。
tail = job.catch(() => {});
return job;
}
return { load, save };
}
module.exports = { createRepository, AppError, validateInput };
临时文件采用同目录、随机名字和 wx 排他创建。写完内容后同步文件、关闭句柄,再重命名到正式文件。这样可以减少直接截断正式文件后只写了一半的风险。若排他创建失败,程序不会删除那个未由自己创建的路径;若自己创建成功后写失败,则清理自己的临时文件,并保留旧笔记。
这仍然是教学用小型 JSON 仓库。它没有跨进程事务、多个文档的原子提交或所有文件系统上的断电持久性保证;文件 sync 也不能替代完整的数据库恢复协议。Windows 杀毒软件、文件占用和权限策略可能让 rename 失败,正确响应是报告错误、保留原文件,而不是先删除原文件再重试。需要多表关系或持续高频写入时,应该评估数据库,并学习事务和迁移。
代码按 Unicode 码点计数标题和正文,而不是按 UTF-16 的 length。组合字符仍可能由多个码点组成,所以这不等于用户视觉上的“字数”;教材明确这个边界,避免把一个技术限制宣传成完整自然语言字符计数器。示例把输入限制在较小范围,不能拿 readFile 加 JSON.parse 直接处理任意大文件。
Vue:区分草稿、忙碌状态与保存事实#
Vue 中的 title 和 body 是可编辑草稿;saved 是上次可信结果的快照;revision 是草稿所依据的磁盘版本。dirty 通过比较草稿快照与 saved 得到。它不直接比较一个会不断变化的“默认对象”,也不会因为点击保存就立即变成 false。
本例在一次读取或保存期间禁用表单,避免用户在等待回复时继续输入,而 accept 用较旧结果覆盖新输入。真实复杂编辑器也可以不禁用输入,但那需要保存开始时的草稿版本、当前草稿版本与磁盘版本分别建模;先理解这个最小模型,再增加并发编辑体验。
import { createApp } from 'vue';
import App from './App.vue';
createApp(App).mount('#app');
// 编辑器类型提示不会替代 main 中的运行时校验。
export {};
type Note = { schemaVersion: 1; revision: number; title: string; body: string };
type Result<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } };
declare global {
interface Window {
desktopNotes?: {
load(): Promise<Result<Note>>;
save(input: { expectedRevision: number; title: string; body: string }): Promise<Result<Note>>;
versions(): Promise<Result<{ electron: string; node: string; chrome: string }>>;
};
}
}
声明文件帮助编辑器知道桥接方法的形状,不会在运行时阻止非法输入。本例 App.vue 使用 JavaScript,构建并未执行全项目 TypeScript 检查;主进程中的 validateInput 才是执行时的约束。即使以后把界面改成 TypeScript,外部数据也仍然要校验。
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
const bridge = window.desktopNotes;
// Vue 管理可编辑草稿;磁盘版本只有在 main 确认成功后才进入 saved。
const title = ref('');
const body = ref('');
const revision = ref(0);
const saved = ref('');
const ready = ref(false);
const busy = ref(false);
const status = ref(bridge ? '正在读取本地笔记…' : '这是网页预览;请启动 Electron 窗口体验本地保存。');
const versions = ref(null);
const snapshot = () => JSON.stringify({ title: title.value, body: body.value });
const dirty = computed(() => ready.value && snapshot() !== saved.value);
function accept(note) {
title.value = note.title; body.value = note.body; revision.value = note.revision;
saved.value = snapshot(); ready.value = true;
}
async function load() {
if (!bridge || busy.value) return;
if (dirty.value && !window.confirm('重新读取会放弃尚未保存的修改,继续吗?')) return;
busy.value = true;
try {
const result = await bridge.load();
if (!result.ok) { status.value = result.error.message; return; }
accept(result.value); status.value = `已读取本地版本 ${revision.value}`;
} catch { status.value = '桌面通信失败,请查看主进程日志。'; }
finally { busy.value = false; }
}
async function save() {
if (!bridge || busy.value || !ready.value || !dirty.value) return;
busy.value = true;
try {
// expectedRevision 表示草稿从哪个磁盘版本产生,防止旧草稿覆盖新版本。
const result = await bridge.save({ expectedRevision: revision.value, title: title.value, body: body.value });
if (!result.ok) { status.value = result.error.message; return; }
accept(result.value); status.value = `已保存为本地版本 ${revision.value}`;
} catch { status.value = '保存结果未知。请先重新读取确认,不要假设已经保存。'; }
finally { busy.value = false; }
}
function onKey(event) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault(); save();
}
}
onMounted(async () => {
window.addEventListener('keydown', onKey);
await load();
if (bridge) {
try { const result = await bridge.versions(); if (result.ok) versions.value = result.value; }
catch { status.value = '笔记已加载,但运行版本读取失败。'; }
}
});
onUnmounted(() => window.removeEventListener('keydown', onKey));
</script>
<template>
<main>
<header><p class="eyebrow">Electron × Vue 学习实验</p><h1>本地笔记</h1></header>
<p>笔记只保存在这个桌面应用的数据目录。请手动保存;关闭窗口不会自动保存未提交的修改。</p>
<form @submit.prevent="save">
<label for="title">标题</label>
<input id="title" v-model="title" :disabled="busy || !ready" autocomplete="off">
<label for="body">正文</label>
<textarea id="body" v-model="body" rows="13" :disabled="busy || !ready"></textarea>
<div class="actions">
<button type="submit" :disabled="busy || !ready || !dirty">保存 · Ctrl / ⌘ S</button>
<button type="button" :disabled="busy || !bridge" @click="load">重新读取</button>
<span>{{ busy ? '正在处理' : dirty ? '有未保存修改' : '与读取版本一致' }}</span>
</div>
</form>
<p class="status" role="status" aria-live="polite">{{ status }}</p>
<section aria-labelledby="preview-title"><h2 id="preview-title">纯文本预览</h2><pre>{{ body || '正文会显示在这里。' }}</pre></section>
<p v-if="versions" class="versions">Electron {{ versions.electron }} · 内嵌 Node {{ versions.node }} · Chromium {{ versions.chrome }}</p>
</main>
</template>
<style scoped>
main{max-width:800px;margin:32px auto;padding:0 28px;font:16px/1.8 system-ui;color:#253248}
.eyebrow,.versions{font-size:13px;color:#647080}h1{font-size:32px;margin:0}h2{font-size:18px}
label{display:block;margin-top:16px;font-weight:600}input,textarea{box-sizing:border-box;width:100%;font:inherit;padding:10px;border:1px solid #bac5c0;border-radius:5px;background:white;color:inherit}
textarea{resize:vertical}button{font:inherit;border:1px solid #197a55;border-radius:5px;padding:6px 13px;background:#197a55;color:white;cursor:pointer}
button+button{background:white;color:#197a55}button:disabled{opacity:.5;cursor:default}.actions{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:16px}.actions span{font-size:14px}
.status{background:#eef5f1;padding:12px}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit;background:#f6f6f7;padding:16px}
input:focus-visible,textarea:focus-visible,button:focus-visible{outline:2px solid #197a55;outline-offset:3px}
</style>
重新读取可能覆盖尚未保存的草稿,所以 load 先确认。版本冲突时不调用 accept,保留用户输入,提示复制草稿再读取。正文用插值和 pre 展示,输入的 HTML 只是文本;如果改成 Markdown,需要重新设计内容清理与链接策略,不能直接换成 v-html。
快捷键使用同一个 save 函数,不另写一套保存规则;组件卸载时移除监听,避免重复挂载以后一次按键触发多次请求。本例明确采用手动保存,关闭窗口不会自动保存未提交修改。增加关闭前提醒时,应同时考虑窗口关闭、应用退出和系统关机,不只处理一个页面的点击事件。
启动脚本:终端 Node 与 Electron 的关系#
scripts/launch.cjs 本身由终端 Node 执行。require('electron') 在这个环境返回已安装的 Electron 可执行文件路径,spawn 用它创建子进程。子进程里 main.cjs 的 require('electron') 才返回 Electron 的应用 API。看上去是同一句 require,含义由当前运行环境决定。
部分工具环境会设置 ELECTRON_RUN_AS_NODE,使 Electron 以类似 Node 的方式运行。本例只从即将启动的子进程环境中移除这个值,不修改系统设置,也不覆盖整个 process.env。windowsHide 隐藏额外控制台窗口,不会阻止用户运行 npm start 时看到桌面应用。
const path = require('node:path');
const { spawn } = require('node:child_process');
const electron = require('electron');
const root = path.join(__dirname, '..');
const mode = process.argv[2];
if (!['app', 'dev', 'check'].includes(mode)) throw new Error('模式必须是 app、dev 或 check');
const args = mode === 'check' ? [path.join(__dirname, 'runtime-check.cjs')]
: [root, ...(mode === 'dev' ? ['--dev'] : [])];
const childEnv = { ...process.env };
// 只修改子进程副本,避免某些编辑器的环境变量把 Electron 当普通 Node 启动。
delete childEnv.ELECTRON_RUN_AS_NODE;
const child = spawn(electron, args, { cwd: root, env: childEnv, stdio: 'inherit', windowsHide: true });
child.on('error', error => { console.error(error.message); process.exitCode = 1; });
child.on('exit', code => { process.exitCode = code ?? 1; });
const { app } = require('electron');
// 无窗口检查:证明实际启动了 Electron 运行时,不验证界面或 IPC。
console.log(JSON.stringify({ electron: process.versions.electron, node: process.versions.node, chrome: process.versions.chrome, platform: process.platform }));
app.exit(0);
npm.cmd run check:runtime
本次无窗口实际输出如下。它证明 Electron 二进制能启动、Electron API 能加载,并确认内嵌版本;它没有创建 BrowserWindow,因此不证明 preload、IPC 或文件界面操作已通过桌面验收。
{"electron":"44.3.0","node":"24.20.0","chrome":"152.0.7977.78","platform":"win32"}
自动检查:分别证明存储规则与地址规则#
测试使用 Node 自带的 node:test。存储检查操作自己创建的临时目录,在清理前核对绝对路径和目录前缀,确保不会删除学习者的真实笔记。新建仓库实例重新读取能证明数据确实写进文件,而不是只停留在上一个对象的内存里。
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const { createRepository, validateInput } = require('../lib/repository.cjs');
async function inDirectory(run) {
const base = path.resolve(__dirname, '../.check-data');
await fs.mkdir(base, { recursive: true });
const directory = await fs.mkdtemp(path.join(base, 'case-'));
try { await run(directory); }
finally {
assert.equal(path.dirname(directory), base);
assert.ok(path.basename(directory).startsWith('case-'));
await fs.rm(directory, { recursive: true, force: true });
}
}
test('输入校验不会接受路径、越界或错误版本', () => {
const valid = { title: '笔记', body: '你好', expectedRevision: 0 };
assert.equal(validateInput(valid).title, '笔记');
assert.throws(() => validateInput({ ...valid, path: 'other.json' }));
assert.throws(() => validateInput({ ...valid, expectedRevision: -1 }));
assert.throws(() => validateInput({ ...valid, body: 'a'.repeat(20001) }));
});
test('首次读取、保存与新仓库实例重新读取', () => inDirectory(async directory => {
const repository = createRepository(directory);
assert.equal((await repository.load()).revision, 0);
const saved = await repository.save({ expectedRevision: 0, title: '第一天', body: '主进程控制系统能力。' });
assert.equal(saved.revision, 1);
assert.deepEqual(await createRepository(directory).load(), saved);
}));
test('同一进程并发提交旧版本,只允许一个成功', () => inDirectory(async directory => {
const repository = createRepository(directory);
const input = { expectedRevision: 0, title: '并发', body: '同一个起点' };
const results = await Promise.allSettled([repository.save(input), repository.save(input)]);
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
assert.equal(results.find(result => result.status === 'rejected').reason.code, 'REVISION_CONFLICT');
assert.equal((await repository.load()).revision, 1);
await repository.save({ ...input, expectedRevision: 1 });
assert.equal((await repository.load()).revision, 2); // 失败没有毒化写队列。
}));
test('损坏的原文件不会被默认数据静默覆盖', () => inDirectory(async directory => {
const filename = path.join(directory, 'note.json');
await fs.writeFile(filename, 'broken-content');
const repository = createRepository(directory);
await assert.rejects(() => repository.load(), error => error.code === 'CORRUPT_STORAGE');
await assert.rejects(() => repository.save({ expectedRevision: 0, title: '不覆盖', body: '' }));
assert.equal(await fs.readFile(filename, 'utf8'), 'broken-content');
}));
const test = require('node:test');
const assert = require('node:assert/strict');
const { isTrustedPage, assetPath } = require('../lib/policy.cjs');
test('只信任当前本地应用页面或指定开发入口', () => {
assert.equal(isTrustedPage('app://notes/index.html', false), true);
assert.equal(isTrustedPage('http://127.0.0.1:5187/', true), true);
for (const url of ['app://evil/index.html', 'app://notes/assets/a.js', 'https://example.test/', 'app://notes/index.html?script=x']) {
assert.equal(isTrustedPage(url, false), false);
}
assert.equal(isTrustedPage('http://127.0.0.1:5188/', true), false);
});
test('协议仅映射构建产物的受限路径', () => {
assert.equal(assetPath('app://notes/index.html'), 'index.html');
assert.equal(assetPath('app://notes/assets/a-123.js'), 'assets/a-123.js');
for (const url of ['app://notes/../../secret.txt', 'app://notes/assets/%2e%2e/secret', 'file:///c:/secret', 'app://notes/assets/a%2Fb.js']) {
assert.equal(assetPath(url), null);
}
});
npm.cmd test
npm.cmd run build
本次六项测试通过,Vite 构建成功。并发检查故意从同一个 expectedRevision 提交两个保存,要求仅一个成功,并确认冲突后队列还能继续。这比只检查 save 返回一个对象更有意义,因为它对应用户可能失去数据的失败条件。损坏文件检查则确保程序不会把读取失败误当作“第一次启动”,然后覆盖原文件。
手动桌面验收清单#
以下是需要在真实窗口执行的步骤和预期,本次没有代替你完成这些 GUI 操作。不要把“步骤写在教材里”理解为“所有平台已验证”。
| 操作 | 预期结果 | 失败时先检查 |
|---|---|---|
| npm start | 出现本地笔记窗口,无远程服务依赖 | 构建输出、Electron 下载、主进程启动日志 |
| 修改正文并保存 | 提示保存的新版本,未保存标记消失 | bridge 是否存在、main 校验、userData 权限 |
| 退出再启动 | 读取最后一次成功保存的标题和正文 | 实际数据目录、应用身份、读取错误 |
| 输入空白标题并保存 | 显示输入错误,草稿仍保留 | 主进程 validateInput 与返回契约 |
| 修改草稿后点重新读取 | 先确认,取消则保持草稿 | dirty 与 saved 的更新时机 |
| 打开普通浏览器预览 | 明确显示网页预览,本地保存不可用 | 不应为了预览关闭沙箱 |
| 第二次启动同一应用 | 聚焦已有窗口,而非并发写同一仓库 | 单实例锁和 second-instance 处理 |
| 按 Ctrl / Command + S | 复用同一保存逻辑,不弹网页另存为 | 键盘监听、preventDefault 与 busy 状态 |
练习一:新增一个有界的“复制正文”能力#
先说清契约:页面允许请求把一段不超过两万个码点的纯文本写入剪贴板;主进程校验来源和参数;返回成功事实。不要实现通用 clipboard(method, args),也不要把剪贴板模块整个暴露给页面。这里只给出与现有工程配合的完整新增代码段,每段明确放置位置;它不是另一套可以脱离主项目运行的程序。
参考实现与验证要点
在 main.cjs 的 Electron 解构导入中增加 clipboard,并在 app.whenReady 内现有 handle 注册处加入下列处理器。复用的 handle 已包含来源校验和结果封装,所以 operation 只负责这项业务的参数与操作。
handle('notes:copy-text', async input => {
if (typeof input !== 'string' || [...input].length > 20000) {
const error = new Error('INVALID_INPUT');
error.code = 'INVALID_INPUT';
throw error;
}
await clipboard.writeText(input); // Electron 44 的此 API 返回 Promise,完成后再报告成功。
return { copied: true };
});
preload.cjs 中传给 exposeInMainWorld 的对象增加下面这个方法。页面调用 window.desktopNotes.copyText(body.value),检查 ok 后再显示成功;通信失败仍需要 catch。
这里特意使用 await:本项目固定版本 Electron 44.3.0 的 clipboard.writeText 返回 Promise。不能照搬早期同步示例,在剪贴板写入完成前就向页面报告成功。升级时应查对应版本的 clipboard 契约。
copyText: text => ipcRenderer.invoke('notes:copy-text', text),
desktop.d.ts 中 Window.desktopNotes 的对象类型增加对应声明,参数为 string,返回 Promise<Result<{ copied: true }>>。同时把 INVALID_INPUT 文案改为适用于多种能力的通用提示,或为复制增加独立错误码,避免复制失败时显示“标题长度错误”。验收应包含中文、空字符串、超长正文、非字符串以及页面来源拒绝;真正的系统剪贴板结果必须在桌面环境检查。
练习二:解释一次版本冲突,而不是绕过它#
设磁盘版本为五,窗口甲和窗口乙都基于版本五编辑。甲先保存成功到版本六,乙还提交 expectedRevision 为五。请解释为什么乙应该收到冲突,以及为什么“失败后自动把 expectedRevision 改成六再保存”会破坏保护作用。
参考答案
乙的草稿没有包含甲的修改。如果自动替换版本号重试,就等于让乙声称自己已经基于版本六编辑,但这个声明是假的。正确做法是保留乙的草稿,读取当前版本,让用户比较并决定如何合并,再基于已确认的新版本提交。后续可以做字段级合并或编辑历史,但不能通过删除冲突检查来获得表面的成功率。
当前应用只有一个窗口和单实例入口,存储检查仍使用两个并发请求验证这一规则,因为请求可能来自重复动作或后续扩展。它证明的是同一仓库实例中的顺序与版本约束,不证明两个独立进程同时写文件的正确性。若需求扩展到多进程写入,需引入可靠事务或专门存储服务。
练习三:损坏数据为什么必须留下证据#
读取 note.json 时,ENOENT 与 JSON 解析失败能否都返回默认空笔记?如果不能,它们分别代表什么?请画出默认数据被错误保存后覆盖原内容的路径。
参考答案
ENOENT 表示目标文件不存在,首次运行可以合理返回默认内容。解析失败意味着文件存在,但当前程序无法解释它;这可能是内容损坏,也可能是程序版本与格式不兼容。此时返回默认数据会让界面看起来像首次启动,再次保存就可能用空内容覆盖唯一证据。正确处理是停止写入、提示错误、保留原文件,并先复制备份再人工排查。不能仅因为用户希望“能打开窗口”,就把数据损坏隐藏成成功。
收尾文件与本章验收#
node_modules/
dist/
.check-data/
.runtime-check/
# Vue + Electron 本地笔记实验
手册「完整 Vue 桌面笔记实验」的全部源码。Vue 3.5.42、Vite 8.3.0、Electron 44.3.0。
需要本机 Node(建议受支持的 Node 24 LTS)和 npm。安装及首次获取 Electron 二进制需要联网;本应用不调用模型、不上传笔记、不需要账号。
## 运行
1. 在本目录执行 `npm ci`;Windows 可使用 `npm.cmd ci`。
2. `npm start`:构建 Vue 页面并启动 Electron 窗口。
3. 输入标题和正文,手动保存。关闭再打开后重新读取已保存版本。
开发时开两个终端:先运行 `npm run dev:web`,再运行 `npm run dev:desktop`。单独访问网页只能看预览,不能访问 preload 的本地保存能力。
## 验证
- `npm test`:输入、读写、版本冲突、损坏文件保护和协议路径的 Node 检查。
- `npm run build`:Vue 编译。
- `npm run check:runtime`:启动无窗口 Electron,输出其内嵌运行时版本;这不是 GUI 或 IPC 验收。
真实桌面保存、键盘、重启与开发模式需按教材中的步骤操作。本实验没有安装包、签名、自动更新或真实 GUI 自动化验收。
## 保存规则
笔记写入 `app.getPath('userData')` 下的 `learning-notes/note.json`,由主进程确定位置。renderer不能传入路径或任意IPC频道。主进程串行处理保存,并要求版本匹配;损坏文件会报错并保留。
只在点击保存成功后提交数据,关闭窗口不会自动保存未提交修改。同目录临时写入加重命名减少半写文件的风险,不承诺所有文件系统上的断电持久性;写队列仅协调当前进程。本例使用单实例锁,不能把它当作多进程数据库。
你应能在不关闭 contextIsolation 或 sandbox 的情况下运行项目,解释三个运行位置,追踪一次保存的参数、来源校验、磁盘步骤和返回状态。你还应能证明旧版本提交被拒绝、失败不锁死队列、损坏文件不被默认值覆盖,并说明纯 Node 检查、Vue 构建、Electron 无窗口启动与实际桌面验收各自证明了什么。
进一步的产品化学习进入本地数据、后台任务、打包与分发。这份源码是一个边界完整的小实验,不包含安装器、代码签名、自动更新、云同步和 AI 调用。扩展功能时保留现有检查,每增加一项系统能力,就增加明确契约和对应的失败验收。
官方参考#
- Electron 进程模型:区分主进程、渲染进程和 preload。
- Electron IPC 教程:请求响应模式与 contextBridge。
- Electron 安全建议:隔离、沙箱、导航与发送者校验。
- Electron 自定义协议:协议注册时机与 session 范围。
- Node 24 文件系统 API:文件句柄、同步、重命名和并发写入约束。
- Vite 配置:构建环境、资源基础路径和插件配置。