1. 为什么我做了个写 Markdown 的本地笔记软件
1.1 看 Markdown 源码看到眼花的日常
如果你写过技术博客、维护过 GitHub 仓库的 README、或者用 Markdown 记录学习笔记,一定经历过这样的场景:打开一个 .md 文件,满屏都是 #、**、[]()、` 这样的标记符号。内容稍微多一点,标题层级一乱,就分不清哪段是正文、哪段是代码、哪段是链接。更难受的是,写完还得复制到在线编辑器里预览,确认格式没问题之后再粘贴回本地文件。
这种"源码编辑 + 外部预览"的割裂体验,其实很不自然。我们写 Markdown 的目的是快速记录,而不是跟语法符号搏斗。后来我陆续试过几类工具:
- 本地文本编辑器:VSCode、Sublime Text。功能强,但是需要装一堆插件才能做到"写完直接看到成品"。
- 在线笔记平台:语雀、Notion、飞书文档。编辑体验不错,但数据都在云端,网络不好、断网、内容敏感、想长期归档的时候都不太方便。
- 经典 Markdown 编辑器:Typora。体验很好,但很多版本需要付费,而且对部分用户来说,还是希望有一款完全开源、数据完全在自己手里的工具。
几圈比较下来,我决定自己做一个"写完就是成品"的本地 Markdown 笔记软件。核心诉求很简单:打开软件,左边写 Markdown 源码,右边实时看到渲染后的成品效果,数据默认保存在本地磁盘,整个过程不需要联网,也不需要把内容交给第三方服务器。这篇文章就把完整的实现思路、核心代码、关键原理和踩坑点整理出来。
1.2 本地笔记软件和在线笔记的本质区别
很多人在选笔记工具时,纠结的核心其实是"数据放在哪里"。
在线笔记的优势是跨设备同步方便,手机、电脑随时打开。但代价也很明显:你的每一篇笔记都经过别人的服务器,内容安全和隐私完全依赖平台策略;一旦平台调整收费模式、停止服务或者账号异常,笔记数据就可能受到影响。
本地笔记软件把数据主权拿回了自己手里:
- 所有 .md 文件都在你自己的磁盘上,格式是纯文本,任何文本编辑器都能打开。
- 不依赖网络,不依赖服务商,断网也可以正常读写。
- 文件可以被 Git 管理,每次修改都有历史记录,误删了也能找回。
- 后续想换工具,直接把 .md 文件带走就行,没有任何迁移成本。
当然,本地笔记也牺牲了一部分便利性,比如实时多端同步需要自己搭同步方案。但这恰恰是它适合的场景:写技术文档、做知识归档、保存不便于上传到云端的私密内容。本文实现的笔记软件,正是围绕"本地优先 + 即时渲染"这两个核心点来设计。
1.3 本文实现的功能范围
为了让文章落地而不空谈,我把这个笔记软件的范围控制在一条完整但不过度复杂的链路里:
- 使用 Electron 构建桌面应用,跨平台运行。
- 左侧编辑区输入 Markdown 源码,右侧实时渲染成品效果。
- 支持打开本地 .md 文件、保存文件、新建文件。
- 支持代码块高亮、GFM 表格、删除线等常用扩展语法。
- 编辑区和渲染区滚动位置联动。
- 提供可运行的完整项目代码,并说明如何打包成桌面应用。
最终效果就是:你不再需要"写完复制到别处预览",也不需要一堆插件组合。开箱即用,写完 Markdown 源码的同时,右侧已经呈现最终的文档成品。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型:用什么技术来做这个笔记软件
2.1 桌面应用框架:Electron 还是 Tauri
做本地桌面笔记软件,绕不开桌面应用框架的选择。目前主流方案有两个阵营。
Electron 的优点是生态成熟、资料多、上手快。它内置了 Chromium 和 Node.js,Markdown 解析、文件读写、DOM 渲染这些能力都很容易组合起来。缺点是打包体积偏大,内存占用相对高。对于一个以"打开文件 + 编辑 + 渲染"为核心的笔记软件来说,Electron 完全够用,而且开发效率最高。
Tauri 是 Rust + WebView 的轻量方案,打包体积小、内存占用低,但需要对 Rust 有一定的了解,涉及文件系统、窗口能力时需要写 Rust 代码,入门门槛略高。
考虑到本文重点在 Markdown 渲染链路,而不是桌面壳子本身的性能极致优化,选 Electron 更合适。核心原因有三个:
- 纯 JavaScript 技术栈,前端开发者零额外成本。
- 文件读写直接用 Node.js 的 fs 模块,不需要桥接层。
- 社区资料和打包工具链成熟,遇到问题能快速搜到解决方案。
如果你在真正的产品中更看重安装包体积,后续可以调研 Tauri 迁移方案,但架构思路是相通的。
2.2 Markdown 解析器:markdown-it 还是 marked
Markdown 源码渲染成 HTML,本质是"字符串解析 + AST 生成 + HTML 输出"的过程。常用的 JavaScript 解析器有 marked、markdown-it、remark 等。
以 markdown-it 为例,它的核心流程是:
- 把 Markdown 源码通过词法分析拆分成多个 token。
- 对 token 进行规则匹配,区分标题、段落、列表、引用、代码块等类型。
- 把 token 渲染成对应的 HTML 标签。
- 输出完整 HTML 字符串,交给页面展示。
选择 markdown-it 的理由也比较实际:
- 插件机制成熟,通过
markdown-it-container可以扩展自定义块级容器。 - 内置 CommonMark 规范支持,配合
markdown-it-gfm插件可以识别表格、删除线、任务列表。 - 渲染速度足够快,输入过程中能实时解析,不会产生明显卡顿。
相比 rem编写一个 parse 自己的语法树,markdown-it 的 API 对普通开发者更友好。在项目里只需要安装依赖、创建解析器、配置插件,然后调用 md.render() 就能拿到 HTML。
2.3 项目目录结构
整个项目采用 Electron 标准结构,尽量保持简单清晰:
text复制markdown-notes/
├── package.json
├── main.js # Electron 主进程
├── index.html # 界面骨架
├── renderer.js # 渲染进程逻辑
└── styles/
└── markdown.css # Markdown 成品排版样式
主进程(main.js)负责创建窗口、调用 Node.js 读取和保存文件。渲染进程(renderer.js + index.html)负责编辑器交互、Markdown 解析、渲染结果展示。
这个结构虽然简单,但已经能完整覆盖"打开文件、编辑、实时预览、保存"的全流程。
3. Markdown 渲染的核心原理
3.1 从源码到成品的完整流程
理解 Markdown 渲染,最关键的是建立一条链路意识:Markdown 源码 → 解析器 → HTML 字符串 → DOM 节点 → 浏览器显示。
每次用户在编辑区敲入一个字符,我们执行的操作是:
- 获取编辑区的原始文本。
- 调用 markdown-it 的
render()方法解析文本。 - 得到 HTML 字符串后,设置到右侧预览区的
innerHTML。 - 浏览器根据 HTML 结构自动排版。
这四步看起来简单,但有几个细节决定了最终体验:
- 解析不能阻塞 UI。输入过程中如果进行大量解析,最好做防抖,例如 200ms 延迟后再解析。
- 代码高亮需要在 HTML 输出前或输出后额外处理,否则
<pre><code>里的代码没有颜色。 - 预览区的样式是"成品"的关键。同样的 HTML,不同 CSS 渲染结果差异巨大,所以需要单独写一份适合阅读的 Markdown 排版样式。
3.2 手写一个最小 Markdown 解析流程
为了理解底层原理,可以先不看第三方库,手写一个非常简化的解析逻辑。以下代码只处理标题和加粗,但足以解释"解析"的本质:
javascript复制// 文件路径:examples/mini-parser.js
function miniParse(markdown) {
const lines = markdown.split('\n');
let html = '';
for (const line of lines) {
// 标题:以 # 开头
let headingMatch = line.match(/^(#{1,6})\s+(.*)/);
if (headingMatch) {
const level = headingMatch[1].length;
const content = headingMatch[2];
html += `<h${level}>${content}</h${level}>`;
continue;
}
// 加粗:**文本**
let boldText = line.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
html += `<p>${boldText}</p>`;
}
return html;
}
console.log(miniParse('## 标题\n这是一段**加粗**文本'));
// 输出:
// <h2>标题</h2><p>这是一段<strong>加粗</strong>文本</p>
可以看到,Markdown 解析的本质就是正则规则匹配和 HTML 标签拼接。真实项目里,markdown-it 做的事情比这个复杂得多,但核心思想是一致的。理解这一点后,再看第三方库的配置项,就不会觉得黑盒。
3.3 markdown-it 的常用基础配置
实际项目中我们不会手写解析器,而是直接用 markdown-it。最小用法是:
javascript复制// 文件路径:examples/basic-render.js
const MarkdownIt = require('markdown-it');
const md = new MarkdownIt({
html: true, // 允许源码中写 HTML
linkify: true, // 自动识别链接
typographer: true // 优化标点符号
});
const result = md.render('# 你好,Markdown');
console.log(result);
// <h1>你好,Markdown</h1>
基础配置里三个选项比较关键:
html: true。默认情况下,Markdown 源码中的 HTML 标签会被转义成实体。打开这个选项,源码里写的<span>、<img>等标签会直接生效。做笔记软件时我建议打开,方便插入一些自定义标签。linkify: true。当源码中出现裸链接(没有用[]()包裹的网址)时,自动转成可点击的<a>标签。注意这个选项在开启html时会有一定的 XSS 风险,如果笔记内容来源不可信,需要谨慎。typographer: true。会把一些 ASCII 符号替换成更美观的标点,例如(c)转成 ©。
3.4 代码高亮与 GFM 扩展语法
代码高亮是笔记软件里使用频率最高的能力。markdown-it 本身只负责把代码块包成 <pre><code class="language-xxx">,亲测不含高亮逻辑。这里主流做法有两种:
- markdown-it 官方支持通过
highlight回调返回高亮后的 HTML。 - 渲染完成后,用 highlight.js 统一扫描
<code>元素重新着色。
推荐第二种,实现更简单:
html复制<link rel="stylesheet" href="//cdn.jsdelivr.net/gh/highlightjs/cdn-release@latest/build/styles/github.min.css">
<script src="//cdn.jsdelivr.net/gh/highlightjs/cdn-release@latest/build/highlight.min.js"></script>
渲染完成后执行:
javascript复制document.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
});
GFM(GitHub Flavored Markdown)扩展语法包括表格、任务列表、删除线、自动链接等。通过 markdown-it-gfm 插件可以一次性启用:
bash复制npm install markdown-it-gfm
然后:
javascript复制const MarkdownIt = require('markdown-it');
const markdownItGfm = require('markdown-it-gfm');
const md = new MarkdownIt()
.use(markdownItGfm);
启用后,源码里的表格会渲染成规范的 <table> 结构,任务列表 - [ ] 会渲染成复选框,删除线 ~~文本~~ 也会生效。这对写技术文档、做 TODO 清单是刚需。
4. 完整实战:从零实现本地 Markdown 笔记软件
4.1 初始化项目
首先创建项目目录并初始化 npm:
bash复制mkdir markdown-notes
cd markdown-notes
npm init -y
安装 Electron、markdown-it、markdown-it-gfm:
bash复制npm install electron markdown-it markdown-it-gfm
package.json 中配置启动命令。Electron 的版本更新较快,这里不锁定具体版本,以你安装时的最新稳定版为准:
json复制{
"name": "markdown-notes",
"version": "1.0.0",
"description": "本地 Markdown 笔记软件,写完即看到成品",
"main": "main.js",
"scripts": {
"start": "electron ."
},
"author": "",
"license": "MIT"
}
4.2 编写主进程 main.js
主进程负责创建窗口和文件读写。这里不直接用渲染进程的 fs,而是通过主进程暴露文件操作能力,保持权限分离。
javascript复制// 文件路径:main.js
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const fs = require('fs');
const path = require('path');
function createWindow() {
const win = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: true,
contextIsolation: false
}
});
win.loadFile('index.html');
}
// 打开文件:读磁盘内容返回给渲染进程
ipcMain.handle('file:open', async () => {
const result = await dialog.showOpenDialog({
filters: [{ name: 'Markdown', extensions: ['md', 'markdown'] }],
properties: ['openFile']
});
if (result.canceled || result.filePaths.length === 0) return null;
const filePath = result.filePaths[0];
const content = fs.readFileSync(filePath, 'utf-8');
return { filePath, content };
});
// 保存文件:把渲染进程传入的内容写入磁盘
ipcMain.handle('file:save', async (event, content) => {
const result = await dialog.showSaveDialog({
filters: [{ name: 'Markdown', extensions: ['md'] }],
defaultPath: '未命名.md'
});
if (result.canceled || !result.filePath) return false;
fs.writeFileSync(result.filePath, content, 'utf-8');
return result.filePath;
});
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
这里需要注意 nodeIntegration: true 和 contextIsolation: false 是为了方便在渲染进程直接使用 Node.js 能力。这在 demo 项目里没问题,但如果后续要处理不可信的远程内容,需要打开 contextIsolation 并改用 preload 脚本。
4.3 编写界面骨架 index.html
界面采用左侧编辑区、右侧预览区的双栏布局。中间加一个分隔条,方便调整比例。
html复制<!-- 文件路径:index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>本地 Markdown 笔记</title>
<link rel="stylesheet" href="styles/markdown.css">
<link rel="stylesheet" href="//cdn.jsdelivr.net/gh/highlightjs/cdn-release@latest/build/styles/github.min.css">
</head>
<body>
<div id="toolbar">
<button id="btn-open">打开</button>
<button id="btn-save">保存</button>
<span id="file-name">未命名</span>
</div>
<div id="container">
<textarea id="editor" placeholder="在这里输入 Markdown 源码,右侧实时显示成品效果……"></textarea>
<div id="preview"></div>
</div>
<script src="//cdn.jsdelivr.net/gh/highlightjs/cdn-release@latest/build/highlight.min.js"></script>
<script src="renderer.js"></script>
</body>
</html>
预览区的 HTML 是动态生成的,最终内容由 Markdown 渲染结果决定。样式文件独立成 markdown.css,在这份样式里详细定义了标题、引用、代码块、表格、图片等元素的成品外观。
4.4 编写渲染逻辑 renderer.js
渲染进程是核心,它完成"输入 → 解析 → 渲染 → 高亮"这条链路。
javascript复制// 文件路径:renderer.js
const MarkdownIt = require('markdown-it');
const markdownItGfm = require('markdown-it-gfm');
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true
}).use(markdownItGfm);
const editor = document.getElementById('editor');
const preview = document.getElementById('preview');
const fileName = document.getElementById('file-name');
// 渲染动作:解析 Markdown,输出 HTML,再统一做代码高亮
function render() {
const content = editor.value;
const html = md.render(content);
preview.innerHTML = html;
document.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
});
}
// 输入防抖:避免每敲一个字符都做一次完整解析
let timer = null;
editor.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(render, 200);
});
// 工具栏:打开文件
document.getElementById('btn-open').addEventListener('click', async () => {
const result = await window.api.openFile();
if (!result) return;
editor.value = result.content;
currentFilePath = result.filePath;
fileName.textContent = result.filePath.split(/[\\/]/).pop();
render();
});
// 工具栏:保存文件
document.getElementById('btn-save').addEventListener('click', async () => {
const filePath = await window.api.saveFile(editor.value);
if (!filePath) return;
fileName.textContent = filePath.split(/[\\/]/).pop();
});
// 初始化:默认塞入一篇示例文档
editor.value = `# 欢迎使用本地 Markdown 笔记软件
这是一段**加粗**文字,这是*斜体*文字。
## 功能列表
- [x] 实时渲染
- [ ] 导出 PDF
- [ ] 多标签页
## 代码块示例
\`\`\`javascript
console.log("hello world");
\`\`\`
`;
render();
window.api 是主进程和渲染进程之间的桥接。为了让上面的代码可以运行,还需要在 main.js 创建窗口时把 ipcRenderer 暴露到全局:
javascript复制// 文件路径:preload.js(需要新增)
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('api', {
openFile: () => ipcRenderer.invoke('file:open'),
saveFile: (content) => ipcRenderer.invoke('file:save', content)
});
然后调整 main.js 中的窗口配置:
javascript复制const win = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
});
这里做了一个很重要的改版:从 nodeIntegration: true + contextIsolation: false 改成了 preload + contextIsolation: true。从安全角度考虑,contextIsolation: true 能隔离渲染进程和 Node.js 全局环境,即使 Markdown 内容里嵌入了恶意脚本,也不容易直接调用系统能力。配合 preload 脚本只暴露 openFile 和 saveFile 两个方法,就是最小权限原则。
4.5 编写 Markdown 成品样式
没有好的样式,渲染出来的 HTML 就是一堆没有灵魂的标签。markdown.css 要覆盖标题层级、段落、列表、引用块、代码块、表格等场景。核心逻辑是"让预览区拥有接近成品的阅读体验"。
css复制/* 文件路径:styles/markdown.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#toolbar {
height: 40px;
background: #f5f5f5;
border-bottom: 1px solid #ddd;
display: flex;
align-items: center;
padding: 0 10px;
gap: 8px;
}
#toolbar button {
padding: 4px 14px;
cursor: pointer;
border: 1px solid #ccc;
background: #fff;
border-radius: 4px;
}
#toolbar button:hover {
background: #e8e8e8;
}
#file-name {
color: #666;
font-size: 13px;
margin-left: auto;
}
#container {
display: flex;
height: calc(100vh - 40px);
}
#editor {
width: 45%;
height: 100%;
border: none;
border-right: 1px solid #ddd;
outline: none;
padding: 16px;
font-family: "JetBrains Mono", Consolas, "Courier New", monospace;
font-size: 14px;
line-height: 1.7;
resize: none;
}
#preview {
width: 55%;
height: 100%;
overflow-y: auto;
padding: 24px 32px;
background: #fff;
}
/* 以下是 Markdown 成品的排版样式 */
#preview h1,
#preview h2,
#preview h3,
#preview h4,
#preview h5,
#preview h6 {
margin-top: 1.2em;
margin-bottom: 0.6em;
font-weight: 600;
line-height: 1.3;
}
#preview h1 {
font-size: 2em;
border-bottom: 1px solid #eee;
padding-bottom: 0.3em;
}
#preview h2 {
font-size: 1.5em;
border-bottom: 1px solid #eee;
padding-bottom: 0.3em;
}
#preview h3 {
font-size: 1.25em;
}
#preview p {
margin-bottom: 1em;
line-height: 1.8;
}
#preview ul,
#preview ol {
margin: 0.5em 0 1em 1.5em;
}
#preview li {
line-height: 1.8;
margin-bottom: 0.3em;
}
#preview blockquote {
margin: 1em 0;
padding: 0.5em 1em;
border-left: 4px solid #2f6fed;
background: #f6f8fa;
color: #555;
}
#preview code {
background: #f6f8fa;
border-radius: 3px;
padding: 0.2em 0.4em;
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 0.9em;
}
#preview pre {
background: #f6f8fa;
border-radius: 6px;
padding: 16px;
overflow-x: auto;
margin-bottom: 1em;
}
#preview pre code {
background: none;
padding: 0;
font-size: 0.9em;
}
#preview table {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
}
#preview th,
#preview td {
border: 1px solid #ddd;
padding: 8px 12px;
text-align: left;
}
#preview th {
background: #f6f8fa;
font-weight: 600;
}
#preview img {
max-width: 100%;
}
这份样式看似基础,但核心价值在于把"源码"和"成品"分离开。编辑区保持等宽字体,预览区保持优雅的阅读排版,视觉密度、层级、间距都按成品标准来。
4.6 实现编辑区与预览区同步滚动
Markdown 笔记软件如果只有一个预览区,编辑时看不到成品;如果只有源码区,又回到了传统编辑器的老路。双栏布局下,最影响体验的问题是滚动位置不同步。
写代码时,滚动条在左侧;阅读成品时,滚动条在右侧。要做一个粗略的同步滚动,思路是根据左侧滚动百分比,换算右侧目标位置的百分比。但双侧内容高度不同,直接按百分比同步误差不小。更精准的做法需要计算映射关系:
- 在预览区找当前视口顶部对应的 DOM 节点。
- 根据它在预览区所有节点中的相对位置,换算编辑区的滚动位置。
为了让你快速跑起来,这里先给一个简化版实现,逻辑是左侧滚动不同步,而是右侧预览跟随左侧的滚动比例进行滚动。
javascript复制// 渲染进程追加:同步滚动(简化版)
let syncing = false;
editor.addEventListener('scroll', () => {
if (syncing) return;
syncing = true;
const editorScrollPercent = editor.scrollTop / (editor.scrollHeight - editor.clientHeight);
const previewMaxScroll = preview.scrollHeight - preview.clientHeight;
preview.scrollTop = previewMaxScroll * editorScrollPercent;
setTimeout(() => { syncing = false; }, 50);
});
注意这里在同步时设置了 syncing 标记,避免反向滚动触发事件循环引发抖动。之所以用 setTimeout 而不是直接同步,是因为 scroll 事件触发频率很高,加一个 50ms 的冷却时间能减少无效计算,滚动体验也更稳定。
如果想实现双向同步,即滚动右侧时左侧也跟随,可以在 preview 上再挂一个同样的 scroll 监听,逻辑对称。这里留给你作为扩展练习。
4.7 运行与验证
完成以上文件后,回到项目根目录执行:
bash复制npm start
预期效果是:
- 窗口打开后,编辑区默认有一段示例文档。
- 右侧预览区立即显示渲染后的标题、列表、代码块。
- 修改编辑区任意内容,约 200ms 后预览区自动更新。
- 点击"打开"按钮,可以在系统文件对话框中选择本机任意 .md 文件。
- 点击"保存"按钮,对话框中选择保存路径,内容写入磁盘。
代码块部分会通过 highlight.js 自动着色。表格、任务列表由 markdown-it-gfm 负责解析。
4.8 打包为桌面应用
开发跑通后,可以考虑打包成独立可执行文件。Electron 生态常用的打包工具是 electron-builder。
安装:
bash复制npm install --save-dev electron-builder
在 package.json 中增加:
json复制"scripts": {
"start": "electron .",
"build": "electron-builder"
}
然后执行:
bash复制npm run build
打包产物输出到 dist/ 目录。Windows 会生成 exe 安装包,macOS 生成 dmg。依赖下载可能需要网络环境支持,这类工具在国内使用时如果遇到下载失败,通常需要配置镜像源,具体按你的网络环境调整即可。
5. 常见问题与排查思路
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 打开应用后窗口白屏 | 主进程文件路径错误,或 index.html 中引用的本地文件路径不正确 | 检查 loadFile('index.html') 路径是否和实际文件一致,确认 renderer.js 能正常加载 |
| 点击打开文件按钮无反应 | preload 脚本未暴露 window.api,或 IPC 通道名不一致 |
检查 preload.js 中的 exposeInMainWorld 方法和 ipcMain.handle 事件名是否对应 |
| Markdown 表格渲染不出来 | 未启用 GFM 扩展 | 确认已安装 markdown-it-gfm 并通过 .use() 注册 |
| 代码块没有高亮颜色 | highlight.js 初始化时机太早,代码块尚未渲染 | 确保在 preview.innerHTML 赋值之后再执行 querySelectorAll 高亮逻辑 |
| 编辑大文件时输入很卡 | 每次输入都触发全量渲染,没有防抖 | 参考上文使用 setTimeout 防抖,或改为节流方案 |
| 保存文件后内容丢失 | 编码问题或写入前未拿到最新内容 | 确认使用 'utf-8' 编码写入,并且在调用 save 前同步 editor.value |
| 多屏或高分屏下界面字体模糊 | Electron 缩放策略问题 | 一般不影响功能,可以按操作系统显示设置调整窗口 DPI 策略 |
部分问题需要按报错信息继续排查。遇到 IPC 通信问题时,在渲染进程代码里加一行 console.log(window.api),如果打印出来是 undefined,说明 preload 脚本没有执行成功。检查 BrowserWindow 的 preload 路径是否用了绝对路径,建议用 path.join(__dirname, 'preload.js')。
6. 工程化与最佳实践
6.1 源码与数据分离
写这类笔记软件时,很多人会陷入一个误区:把用户笔记文件和代码文件混在同一个目录里。正确的做法是把应用代码和用户数据严格分离:
- 代码目录只负责程序逻辑、依赖、图标、打包配置。
- 用户数据目录独立存放,例如使用 Electron 的
app.getPath('documents')作为默认笔记目录。 - 首次运行时自动创建笔记根目录,不存在时再创建。
这样做有实际好处:备份笔记时只需要复制数据目录,升级应用不会影响已有文件;使用 Git 管理源码时,也不会误把用户笔记提交进仓库。
6.2 用 Git 管理笔记历史
本地笔记相比在线笔记,丢失风险其实更高——硬盘损坏、误删文件、误覆盖内容,都可能造成数据丢失。缓解这个问题的最佳方案不是复杂的数据库,而是 Git。
在笔记根目录初始化仓库:
bash复制git init
git add .
git commit -m "notes: 初始提交"
每当完成一批内容更新,手动或自动做一次提交。配合 Git 服务(私有仓库或自建 GitLab),笔记就有了完整的版本历史,任何一个文件都可以回滚到任意时间点。这是在保持"本地优先"的前提下,达到"远程备份"和"多端同步"的最轻量方案。
如果不想命令行操作,也可以在软件里集成一个"提交快照"按钮,调用 child_process 执行 git add -A && git commit。但要注意在真实产品中先检查 Git 是否可用,并对错误做兜底。
6.3 提升编辑体验的几个细节
基础功能跑通之后,真正拉开体验差距的是细节。
- 自动保存:每输入一段时间后自动
writeFile,而不是让用户手动点保存。建议做定时自动保存,默认每隔 1 到 3 分钟写一次文件。 - 未保存状态提示:标题栏或标签页显示一个实心圆点,表示当前文件有未保存的修改。关闭窗口前拦截并提示,防止误关丢失内容。
- 文件拖拽打开:把 .md 文件拖进窗口自动打开。
- 字数统计:状态栏显示当前文档字数、行数,对写作场景非常有用。
- 导出 HTML / PDF:既然已经渲染出了 HTML,导出 PDF 只需要调用系统打印能力,或者通过 Electron 的
webContents.printToPDF()实现。
这些功能大多可以在现有架构上直接扩展,不用推翻重来。
6.4 安全边界与 Markdown 内容信任
使用 html: true 配置意味着 Markdown 里写的 HTML 会被直接渲染。如果打开的是自己写的笔记,这没有问题;但如果打开从网上下载的 .md 文件,里面可能包含 <script> 标签或带有内联事件属性的 HTML。渲染进页面后,这些内容有一定安全风险。
在 Electron 渲染进程中,默认的安全策略会限制部分脚本执行,但真正稳妥的做法是提高安全意识:
- 用 preload 脚本暴露最小权限接口,而不是完全开放
nodeIntegration。 - 对渲染出的 HTML 做 Sanitize 处理,过滤
<script>、on*事件属性。 - 不处理的 Markdown 内容来源只限于自己信任的文件。
由于当前教程为了保持代码路径足够精简,没有引入 Sanitize 库。如果你计划开发正式产品,建议引入 dompurify,在把 HTML 写入 preview 之前做一次净化。
6.5 后续扩展方向
这个项目已经形成了完整的 Markdown 渲染底座,可以继续扩展的方向非常多:
- 多标签页管理,支持同时打开多个 .md 文件。
- 文件树侧边栏,方便管理整个笔记目录。
- 全文搜索,在本地建立标题和关键词索引。
- 数学公式渲染,引入 KaTeX 处理
$...$和$$...$$。 - 图片粘贴上传,自动保存到笔记目录下的
assets文件夹,并将引用路径维护好。 - 移动到 Tauri 阵营,进一步降低安装包体积和内存占用。
每一步扩展都可以独立成一篇博客来写。从架构角度看,当前方案最大的优势是"Markdown 解析和渲染"这个核心链路已经稳定,扩展功能时不需要动底座。
7. 总结与下一步学习路线
这篇文章围绕"写完就是成品的本地 Markdown 笔记软件"这个目标,完整实现了从项目初始化、Electron 主进程、渲染进程、Markdown 解析、代码高亮、同步滚动到打包发布的全流程。核心收获有四点:
- 理解了 Markdown 源码到成品的渲染链路:源码 → 解析器 → HTML → DOM → 排版。
- 掌握了 Electron 主进程和渲染进程的分工,以及用 preload 暴露最小权限接口的安全写法。
- 学会了 markdown-it 和 markdown-it-gfm 的配置方法,能处理表格、任务列表、删除线等常见扩展语法。
- 了解了本地笔记软件的工程化要点:数据目录与代码分离、Git 版本管理、自动保存、安全净化。
如果要继续深入,下一阶段建议按这个顺序推进:
- 加强渲染体验:引入数学公式插件 KaTeX,支持 mermaid 流程图等图表的渲染。注意 mermaid 需要通过
md.render()前后处理或者通过 DOM 扫描初始化,这是很好的学习课题。 - 完善编辑器能力:接入 CodeMirror 或 Monaco Editor 替换原生
<textarea>,获得语法高亮、行号、自动缩进等能力。 - 做一套笔记管理方案:设计合理的文件目录结构、标签体系、全文搜索方案。
- 考虑跨端同步:不依赖第三方云平台,自建一个基于 Git 的同步方案。
如果这篇文章对你有帮助,可以收藏备用。有问题也欢迎在评论区交流,我会尽量根据实际运行效果给出排查建议。下一步的实践方向是:把你最常用的 Markdown 笔记内容装进这个软件里,用一段时间,观察自己的使用习惯,再确定下一步优先开发哪个功能。
