本页目录

完整实战:Vue 桌面笔记应用

从 Vue 草稿到受控 IPC、本地保存与错误恢复,提供全部源码和可下载实验。

L2 · 能交付约 20 分钟阅读含示例、练习与验收

建议先读:Vue 与 Electron 的开发、构建和桥接工作流用户目录、配置写入与本地数据可靠性IPC 请求、事件、取消与错误合同

本页内容

这个项目把前面的知识接成什么#

我们做一个真正具有桌面边界的本地笔记程序。你使用熟悉的 Vue 编写界面,在 Electron 窗口里输入标题和正文,点击保存,由主进程把数据写到应用的数据目录。关闭应用以后再次启动,能重新读到上次保存的版本。整个业务过程不需要账号、模型服务或远程后端。

这个练习的难点不是文本框,而是让每一层承担正确职责。Vue 知道用户正在编辑什么;preload 提供少量明确的方法;主进程决定谁能调用这些方法、数据放在哪里;存储模块负责校验、排队、冲突和损坏文件保护。读完这一章,你应该能够从一次按钮点击追踪到磁盘变化,也能从磁盘错误反向找到界面上的提示。

先完成Node 与 Electron 学习路线中的基础章节。本章把全部文件集中展示,适合在理解单个机制后组装;如果你还不知道主进程是什么,直接复制整个目录运行,只能得到一个会启动的程序,难以判断哪里出了问题。

下载完整桌面笔记实验源码。压缩包包含这里展示的全部源文件、依赖锁文件、说明和检查程序。正文不展开机器生成的 package-lock.json,下载包保留完整文件,可直接使用 npm ci。阅读教材仍可完全离线;第一次安装实验依赖及 Electron 二进制需要联网。

先看一次保存经过哪些边界#

text
用户输入标题和正文
    ↓ 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;端口被占用时明确报错,不悄悄换到另一端口,否则主进程允许的地址与实际页面不一致。

终端一:Vue 热更新
npm.cmd run dev:web
终端二:Electron 开发窗口
npm.cmd run dev:desktop

只在浏览器中打开 Vite 地址,会看到“网页预览”的提示,本地保存不可用。这不是接口故障,因为普通浏览器没有执行 Electron 的 preload。理解这个现象比在浏览器里模拟出一个假的 desktopNotes 全局对象更有价值:同一套界面可以渲染,不等于它拥有相同的系统能力。

项目结构与每个文件的用途#

text
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。

package.json
{
  "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 脚本或在线图片,因此断开网络后已经安装好的实验仍可使用。

index.html
<!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 只用于样式,不是允许任意内联脚本。

vite.config.mjs
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 的地址都视为同一个可信来源。

lib/policy.cjs
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,而不是依赖页面自觉调用正确接口。

main.cjs
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 本身。调用方不能通过改一个频道字符串变成系统命令执行器。每个方法对应一项可说明、可检查的业务能力,这会让前后端契约一样清晰地出现在桌面边界上。

preload.cjs
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 永远不会执行,第一次失败就会把整个仓库锁死。

lib/repository.cjs
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 用较旧结果覆盖新输入。真实复杂编辑器也可以不禁用输入,但那需要保存开始时的草稿版本、当前草稿版本与磁盘版本分别建模;先理解这个最小模型,再增加并发编辑体验。

src/main.js
import { createApp } from 'vue';
import App from './App.vue';
createApp(App).mount('#app');
src/desktop.d.ts
// 编辑器类型提示不会替代 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,外部数据也仍然要校验。

src/App.vue
<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 时看到桌面应用。

scripts/launch.cjs
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; });
scripts/runtime-check.cjs
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 或文件界面操作已通过桌面验收。

本次 Windows 运行输出
{"electron":"44.3.0","node":"24.20.0","chrome":"152.0.7977.78","platform":"win32"}

自动检查:分别证明存储规则与地址规则#

测试使用 Node 自带的 node:test。存储检查操作自己创建的临时目录,在清理前核对绝对路径和目录前缀,确保不会删除学习者的真实笔记。新建仓库实例重新读取能证明数据确实写进文件,而不是只停留在上一个对象的内存里。

checks/repository.cjs
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');
}));
checks/policy.cjs
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 只负责这项业务的参数与操作。

main.cjs 新增处理器
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 契约

preload.cjs 对象中的新增成员
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 表示目标文件不存在,首次运行可以合理返回默认内容。解析失败意味着文件存在,但当前程序无法解释它;这可能是内容损坏,也可能是程序版本与格式不兼容。此时返回默认数据会让界面看起来像首次启动,再次保存就可能用空内容覆盖唯一证据。正确处理是停止写入、提示错误、保留原文件,并先复制备份再人工排查。不能仅因为用户希望“能打开窗口”,就把数据损坏隐藏成成功。

收尾文件与本章验收#

.gitignore
node_modules/
dist/
.check-data/
.runtime-check/
README.md
# 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 调用。扩展功能时保留现有检查,每增加一项系统能力,就增加明确契约和对应的失败验收。

官方参考#

原有课程整理于 2026-09-10;Node / Electron 扩充于 2026-09-11。示例环境与验证范围以正文为准。
原创中文学习手册,阅读结构参考 Vue 文档;非 Vue 官方教材。
下载本章 Markdown

支持中文和英文全文搜索 · ↑ ↓ 选择 · Enter 打开 · Esc 关闭