本地Markdown笔记工具实践:Electron+React+TypeScript打造rea

发布时间:2026/10/21 15:34:07
本地Markdown笔记工具实践:Electron+React+TypeScript打造rea 1. 为什么是 rea这个项目到底要做什么这个项目的代号叫 rea全称是我自己硬凑出来的Rapid Editor Assistant。简单说它是一个跑在桌面上的本地 Markdown 笔记与素材管理工具。没有云同步没有多人协作没有会员体系核心功能只有三个打开本地 Markdown 文件、编辑并自动保存、把配图和附件收纳到同一个目录里。听起来没新意但如果你和我一样电脑里散落着几百个笔记文件、截图、临时文档就会知道“把散的东西收拢”本身已经很有价值。rea 的出发点很个人我平时会开很多临时文件夹写稿写到一半插入截图图片经常丢到桌面。隔一个月再翻想找一段文字时还要先猜文件在哪。在线文档能搜索但图片导入、目录结构、离线访问都不受我控制。rea 想做的是“我的本地文件我随时能打开所有辅助内容自动归位”这件事。如果你正准备用 Web 技术写桌面工具或者厌倦了在线文档又或者只是想看一个完整的小项目从选型到填坑的过程这篇文章应该能让你少走一点弯路。1.1 一个编辑器怎么解决文件碎片问题rea 的灵感其实来自一个特别小的使用场景。有一次我写一篇长文配图大概十几张截图分别放在桌面、下载目录、微信临时缓存里。写到后半段想调整顺序光找图就花了不少时间。rea 的做法是当你在编辑器里粘贴一张截图时它不把图片塞进 Markdown 文本而是把文件复制到当前笔记所在目录下的_assets文件夹并把 Markdown 里的引用路径改写成相对路径。这样笔记和配图永远待在一起之后用任何工具打开这个目录结构都是完整的。这个需求听起来很简单但它直接影响技术选型。如果做成网页插件浏览器出于安全限制读写本地文件会反复弹授权体验很割裂。如果做成命令行工具能操作文件却没法提供拖拽粘贴、实时预览这些自然交互。最早的判断就是它必须是一个桌面应用并且最好用我熟悉的前端技术来做这样才能把编辑体验和文件系统接在一起。1.2 为什么是桌面应用而不是网页这里我做过一次对比。浏览器里的 PWA 或者在线文档胜在随时随地打开但你永远要面对两个问题一是云服务一旦调整策略你的数据结构和访问方式都会被动变化二是离线编辑能力再强也无法替你把图片整理到本地目录。命令行工具则相反文件操作很强但交互反馈几乎为零写 Markdown 时的预览体验要额外凑合。桌面应用恰好站在这两者中间rea 的方向从一开始就很明确本地优先、文件可见、交互自然。用桌面 Web 技术写一个效率工具还有一个隐藏收益我可以复用大量前端生态里的成熟方案。Markdown 解析、代码高亮、拖拽上传、UI 组件都不用从零造。代价是 Electron 这类框架会带来比较大的包体积和内存占用。实际取舍后面会细说结论是如果你的目标不是做一个常驻内存的后台程序这个代价完全可以接受。2. 技术选型Electron React TypeScript 的取舍rea 的技术栈没走太偏Electron 做桌面壳React 做界面TypeScript 约束数据流Vite 负责开发构建。不是这套组合有多时尚而是它能让我用最少的时间把核心功能跑通。项目最初也犹豫过 Tauri但最终还是选 Electron理由很务实。2.1 为什么选 Electron 而不是 TauriTauri 的包体积和内存占用都比 Electron 好看这是事实。但 rea 的核心功能重度依赖 Node.js 的fs模块和 SQLite这些在 Tauri 里要么通过 Rust 命令重新实现要么用 sidecar 调外部进程。当时我评估下来开发和维护成本明显更高。Electron 的主进程直接就是 Node 环境文件读写、数据库、子进程都能用原生能力我的经验值可以全部复用。所以最终选了“不够轻但足够顺”的 Electron。这个选择题没有标准答案。如果你更在意启动速度和包体积并且熟悉 RustTauri 完全可以是首选。但如果你像我一样主要目标是快速交付一个稳定的个人工具选一个让你睡得着觉的技术栈更重要。实际测试下来rea 打包后的安装包体积确实在 100MB 以上但启动速度仍是秒开级别对于一个“打开写东西、写完关掉”的工具谈不上负担。2.2 主进程和渲染进程的职责边界Electron 最需要先想明白的是进程模型。主进程是 Node 环境负责窗口管理、文件读写、数据库、系统能力渲染进程是页面负责显示和交互。两边不能直接访问必须通过 IPC 通信。我习惯这样类比主进程是餐厅后厨渲染进程是前厅IPC 是传菜窗口。后厨不负责接待前厅不冲进厨房炒菜所有请求按订单走窗口传递。rea 里有一条硬性纪律渲染进程不碰任何 Node API。文件读取、写入、搜索、历史版本操作全部封装在大约 10 个 IPC 事件里渲染进程只调用 preload 暴露出来的白名单方法。这样做的直接好处是一旦页面里出现异常内容不会直接获得系统权限坏处是前期需要多写一层胶水代码但换来的是整个项目的心态安稳。// preload.ts contextBridge.exposeInMainWorld(reaAPI, { readNote: (filePath: string) ipcRenderer.invoke(rea:readNote, filePath), saveNote: (filePath: string, content: string) ipcRenderer.invoke(rea:saveNote, filePath, content), listNotes: (rootDir: string) ipcRenderer.invoke(rea:listNotes, rootDir), });这段代码是 rea 的一小块缩影。contextBridge会保证渲染进程拿到的reaAPI只能调用这几个方法而无法直接拿到底层的ipcRenderer。这也是官方长期建议的安全写法我踩过坑之后再回头看觉得这句提醒再怎么强调都不为过。2.3 本地数据存储文件优先SQLite 辅助rea 对数据存储有一个很固执的原则笔记正文永远是普通 Markdown 文件绝不写进数据库。搜索索引、文件元数据、历史版本记录才放在 SQLite 里。这么做的好处是即使某一天 rea 不更新了我的笔记仍然是一堆可读的文本文件用随手一个工具就能打开。数据库我用的是better-sqlite3它是同步 API在主进程里阻塞时间很短。它承担两类工作一是维护文件目录的元信息例如每个 Markdown 文件的行数、修改时间、标签二是全文搜索索引。全文搜索没有直接用 LIKE 糊弄过去是因为笔记数量到几千篇之后LIKE 查询会明显变慢而 SQLite 自带的 FTS5 适合这种单机场景。具体实现留在 3.3这里先记住结论找一个“文件可见、数据库只是辅助”的结构对个人知识库工具来说最稳。3. 核心功能拆解rea 的核心链路是怎么实现的再往下就是大家最感兴趣的部分了rea 的核心功能到底怎么实现。这个工具不复杂但每个功能都踩过几个真实的坑。我会按“编辑、附件、搜索、保存”四条线来讲。3.1 Markdown 编辑链路React 里接入 CodeMirror 6编辑器是 rea 的脸面我在 Monaco 和 CodeMirror 6 之间选了后者。Monaco 功能强大但重量也更明显对一个小工具来说有点杀鸡用牛刀。CodeMirror 6 的模块化架构更轻Markdown 高亮、自动补全、折叠这些常用能力都可以按需装配。接入时我遇到的第一个坑就是 React 组件生命周期和 CodeMirror 实例的冲突。如果你在useEffect里每次都新建EditorView然后又在依赖数组里放了某个会频繁变化的值组件重渲染时就会出现编辑器失焦、光标跳动甚至白屏。rea 的做法是EditorView实例只创建一次用useRef存住useEffect负责创建和销毁后续布局变化、主题变化、外部文档切换通过 CodeMirror 的Compartment动态重配置不重建实例。这个模式是我参考了很多开源编辑器的常见写法之后确定下来的不是最炫的方案但在实际使用中最稳。还有一个和中文输入法相关的细节编辑器不要做成完全受控组件。如果每次输入都触发 React state 更新再把新的内容传回 CodeMirror在中文拼音组合过程中经常出现候选词丢失或光标瞬移。rea 让 CodeMirror 自己维护文档模型React 这边只在保存、搜索、切换笔记时才主动读取或写入内容。这样代码会更“脏”一点但用户的输入体验是干净的。3.2 图片和附件管理从粘贴到归位图片处理是 rea 最重要的“收纳”能力。用户在编辑器里粘贴截图得到的是一段 data URL不能直接存进 Markdown。rea 的处理流程是先把 data URL 写入一个临时文件然后判断当前笔记是否已经保存到磁盘。如果笔记路径已知就直接把图片移动到_assets目录文件名按时间戳加随机串生成例如20250611-142301-8f3a.png如果笔记还没保存过就先把图片放到全局临时目录等用户第一次保存笔记时再统一迁移。这里有一个容易忽略的问题如果图片文件名完全随机后续在一个长文档里想找到“配图对应哪个章节”会很难受。rea 做了一个小优化文件名保留原始文件名的一部分但用随机串保证唯一性。比如截图_20250611-142301-8f3a.png既避免重名覆盖又保留一点点可读性。这个方法不复杂但对后期整理文档非常友好。3.3 全文搜索SQLite FTS5 加上中文二元分词搜索是 rea 里技术含量最高的一块。我试过多种方案最后选了 SQLite FTS5。原因很简单它和better-sqlite3在同一进程里不需要额外起服务。真正的难点是中文分词。FTS5 默认的unicode61分词器按空格和标点切词对中文基本无效搜索“编辑器”匹配不到含这三个字但位置不同的正文。rea 采用常见的 bigram二元分词方案索引时把中文文本按连续两个字符切成词查询时也切一次然后走 FTS5 的MATCH。例如“本地编辑器”会切成“本地”“地编”“编辑”“辑器”。这个方案有噪音但召回率足够再配合标题权重和修改时间排序实际使用下来已经能解决绝大多数“我记得这句话但记不清在哪个文件”的场景。索引更新是增量的文件系统监听器发现文件变更后会等待 500ms 防抖然后把该文件的索引删除重建避免频繁读写。3.4 自动保存与版本快照自动保存听起来最简单但做崩溃恢复时最容易出问题。rea 的策略是停止输入 1 秒后触发保存保存时先把新内容写入同一个目录下的临时文件再用rename覆盖目标文件。rename在大多数文件系统上是原子操作比直接写原文件更安全不会出现写到一半进程崩溃导致文件损坏的情况。每次保存前rea 还会把旧文件内容复制到.rea/history目录按时间戳保留最近 30 个版本。如果你在文档里删掉一大段之后才发现应该保留可以打开版本历史找回。这个功能技术含量不高但它是 rea 里使用频率最高的功能之一。另一个容易被忽略的细节是自动保存的过程中如果用户还在输入不能锁住整个主线程否则输入会明显卡顿rea 把保存操作放进队列如果上一次保存还没结束新的保存请求就合并掉而不是无限堆积。4. 实操中踩过的坑与排查实录功能讲完下面全是真金白银的坑。我尽量按“当时怎么踩的、后来怎么排查、现在怎么避免”这个顺序说后面还附带一个速查表。4.1 IPC 安全不要图省事开启 nodeIntegrationrea 早期版本为了方便在渲染进程里直接开了nodeIntegration: true页面里有一个“读取本地文件夹”按钮代码里可以直接用require(fs)。这开发确实快但隐患很大。问题出在一次搜索功能的调试我发现页面里加载了一个第三方 JSON 资源如果资源内容被改写里面的一段脚本就可能获得 Node 权限。从那以后我把nodeIntegration关掉contextIsolation打开所有能力都走 preload 暴露的白名单 API。可能有人会说我的渲染进程里没有第三方内容所以没问题。但 Markdown 文档里可以写 HTML 标签不少 Markdown 预览器会渲染这些标签只要存在任何远程资源风险就不是零。rea 的安全原则很简单宁可多写一层 preload也不要让页面拥有系统钥匙。4.2 路径穿越和文件名注入rea 曾经有一个 IPC 接口叫openFile接收一个文件路径主进程收到后直接读取。一开始没多想但后来意识到一个问题如果某个 Markdown 文档里有一个拼接出来的链接点击后会传一个类似../../../../etc/passwd的路径那么主进程就会去读任意文件。这个问题比想象中更现实因为 Markdown 本身允许任意链接地址。修复方式分两层。第一层开放路径之前用path.resolve拿到绝对路径再用path.relative判断它是否仍在允许的根目录内第二层所有文件访问统一走一个fsService在入口处做标准化校验。代码大致是const root path.resolve(app.getPath(documents), rea-library); const target path.resolve(root, requestPath); if (target ! root !target.startsWith(root path.sep)) { throw new Error(路径不在允许范围内); }凡是可能被外部内容影响的路径都要做这一步。这个坑让我明白本地工具的应用边界不是“你操作的这台电脑”而是“用户可能打开的任意文档”恶意的输入未必来自网络攻击可能只是文档里的一个链接。4.3 大目录首次索引卡顿第一次用 rea 扫描一个装满素材的目录约一万多个文件结果主进程直接卡了七八秒窗口里的转圈动画都停了。问题出在扫描逻辑用了一个fs.readdirSync递归同步遍历会阻塞事件循环。后来我把遍历改成异步版本并用任务队列控制并发每处理一个目录就让出事件循环。同时增加跳过规则node_modules、.git、.rea/history、_assets这些目录不进索引。经过优化后同样的大目录首次索引大概两三秒完成后续增量更新几乎无感。如果你要处理更大规模的目录还可以用 worker_threads 把遍历放到独立线程但对 rea 这个场景异步加队列已经够用。我记得这个坑的教训是在任何文件系统操作面前“它可能很慢”不是一个选项而是必须写的代码。4.4 打包、自动更新和安装包体积rea 打包用的 electron-builder这个流程本身不难但有几个问题容易忽略。首先是安装包体积。Electron 每个平台基本都要带一个 Chromium安装包 100MB 是常态。压缩体积的办法有限比较实在的是精简依赖、去掉用不到的 locale 文件以及把代码做分割。其次Windows 上更新时如果原进程还在跑替换文件会失败需要在更新前提示用户关闭窗口或者用 NSIS 的安装前结束进程选项。另外如果你的应用计划做自动更新最好在第一次打包时就规划好签名计划。没有签名的话某些系统会拦截更新后的程序尤其 macOS 上更明显。rea 目前还在个人使用阶段签名问题没那么急但后面如果要分发这块迟早要补上。下面是一张我自己的坑速查表症状可能原因解决办法编辑器光标乱跳React 重建了 CodeMirror 实例useRef 存实例Compartment 动态配置中文输入候选词丢失受控组件回写频率太高让 CodeMirror 自维护文档只按需同步写文件时程序崩溃导致文件损坏直接写原文件临时文件 rename 原子替换大目录扫描卡死同步递归 readdirSync异步遍历 并发队列 跳过规则打开路径报错或读到无关文件路径没有校验path.resolve relative 判断根目录更新时出现权限错误原进程占用文件更新前退出进程 / 安装期结束进程5. 现在状态和给同样想动手的人的话rea 到现在还没有到 1.0但核心链路已经稳定。我日常大部分长文都是用它在本地写的笔记和图片都收在同一个目录历史版本也从未丢过。它没有云同步也没有移动端这些不是 rea 的目标。我最初想解决的就是“文件太散”的问题这个目标目前达成得不错。5.1 后续可能增加的扩展点我给自己留了两个扩展方向一个是标签系统另一个是块引用。这两个方向都不会破坏现有文件结构所以会优先做成 YAML front matter 里的key: value形式以及行内锚点。简单说笔记仍然是一份普通 Markdown只是在文件头部多出几行元信息块引用则通过一个固定的锚点格式来定位段落。这样即使以后又换了工具文件也不至于被某个私有格式绑架。如果让我重新从零开始做一遍我不会急着搭 UI。先把fsService、searchService、historyService三个模块的边界定好用测试用例把路径校验、保存时序、索引更新这些基础行为钉死再套上界面。这样后续加功能时文件操作不会因为界面改动而回归。这个经验我在 rea 中段才意识到前面已经付出过一些重构的代价。5.2 给同样想写桌面工具的人如果你也准备做一个类似的本地工具我最后想分享几个经验。第一先跑通“打开文件 → 编辑 → 保存 → 搜到”这条最短链路再想界面好不好看第二所有文件操作的入口都做路径校验把它当成安全代码来写第三保留一个命令行入口万一 UI 崩了还能用脚本把笔记和附件捞出来。最后再分享一个小技巧在主进程里给每一个文件操作加上source日志字段也就是这次操作是从哪个 IPC 事件发起的。这看起来只是多写一个参数但出现问题排查时能立刻定位到是渲染进程界面触发的还是后台索引自动触发的。我在 rea 上最值的一笔投资就是把文件操作日志从一开始保留到现在。工具可以朴素但数据安全这件事永远值得多花一点功夫。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询