很多写技术文档的人都有这种体验:Markdown 源码越写越多,眼睛越来越花。在编辑器和预览窗口之间来回切换,想定位到某一行时,要么盯着源码数层级,要么等预览重新渲染;如果使用在线笔记平台,又要担心网络、账号和格式导出问题。与其继续忍受“源码和成品分离”的割裂体验,不如自己动手做一个本地运行的 Markdown 笔记软件——写完就是成品,输入即渲染,数据完全留在本机,并且代码开源、可以按自己的需求继续改造。
这篇文章会从一个相对完整的开源小项目出发,拆解如何用 Electron 和开源所见即所得编辑器 Vditor 实现一款本地 Markdown 笔记软件。文章会覆盖技术选型、目录结构、主进程与渲染进程通信、本地文件存储、自动保存、导出 PDF、常见问题排查等完整链路。文章中的核心代码都是可复制、可运行的,你可以直接照着搭一个最小可用版本,再根据自己的习惯扩展主题、搜索、快捷键和导出功能。
1. 背景与核心概念
在正式写代码之前,先理清楚两个问题:为什么需要“所见即所得”的 Markdown 笔记软件,以及“本地优先”到底解决了什么。
1.1 传统 Markdown 编辑体验的痛点
Markdown 本身是一种轻量级标记语言,用 #、**、- 等符号描述标题、加粗、列表和代码块。它最大的优点是纯文本、易存储、易迁移,几乎所有程序员都离不开它。但 Markdown 的缺点也很明显:源码和渲染结果是两套形态,写起来不够直观。
常见的编辑方式有两种。第一种是在 VSCode、Sublime Text 这类代码编辑器里直接写源码,再通过插件开一个预览面板。双栏布局虽然能同时看到源码和结果,但滚动联动、光标定位、图片显示经常需要额外配置,而且长期盯着两栏内容很容易视觉疲劳。第二种是使用在线笔记平台,这种方式渲染体验好一些,但数据存到第三方服务器,格式和导出能力受限,也不适合作为个人知识库的长期载体。
“写完就是成品”的编辑体验,核心是指所见即所得:输入 # 标题 时,按下回车,光标所在行直接显示为标题样式;输入加粗语法时,文字立刻变成加粗体,而不是等你切到预览面板才看到效果。这种交互方式能大幅降低阅读成本,也让人更愿意在笔记里使用标题、列表、引用、表格等结构化语法。
1.2 本地优先解决的核心问题
本地优先,或者说 Local-first,是一种适合个人笔记和知识库的应用设计思路。它的核心原则是:数据产生于本地、存储于本地、按需同步,而不是默认上传到服务器。
对本项目来说,选择本地优先有四个直接收益:
- 数据完全归属用户。笔记文件以标准
.md格式保存在本机,随时可以复制、备份、打包,不依赖任何平台账号。 - 离线可用。没有网络也能正常新建、编辑和导出笔记,这对写作场景非常重要。
- 隐私可控。本地数据不被第三方服务器采集,用户可以自行决定是否同步、如何同步。
- 方便技术上的二次开发。Markdown 文件本身就是开放格式,可以直接用 Git 管理历史,也可以自己写脚本做批量处理。
当然,本地优先也有一些需要解决的问题,比如多端同步、数据防丢失、文件备份策略等。这些不会在最小版本里全部解决,但会在后面的章节中给出工程化建议。
1.3 本项目的产品形态与适合人群
这个小项目的产品形态很清晰:一个跨平台桌面应用,左侧是笔记列表,右侧是编辑器。编辑器采用所见即所得模式,输入 Markdown 语法时直接渲染标题、加粗、列表、引用、代码块和表格。笔记数据自动保存为本地 .md 文件,重启应用后列表自动恢复。
这个项目适合三类人:
- Markdown 重度使用者。想拥有一个开源、可控、完全离线的本地笔记工具,同时希望编辑体验具备商业软件的流畅感。
- Electron 入门学习者。通过一个真实的小型桌面应用,理解主进程、渲染进程、preload 脚本、IPC 通信和原生文件读写。
- 开源爱好者。项目结构简单清晰,可以在此基础上继续扩展搜索、同步、加密、主题等功能。
接下来的技术选型部分会说明为什么选择 Electron 和 Vditor,以及它们在整个架构中的位置。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型拆解:Electron + Vditor
技术选型不是追求“最强”,而是追求“够用且可控”。下面说明本项目选择 Electron 和 Vditor 的理由,并与常见替代方案做对比。
2.1 Electron:本地桌面应用的跨平台容器
Electron 是 GitHub 开源的一个桌面应用框架,它把 Chromium 浏览器和 Node.js 运行时打包在一起,开发者可以使用 HTML、CSS、JavaScript 开发跨平台桌面软件。用 Electron 开发本地笔记软件有三个明显优势:
- 跨平台。同一套代码可以运行在 Windows、macOS、Linux 上,不需要为每个平台分别维护原生界面。
- 生态成熟。Electron 的 IPC、窗口管理、文件访问、自动更新等能力都有完善文档和大量开源示例。
- 前端技术栈。如果你是前端开发者,不需要额外学习 Rust、C++ 或 Swift,直接用原生 JavaScript 就可以完成整个应用。
Electron 的代价是包体积比较大、内存占用偏高。当前也有一些更轻量的替代方案,比如 Tauri,它把渲染层放在系统 WebView 上,然后用 Rust 与系统层交互。Tauri 体积更小、性能更好,但要求开发者具备 Rust 基础,综合学习成本比 Electron 高。对于个人开发者快速验证产品、以及本项目“以笔记应用为载体学习桌面开发”的目标,Electron 依然是稳妥的选择。
Electron 的架构可以简单分成两层:主进程负责窗口管理、系统 API 和 Node.js 能力;渲染进程负责界面渲染和用户交互。两者不能直接访问对方,需要通过 IPC(进程间通信)来传数据。这也是后面代码中最核心的概念之一。
2.2 Vditor:开箱即用的所见即所得编辑器
Vditor 是一个开源的 Markdown 编辑器,由中文开发者社区维护,支持多种编辑模式:
- 所见即所得模式,写作时直接显示渲染后的效果。
- 即时渲染模式,在保留 Markdown 源码标记的同时,对已经完成的语法块做渲染。
- 分屏预览模式,左侧源码、右侧效果。
本项目的核心场景是“写完就是成品”,所以默认使用 wysiwyg(所见即所得)模式。Vditor 内置了对标题、加粗、斜体、列表、任务列表、表格、引用、代码块、图片、数学公式等 Markdown 语法的渲染支持,也提供工具栏、主题、快捷键等配置项。与从零实现一个编辑器相比,Vditor 能省下大量时间,同时源码开源、文档完善,便于后续定制。
使用 Vditor 还有一个好处:它把 Markdown 解析和渲染细节封装得比较完整,我们不需要自己维护 Grammar 或 DOM 渲染逻辑。作为本地笔记应用,我们只需要把精力放在编辑器交互和文件存储这两个重点上。
2.3 其他开源方案对比
除了 Vditor,还有几个常见的开源 Markdown 编辑器方案可以参考:
| 方案 | 特点 | 适合场景 |
|---|---|---|
| Vditor | 开箱即用、中文文档、支持三种编辑模式 | 快速集成到桌面应用或 Web 页面 |
| Milkdown | 基于 ProseMirror,插件化程度高,可深度定制 | 想构建自定义编辑器内核的团队 |
| Toast UI Editor | 功能丰富,Markdown 所见即所得,组件化 | Web 端富文本笔记产品 |
| CodeMirror + preview | 源码编辑能力强,预览需要额外组装 | 偏代码编辑场景 |
对于本项目来说,Vditor 是性价比最高的方案:不需要理解 ProseMirror 的插件机制,也不需要自行组装工具栏、代码高亮和 Markdown 解析器。它“自带成品感”,正好匹配标题中“写完就是成品”的理念。
3. 环境准备与项目初始化
3.1 开发环境
要运行本项目,需要准备以下环境:
- Node.js 18 或更高版本。Electron 和 npm 包都依赖 Node.js 环境。
- npm 或 pnpm,用于安装依赖。
- 一台装有 Windows、macOS 或 Linux 的电脑,开发阶段不限操作系统。
- 可选:VS Code 或其他代码编辑器,方便阅读代码。
Electron 安装包比较大,国内开发者如果下载缓慢,可以把 npm 镜像切换到 npmmirror.com,例如在项目根目录新建 .npmrc 文件,写入:
ini复制registry=https://registry.npmmirror.com
electron_mirror=https://npmmirror.com/mirrors/electron/
这里不需要把镜像配置写入代码仓库,它只属于个人开发环境。镜像设置和具体工具版本属于环境相关配置,团队协作时建议让每个成员根据实际情况自行调整。
3.2 创建项目并安装依赖
项目名称可以叫 local-markdown-notes。先初始化目录:
bash复制mkdir local-markdown-notes
cd local-markdown-notes
npm init -y
然后安装 Electron 和 Vditor。Electron 通常是开发依赖,Vditor 是运行依赖:
bash复制npm install electron --save-dev
npm install vditor
安装完成后,修改 package.json。Electron 应用需要入口文件,这里设置为 src/main/main.js。scripts.start 用于启动应用:
json复制{
"name": "local-markdown-notes",
"version": "1.0.0",
"description": "本地优先的所见即所得 Markdown 桌面笔记",
"main": "src/main/main.js",
"scripts": {
"start": "electron ."
},
"devDependencies": {
"electron": "^31.0.0"
},
"dependencies": {
"vditor": "^3.9.5"
}
}
依赖版本说明:上面的 ^31.0.0 和 ^3.9.5 只是示例写法,Electron 和 Vditor 都在持续更新,实际安装时请以官方 npm 页面显示的最新稳定版本为准。不同版本之间的 API 基本兼容,但如果遇到异常,第一优先级是查看官方文档的迁移说明,而不是盲目套用旧代码。
3.3 目录结构设计
Electron 应用通常分出主进程、预加载脚本、渲染进程三部分。本项目目录结构如下:
text复制local-markdown-notes/
├── package.json
├── src/
│ ├── main/
│ │ └── main.js # 主进程入口
│ ├── preload/
│ │ └── preload.js # 预加载脚本,安全暴露 IPC 接口
│ └── renderer/
│ ├── index.html # 渲染进程页面
│ ├── index.css # 页面样式
│ └── renderer.js # 渲染进程业务逻辑
主进程负责创建窗口、访问文件系统、处理 IPC 请求;渲染进程负责编辑器初始化、笔记列表展示和用户交互;preload 脚本是主进程与渲染进程之间的一座“安全桥”,用来把主进程的能力暴露给页面,同时避免渲染进程直接使用 Node.js API。
这个分离设计并不是多余的。Electron 在安全性上建议关闭渲染进程的 Node.js 集成,只允许通过 preload 暴露的接口与主进程通信。这样可以避免页面中加载到恶意脚本时直接操作操作系统文件。后面的代码会贯彻这套安全模型。
4. 主进程与本地数据层
4.1 数据目录设计:一个 Markdown 文件即一篇笔记
笔记存储采用“一个 .md 文件就是一篇笔记”的方式,而不是把全部笔记塞进一个数据库。这样做的好处是:
- Markdown 本身就是一种开放格式,用户拿到的数据文件在任何编辑器中都能打开。
- 文件系统天然具备“按文件管理”的能力,备份、迁移、逐篇删除都很直观。
- 后续如果要接 Git 做版本管理,可以按文件溯源。
笔记文件的存储位置选在 Electron 提供的 app.getPath('userData') 目录下,再追加一个 notes 子目录。用户数据目录是一个按应用区分的固定路径,跨平台安全,不会污染用户桌面或文档目录。文件命名使用带随机后缀的 ID,例如 note-1715000000000-abc123.md。这样做的核心原因是避开中文文件名和特殊字符在部分文件系统中的兼容性问题,同时避免标题变化导致文件路径频繁变更。
下面代码在主进程中维护笔记目录,并保证目录存在。
4.2 主进程窗口与 IPC 通道
src/main/main.js 是主进程入口,完成四件事:
- 读取或创建笔记目录。
- 创建主窗口。
- 注册 IPC handler,处理渲染进程发来的笔记读写请求。
- 控制外部链接打开方式,防止新窗口直接使用 WebContents。
完整代码如下:
js复制const { app, BrowserWindow, ipcMain, shell } = require('electron');
const fs = require('fs');
const path = require('path');
let mainWindow;
let notesDir;
function getNotesDir() {
if (!notesDir) {
notesDir = path.join(app.getPath('userData'), 'notes');
}
return notesDir;
}
function ensureNotesDir() {
const dir = getNotesDir();
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function extractTitle(content, fallback) {
const lines = content.trim().split('\n');
for (const line of lines) {
if (/^#\s+/.test(line)) {
return line.replace(/^#\s+/, '').trim();
}
}
return fallback || '未命名笔记';
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
title: 'Local Markdown Notes',
webPreferences: {
preload: path.join(__dirname, '../preload/preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
});
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
// 外部链接交给系统浏览器打开,而不是在当前应用窗口里跳转
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
}
app.whenReady().then(() => {
ensureNotesDir();
console.log('笔记目录:', getNotesDir());
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
这里有几个细节值得展开:
contextIsolation: true会把渲染进程的 JavaScript 上下文与 preload 脚本隔离,降低被页面脚本篡改的风险。nodeIntegration: false表示渲染进程不能直接使用require('fs')等 Node.js 模块。sandbox: true进一步限制渲染进程的权限。preload 脚本只使用contextBridge和ipcRenderer,这两个模块在 sandbox 环境下也能正常使用。setWindowOpenHandler的作用是拦截window.open,并把链接转交给系统浏览器,避免笔记里不小心插入恶意链接时直接打开内部窗口。
4.3 笔记增删改查实现
主进程需要使用 ipcMain.handle 来响应渲染进程的请求。这里实现列表、读取、新建、保存、删除五个接口,对应文件系统操作。
js复制ipcMain.handle('notes:list', async () => {
ensureNotesDir();
const files = fs.readdirSync(getNotesDir()).filter((file) => file.endsWith('.md'));
return files.map((filename) => {
const filePath = path.join(getNotesDir(), filename);
const stats = fs.statSync(filePath);
const content = fs.readFileSync(filePath, 'utf-8');
const id = filename.replace(/\.md$/, '');
return {
id,
title: extractTitle(content, filename),
updatedAt: stats.mtimeMs
};
}).sort((a, b) => b.updatedAt - a.updatedAt);
});
ipcMain.handle('notes:read', async (event, id) => {
if (!/^[a-z0-9-]+$/i.test(id)) {
throw new Error('非法笔记标识');
}
ensureNotesDir();
const filePath = path.join(getNotesDir(), `${id}.md`);
if (!fs.existsSync(filePath)) {
return '';
}
return fs.readFileSync(filePath, 'utf-8');
});
ipcMain.handle('notes:create', async () => {
ensureNotesDir();
const id = `note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const filePath = path.join(getNotesDir(), `${id}.md`);
fs.writeFileSync(filePath, '', 'utf-8');
return { id };
});
ipcMain.handle('notes:save', async (event, id, content) => {
if (!/^[a-z0-9-]+$/i.test(id)) {
throw new Error('非法笔记标识');
}
ensureNotesDir();
const filePath = path.join(getNotesDir(), `${id}.md`);
const tempPath = `${filePath}.tmp`;
fs.writeFileSync(tempPath, content, 'utf-8');
fs.renameSync(tempPath, filePath);
return { ok: true };
});
ipcMain.handle('notes:delete', async (event, id) => {
if (!/^[a-z0-9-]+$/i.test(id)) {
throw new Error('非法笔记标识');
}
ensureNotesDir();
const filePath = path.join(getNotesDir(), `${id}.md`);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
return { ok: true };
});
对 id 做正则校验是必不可少的。原因很直接:id 会拼接到文件路径中,如果不加限制,用户传入 ../../evil 之类的字符串就可能导致路径穿越,从而读取或删除非预期文件。作为本地应用,我们依然要在边界上做防御。
列表接口会在每次打开应用时读取所有 .md 文件,并提取标题。标题提取逻辑很简单:从内容中找第一行一级标题 #,找不到就返回“未命名笔记”。这样就不需要额外维护标题索引文件,减少数据不一致的概率。
4.4 原子写入:如何避免笔记文件损坏
保存笔记时要注意一个潜在问题:如果直接 writeFileSync(filePath, content),而写入过程中进程退出或断电,文件可能只写入一半,导致整篇笔记损坏。
更稳妥的做法是分两步写入:
- 先把内容写入同目录下的临时文件。
- 用
rename把临时文件替换为目标文件。
rename 是同一文件系统内的原子操作,可以保证要么旧文件完整保留,要么新文件完整覆盖。即使写入临时文件时突然中断,最多只会残留一个 .tmp 文件,不会影响正式笔记文件。
上述 notes:save handler 中已经体现了这个思路。如果项目后续要增加自动备份,也可以在保存前复制一份 .bak 文件,或者用一个简单的队列串行化写入,避免多个保存请求并发触发文件竞争。
5. 渲染进程与 Vditor 集成
5.1 index.html 页面结构
渲染进程页面分成左右两部分:左侧是笔记列表,右侧是编辑器容器。页面需要引入 Vditor 的 CSS 和 JavaScript 文件。因为在开发阶段是通过 loadFile 加载本地 HTML,所以可以用相对路径引用 node_modules 里的依赖:
html复制<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:;">
<title>Local Markdown Notes</title>
<link rel="stylesheet" href="../../node_modules/vditor/dist/index.css" />
<link rel="stylesheet" href="index.css" />
</head>
<body>
<div class="layout">
<aside class="sidebar">
<header class="sidebar-header">
<span class="app-title">本地笔记</span>
<button id="new-note-btn" title="新建笔记">+</button>
</header>
<div class="search-box">
<input type="search" id="search-input" placeholder="搜索标题或内容" />
</div>
<ul id="note-list" class="note-list"></ul>
<footer class="sidebar-footer">
<span id="note-count">0</span> 篇笔记
</footer>
</aside>
<main class="editor-pane">
<div id="editor"></div>
</main>
</div>
<script src="../../node_modules/vditor/dist/index.min.js"></script>
<script src="renderer.js"></script>
</body>
</html>
这里有三个容易踩坑的点:
- 页面里不要直接写内联
onclick之类的处理器,因为 CSP 的script-src默认禁止内联脚本。事件绑定统一放在renderer.js中。 - CSP 里
img-src加入了data: https:,是为了让编辑器能显示笔记中常见的 base64 图片和网络图片。 - 开发阶段使用
../../node_modules/vditor/dist/相对路径没问题,但打包发布时node_modules的路径不再稳定。推荐后续用 electron-vite 或静态资源复制脚本把 Vditor 的dist目录复制到src/renderer/vendor中,再用相对路径引用。
5.2 初始化 Vditor 编辑器
src/renderer/renderer.js 是渲染进程的核心逻辑。它负责初始化 Vditor、维护当前笔记 ID、渲染列表、防抖自动保存以及处理搜索和删除。
基本代码如下:
js复制const newNoteBtn = document.getElementById('new-note-btn');
const noteListEl = document.getElementById('note-list');
const noteCountEl = document.getElementById('note-count');
const searchInput = document.getElementById('search-input');
let currentId = null;
let vditor = null;
let saveTimer = null;
function createEditor() {
vditor = new Vditor('editor', {
height: window.innerHeight - 10,
mode: 'wysiwyg',
cache: { enable: false },
placeholder: '开始输入你的笔记……',
input() {
scheduleSave();
}
});
}
function scheduleSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveCurrentNote();
}, 800);
}
async function saveCurrentNote() {
if (!currentId || !vditor) return;
const content = vditor.getValue();
await window.api.saveNote(currentId, content);
}
Vditor 的初始化配置中,mode: 'wysiwyg' 表示所见即所得模式,cache: { enable: false } 表示关闭编辑器自身的浏览器缓存。这里关闭缓存是因为本地笔记已经有文件系统持久化,避免编辑器在修改前自动覆盖用户数据。
input() 回调会在每次输入时触发。我们在这里不是立刻保存文件,而是使用 800ms 防抖,把短时间内的连续输入合并为一次保存请求。这个时间段比较平衡:既不会在每次按键时都写一次磁盘,也不会因为间隔太长导致内容丢失。
window.api 是 preload 脚本暴露给渲染进程的对象,下一篇会说明它的实现。
5.3 笔记列表与编辑器联动
渲染进程需要把左侧列表和右侧编辑器联动起来。核心步骤是:渲染列表时点击项目打开对应笔记;新建笔记时先创建空文件,再打开;删除笔记时二次确认并刷新列表。
js复制async function refreshList() {
const notes = await window.api.listNotes();
noteListEl.innerHTML = '';
notes.forEach((note) => {
const li = document.createElement('li');
li.className = 'note-item' + (note.id === currentId ? ' active' : '');
li.textContent = note.title;
li.addEventListener('click', () => openNote(note.id));
li.addEventListener('dblclick', async (event) => {
event.stopPropagation();
if (window.confirm(`删除“${note.title}”?`)) {
await window.api.deleteNote(note.id);
if (currentId === note.id) {
currentId = null;
vditor.setValue('');
}
refreshList();
}
});
noteListEl.appendChild(li);
});
noteCountEl.textContent = notes.length;
}
async function openNote(id) {
currentId = id;
const content = await window.api.readNote(id);
vditor.setValue(content);
refreshList();
}
newNoteBtn.addEventListener('click', async () => {
const { id } = await window.api.createNote();
await openNote(id);
});
createEditor();
refreshList();
打开笔记时,先用 vditor.setValue(content) 把 Markdown 内容写入编辑器,然后刷新列表,让当前项高亮。由于 Vditor 在所见即所得模式下会解析 Markdown 并渲染 DOM,这里不需要额外调用预览方法。
双击删除虽然加了一层 confirm 确认,但仍是比较冒险的交互。在生产版本中,更推荐右键菜单删除、或者把删除按钮放在侧边栏项目上,避免误触。后面的工程化章节会继续讨论这个问题。
5.4 防抖自动保存增强
上面的 scheduleSave 已经实现了防抖,但仅靠防抖还不够。如果用户在保存间隔内直接点击系统关闭按钮,最后几秒的输入可能没有落盘。因此可以增加一个兜底策略:每隔 60 秒强制保存一次,同时在页面不可见时立即保存。
js复制async function saveCurrentNote() {
if (!currentId || !vditor) return;
const content = vditor.getValue();
await window.api.saveNote(currentId, content);
}
setInterval(() => {
if (document.visibilityState === 'hidden' || !saveTimer) {
saveCurrentNote();
}
}, 60000);
window.addEventListener('pagehide', () => {
saveCurrentNote();
});
document.visibilityState 在 Electron 渲染进程中同样有效,当窗口最小化或隐藏时触发保存,可以在大多数场景下避免数据丢失。当然,极端情况下仍可能有数据丢失风险,因此“每篇笔记是独立 Markdown 文件 + 定期备份”的架构价值就体现出来了。
5.5 页面样式
src/renderer/index.css 使用 flex 布局,左侧固定宽度侧边栏,右侧自适应编辑器区域。代码比较简单:
css复制* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
}
.layout {
display: flex;
height: 100vh;
}
.sidebar {
width: 280px;
border-right: 1px solid #e5e5e5;
display: flex;
flex-direction: column;
background: #f7f7f7;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
font-size: 16px;
font-weight: 600;
border-bottom: 1px solid #e5e5e5;
}
.search-box {
padding: 10px 12px;
}
.search-box input {
width: 100%;
padding: 8px 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
}
.note-list {
flex: 1;
overflow-y: auto;
list-style: none;
}
.note-item {
padding: 10px 16px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-item:hover {
background: #f0f0f0;
}
.note-item.active {
background: #e8f0fe;
font-weight: 500;
}
.editor-pane {
flex: 1;
min-width: 0;
padding: 4px 0;
}
.sidebar-footer {
padding: 10px 16px;
font-size: 12px;
color: #999;
border-top: 1px solid #e5e5e5;
}
这一段样式只做了最基础的布局和交互反馈,没有引入 CSS 框架。Vditor 本身带有完整的编辑器样式,工具栏和内容区的显示效果已经足够。
6. 运行验证与产物体检
6.1 启动命令
在项目根目录执行:
bash复制npm start
如果一切正常,Electron 窗口会打开,并且终端会打印:
text复制笔记目录: /Users/xxx/Library/Application Support/local-markdown-notes/notes
不同操作系统下 userData 路径不同:Windows 通常在 %APPDATA% 下,macOS 在 ~/Library/Application Support 下,Linux 在 ~/.config 下。打印这个路径是为了方便调试数据落盘问题。
6.2 功能测试清单
按下面顺序手动验证一遍:
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| 1 | 点击“+”新建笔记 | 右侧编辑器清空,列表出现“未命名笔记” |
| 2 | 输入 # 标题 并回车 |
标题立即渲染为一级标题样式 |
| 3 | 输入加粗、列表、引用、表格 | 内容实时以对应 Markdown 风格展示 |
| 4 | 切换点击其他笔记 | 编辑器内容切换,列表高亮跟随 |
| 5 | 重启应用 | 笔记列表完整恢复,内容未丢失 |
| 6 | 双击某篇笔记并确认删除 | 列表删除该笔记,对应文件被移除 |
这里尤其要验证第 2 步和第 5 步。第 2 步确认所见即所得模式真的生效,而不是退化成源码编辑;第 5 步确认本地持久化链路完整。
6.3 数据落盘检查
应用运行一段时间后,打开终端打印出的笔记目录,应该能看到多个 .md 文件。文件内容就是编辑器里的 Markdown 原文。
可以用下面命令快速检查某个笔记的内容:
bash复制cat "/Users/xxx/Library/Application Support/local-markdown-notes/notes/note-xxx.md"
如果文件存在并且内容正确,说明渲染进程、preload、IPC、主进程、文件系统这条链路全部打通。这也是本地优先架构最有说服力的验证方式:应用关闭后,数据依然以开放格式存在磁盘上。
7. 进阶增强:导出、搜索、主题与备份
最小版本已经解决了“编辑 + 本地存储”的核心需求。接下来这些功能会根据实际使用场景逐渐变得必要。
7.1 导出 PDF
很多笔记不只是给自己看,还需要分享给同事、客户或学生。导出 PDF 是比较刚需的能力。Electron 的 webContents.printToPDF() 可以直接把 HTML 页面渲染成 PDF。
实现思路是:渲染进程拿到 Vditor 已经渲染好的 HTML,发给主进程;主进程组装一个完整 HTML 页面,然后通过隐藏窗口加载它并打印为 PDF。
主进程可以增加一个 handler:
js复制ipcMain.handle('notes:export-pdf', async (event, htmlContent) => {
const win = BrowserWindow.fromWebContents(event.sender);
const htmlFile = path.join(app.getPath('temp'), `export-${Date.now()}.html`);
const fullHtml = `<!DOCTYPE html><html><head><meta charset="utf-8" /></head><body>${htmlContent}</body></html>`;
require('fs').writeFileSync(htmlFile, fullHtml, 'utf-8');
const result = await win.webContents.printToPDF({
printBackground: true
});
require('fs').writeFileSync(htmlFile.replace(/\.html$/, '.pdf'), result);
return { ok: true };
});
上面的代码是最简版本,直接输出到临时目录,没有弹保存对话框。生产版可以使用 dialog.showSaveDialog 让用户选择保存位置。这里需要注意:webContents.printToPDF 的参数和返回值在不同 Electron 版本有细节差异,应优先查看当前版本的官方文档。
7.2 全文搜索
当笔记数量从几十篇增长到几百篇时,左侧列表按时间排序已经不够用,需要全文搜索。可以在主进程增加一个简单扫描接口:
js复制ipcMain.handle('notes:search', async (event, keyword) => {
ensureNotesDir();
const files = fs.readdirSync(getNotesDir()).filter((file) => file.endsWith('.md'));
const results = [];
for (const file of files) {
const filePath = path.join(getNotesDir(), file);
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(keyword)) {
results.push({
id: file.replace(/\.md$/, ''),
title: extractTitle(content, file)
});
}
}
return results;
});
然后在渲染进程的 search-input 事件中调用该接口,把结果渲染到列表中。这种按序扫描的实现方式适合笔记量在千篇以内的场景,优点是零依赖、实现简单。如果笔记量更大,可以引入轻量级全文检索库,比如 minisearch 或 flexsearch,在启动时建立索引,搜索响应会快得多。
7.3 用 Git 管理笔记历史
本地 Markdown 文件天然适合用 Git 做版本管理。你可以在笔记目录执行 git init,然后在每次保存后提交一次变更。这样做的好处是:
- 可以回溯任意时间段的内容。
- 误删除笔记后可以恢复。
- 不同主题分支可以在不同目录切换。
简单实现思路是:主进程保存接口完成后,通过 child_process.execFile 执行 git add -A 和 git commit -m "update"。但这里有两个坑:
- 笔记目录默认在用户数据目录,可能不处于 Git 仓库中,需要先初始化。
git命令不是 Node.js 内置能力,需要系统已安装 Git,并且要考虑路径和权限问题。
因此这个功能更适合做成“手动执行”或“设置面板中的一键备份”,而不是默认强制开启。如果目标是自动备份,也可以直接用文件系统复制目录到备份路径。
7.4 主题切换与数学公式
Vditor 的 UI 主题、内容主题和代码高亮主题都支持配置,具体切换方法取决于 Vditor 版本。一般步骤是:渲染进程在设置面板中改变主题配置,再调用 Vditor 提供的主题更新接口,同时更新页面根节点的 CSS 变量。
对于写技术笔记的用户,Markdown 数学公式也是常见需求。Vditor 内置了数学公式渲染能力,可以通过工具栏按钮或配置项开启。这个功能在本地笔记中非常实用,尤其在记录算法、机器学习和理工科知识时。
这些扩展共同说明一个道理:开源组件的“可定制性”是本地笔记软件的重要加分项。你不需要等平台发布某个功能,直接在自己仓库里加代码即可。
8. 常见问题排查
本地 Electron 应用的问题往往集中在路径、模块加载和 IPC 环境上。下表是几个高频问题:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 窗口打开但白屏 | 渲染页面路径错误或 Vditor 资源加载失败 | 检查 loadFile 路径,检查 Vditor CSS/JS 是否成功加载 |
| Vditor 样式错乱 | 静态资源路径不对,或 CSP 阻止了样式 | 用 DevTools 查看网络请求,调整资源路径 |
渲染层 window.api 为 undefined |
preload 路径错误、contextIsolation 未开启或 sandbox 配置不当 | 检查 webPreferences,检查 preload 文件是否输出日志 |
| 笔记内容保存为空 | 自动保存时机不对,或 getValue() 调用过早 |
在 input 回调中保存,并增加定时兜底 |
| 文件名乱码或无法创建 | 标题中包含特殊字符 | 使用随机 ID 作为文件名,避免用标题直接命名 |
| 中文内容乱码 | 文件编码不一致 | 统一使用 utf-8 读写文件 |
8.1 白屏与模块加载失败
白屏是 Electron 开发中最常见的问题。先用 DevTools 的 Network 面板排查:打开窗口后按 Ctrl+Shift+I 或 Cmd+Option+I,查看控制台报错和网络请求。
常见的报错是:
text复制Failed to load resource: net::ERR_FILE_NOT_FOUND
这表示 HTML 中引用的文件路径不存在。开发阶段 Vditor 的路径是 ../../node_modules/vditor/dist/,从 src/renderer/index.html 出发,向上两级到项目根目录,再进入 node_modules,方向是正确的。但如果项目结构调整,这个相对路径就会失效。
更稳妥的思路是使用构建工具,把 Vditor 和业务代码一起打包到 dist/renderer 目录,这样路径就由构建工具统一处理,开发环境和生产环境都能保持一致。
8.2 Vditor 样式错乱
Vditor 样式错乱通常有两类原因:
- CSS 文件没有加载。样式文件路径错误,或者 CSP 的
style-src没有允许内联样式。Vditor 在运行时可能会动态向页面插入 style 标签,如果 CSP 只允许外部样式,编辑器 UI 就会出现异常。开发阶段可以在 CSP 中加入style-src 'self' 'unsafe-inline'。 - 编辑器高度为 0。容器没有明确高度时,Vditor 可能计算不出来真实高度。给
#editor设置固定高度,或者在 Vditor 配置中指定height,可以解决这类问题。
排查优先级:先看 DevTools 的 CSS 加载状态,再看 #editor 的 computed style。
8.3 preload 中的 API 在渲染层 undefined
如果渲染进程里 window.api 是 undefined,先确认三件事:
webPreferences.preload路径是否正确。路径要使用绝对路径,常见写法是用path.join(__dirname, '../preload/preload.js')。contextIsolation是否开启。只有开启 contextIsolation,contextBridge.exposeInMainWorld才会按预期暴露到页面。- preload 脚本本身是否抛错。比如在
sandbox: true环境中使用了 Node.js 内置模块,就会报错。本项目 preload 只依赖electron模块,因此可以正常运行。
可以在 preload.js 开头添加一行 console.log('preload executed'),然后在主进程 DevTools 的 Console 面板中查看是否打印。Electron 渲染进程的日志默认会输出到 DevTools 的控制台,方便定位问题。
9. 工程化与开源发布最佳实践
9.1 渲染进程安全边界
Electron 最危险的反模式是开启 nodeIntegration: true,然后在页面里写 require('fs')。一旦笔记中嵌入的攻击脚本被执行,攻击者就能直接读取本机文件。因此无论项目规模多小,都应该保持以下安全配置:
contextIsolation: truenodeIntegration: falsesandbox: true- 只通过 preload 暴露白名单 IPC 接口
IPC handler 内部还要对参数做校验。比如 notes:read、notes:save、notes:delete 接收的 id 必须匹配 ^[a-z0-9-]+$,避免路径穿越。这类输入校验是本地应用在防攻击方面最直接的手段。
生产环境发布前,还应该考虑对主进程代码做签名。macOS 应用建议启用公证(notarization),Windows 应用建议使用代码签名证书。签名后的应用不仅更安全,也能减少系统安全弹窗对用户造成的困扰。
9.2 本地笔记数据备份策略
本地优先意味着用户把数据安全责任交给了应用。作为开发者和使用者,都需要建立备份意识。几种可行的备份策略:
- 应用内部:每次保存前保留上一份
.bak文件,或者维护一个history目录存放最近几十次修改。 - 操作系统层面:引导用户把
notes目录纳入云盘同步目录。 - Git 版本管理:在笔记目录内维护一个 Git 仓库,定期提交。
- 导出层:提供批量导出为 Markdown、HTML、PDF 的能力,方便用户迁移到其他工具。
最推荐的是第二种方案,也就是“应用只管本地读写,备份交给用户指定的存储路径”。这样既保持应用简单,又给用户充分的自主权。
9.3 打包与发布准备
Electron 打包常用工具是 electron-builder。它可以生成 Windows 的 nsis 安装包、macOS 的 dmg、Linux 的 AppImage 等格式。一个常见的配置示例:
yaml复制appId: com.example.localnotes
productName: LocalMarkdownNotes
directories:
output: release
files:
- src/**/*
- package.json
win:
target: nsis
mac:
category: public.app-category.productivity
target: dmg
linux:
target: AppImage
打包时需要注意静态资源路径问题。如果渲染进程直接引用 ../../node_modules/vditor/dist/,打包后这段路径会失效。常见的解决方案是把 Vditor 的 dist 目录复制到项目内的静态资源目录,比如 src/renderer/vendor/,再修改 HTML 引用路径。
发布新版本时,建议维护一个 CHANGELOG.md,并把版本号、改动列表和下载方式写入 README。如果项目发布到 GitHub,可以配合 GitHub Actions 在推送 tag 时自动构建三平台安装包,这能显著降低人工打包成本。
9.4 开源项目的必备物料
既然项目定位是开源,就需要公开开源协议和基本文档,而不是只放一份代码。通常至少需要:
LICENSE文件。个人开源项目常用 MIT、Apache-2.0、GPL-3.0 等协议,选择前需要确认自己是否接受相应约束。README.md。用简洁的文档说明项目定位、运行方式、数据目录位置和功能截图。CONTRIBUTING.md。如果有社区协作计划,可以说明代码规范、提交格式和开发环境搭建方式。- 安全报告入口。如果是 GitHub 仓库,可以启用 Security Advisories,方便用户报告问题。
这些物料看起来繁琐,但真正决定一个开源项目能否被长期使用和维护的,往往就是这些“非代码”部分。项目的技术价值通过源码体现,项目可持续性则通过文档和社区机制体现。
10. 小结
通过这个开源小项目,我们可以清晰看到一条完整链路:用 Electron 搭建桌面应用外壳,用 Vditor 提供所见即所得编辑能力,用 preload 脚本安全桥接渲染进程与主进程,再用 Node.js 的文件系统 API 把每篇笔记保存为本地 Markdown 文件。
本文的核心代码量不大,但它覆盖了本地应用开发中最容易出现问题的几个环节:
- 主进程与渲染进程如何安全通信。
- IPC 参数校验如何防止路径穿越。
- 文件写入如何做到原子性。
- 所见即所得编辑器如何集成到 Electron 中。
- 自动保存如何平衡实时性和性能。
在此基础上,你可以继续扩展全文搜索、PDF 导出、Git 历史记录、主题切换、云同步等能力。也可以沿着安全加固的方向,把 CSP 收紧、加入签名和公证、设计更完善的备份策略。如果把这套代码发布到自己的开源仓库,它不仅能成为个人笔记工具,也是一份很好的 Electron 入门示例。
如果你正在寻找一款“写完就是成品”的本地 Markdown 笔记软件,也喜欢亲手掌控数据存储方式,不妨按文章里的步骤把项目跑起来,再加上你需要的功能。开源的好处就是:不满意的地方,随时可以自己改。
