自研Markdown编辑器:好看与彪悍的工程实践 📅 发布时间:2026/9/16 5:16:03 👁 浏览次数: 说实话我本来没打算自己写一个 Markdown 编辑器。Typora、Obsidian、VS Code 轮着用了好几年每个都能用但每个都差那么一口气好看的不够顺手顺手的不够好看性能强的又往往在界面上显得过于“工程师审美”。作为每天要写技术笔记、项目方案、博客草稿的重度用户我最后决定干脆自己动手做一款——既好看又彪悍的 Markdown 编辑器。这个项目解决的核心问题其实很简单Markdown 语法本身不复杂但真正把它用舒服了需要编辑器在输入体验、实时渲染、导出链路、主题排版这四个方向上同时做到位。市面上的工具要么在某一项上很强要么在某一项上让人抓狂。我自己动手做不是为了证明技术多厉害而是想验证一件事一个独立开发者能不能用相对现代的方案做出一款让 Markdown 重度用户“用完回不去”的编辑器。这篇博文我会把整个项目的思路、功能设计、实操过程、踩坑记录都铺开讲清楚。如果你也是一个 Markdown 重度用户或者你也动过“自己写个编辑器”的念头这篇文章应该能帮你少走很多弯路。1. 为什么非要自研一款 Markdown 编辑器1.1 重度用户的三个真实痛点先说说我自己的使用场景。我每天产出的文字量大概在五千到两万字之间来源包括技术博客、项目文档、产品需求、周报月报甚至还有几本在写的电子书。这个量级下Markdown 几乎是我唯一的选择纯文本、易迁移、版本管理友好、语法足够简洁。但用多了之后痛点就越来越明显。第一个痛点是“颜值”和“效率”很难兼得。有的编辑器界面确实精致但快捷键体系稀烂连个“插入链接”都要点三次鼠标有的编辑器性能彪悍、插件丰富但默认字体、间距、配色简直像从 2010 年穿越过来的。作为一名每天要在编辑器里泡八小时以上的人界面丑其实很影响心情和产出。第二个痛点是“所见即所得”和“源码编辑”之间的撕裂。很多编辑器要么只给你一个编辑框写完还要切到预览视图要么让你在源码和渲染结果之间来回切换写表格、写公式的时候尤其崩溃。我想要的是源码该看的时候能看排版该所见即所得的时候也能所见即所得两种模式自然融合而不是互相打架。第三个痛点是导出的问题。Markdown 写起来爽但交付的时候往往需要 PDF、Word 或者网页格式。传统路线是写完之后挂个 Pandoc 命令慢慢折腾脚本配置十几个参数换一台电脑就全乱了。而市面上大部分编辑器就算做了导出功能也往往是“导出能看就行”字体、分页、页眉页脚全都不能自定义稍微正式一点的场景就露怯。这三个痛点单独拿出来都能忍但叠加在一起每天都要忍人就很容易暴躁。我开始琢磨既然市面上没有完全符合我需求的工具那我自己能不能写一个1.2 “好看”和“彪悍”是同一个意思的两个侧面项目定名的时候我脑子里冒出来两个词“好看”和“彪悍”。这两个词听着像是两个方向实际操作下来才发现它们其实是同一个追求的两面把细节做到位。“好看”不是花哨而是秩序感。编辑器里的文字、间距、配色、图标、动画都要在一个统一的节奏里。我花了大量时间在排版细节上标题和正文的行距比例、代码块和引用块的背景色差、暗色主题下哪些地方该用亮色、哪些地方该用低饱和色。这些事情单看起来都不起眼但放到一起就是“这个编辑器用起来很舒服”的底层原因。“彪悍”则体现在几个硬指标上能不能稳定处理十万字的文档同步滚动跟不跟手代码块高亮行不行数学公式渲染快不快表格编辑会不会让人想骂人导出 PDF 能不能做到像素级的样式控制。这些功能不需要花哨但每一个都必须硬核。所以“既好看又彪悍”本质上是对同一件事的要求任何表面上的流畅和美观背后都需要足够扎实的工程实现去支撑。1.3 技术选型为什么不直接改别人家的编辑器在决定自研之前我认真评估过三条路。第一条路是给 VS Code 写插件。VS Code 本身就有不错的 Markdown 预览能力插件生态也成熟。但问题在于VS Code 的 UI 框架限制太死想做深度定制的编辑体验比如混合渲染、自定义光标行为、精细的导出排版控制都要跟它的底层逻辑较劲事倍功半。而且 VS Code 本身的内存占用已经够夸张了我不想再添一个重量级常驻进程。第二条路是改造 Typora 这类成熟产品。Typora 的“所见即所得”做得确实好但它是闭源的很多内部机制改不动。更关键的是我对编辑器的需求是动态变化的——今天想要个新语法扩展明天想要个不同的导出模板闭源工具做不到这种灵活性。第三条路也是我最终走的路基于开源组件自己拼装一个。底层用 Electron 做跨平台壳编辑器内核选 CodeMirror 6解析器用 markdown-it公式用 KaTeX代码高亮用 Shiki导出走 Electron 的 printToPDF 加自定义样式。这些组件都是久经考验的我做的事情是把它们用正确的姿势组装起来再补充胶水逻辑和产品设计。这个选择的核心逻辑是不重复造轮子但也不被某个现成产品限制住。开源生态已经把底层解决得很好了我只需要把功夫花在“组合”和“细节”上。2. 功能设计先把“好用”给钉死2.1 编辑体验光标、输入法、快捷键编辑器好不好用首先要看打字这件事顺不顺。我在这部分踩的坑最多也最值得展开讲。CodeMirror 6 的默认行为其实偏“程序员编辑器”对中文输入法的支持只能说够用。我自己测试下来最大的问题有两个一是中文输入法组合输入阶段光标会闪现到错误位置二是在列表项里回车换行时缩进和列表符号处理得不够聪明。前者我通过配置 composition 相关的监听事件解决了后者则是写了一个自定义的 input handler在回车时检测当前行是否属于列表上下文自动补全-或1.前缀并把缩进级别继承下来。快捷键设计上我并没有完全照搬任何一款编辑器而是整理了一套“Markdown 操作优先”的映射逻辑。常用的语法插入都做到了一键完成CtrlB加粗、CtrlI斜体、CtrlK插入链接、CtrlShiftC插入代码块、CtrlShiftK插入公式。这套设计的目标是你的手可以一直停在键盘上不需要频繁摸鼠标。还有一个细节是 Markdown 的换行规则。很多新手被“两个空格加换行才能分段”这个规则坑过所以我在编辑层面做了优化如果你在段落中间直接回车编辑器插入一个正常的段落分隔如果你在行尾输入了两个空格编辑器会识别为软换行并在渲染层正确表现。这样既尊重了 Markdown 语法本身又降低了记忆成本。2.2 所见即所得的渲染实时预览怎么做到Markdown 编辑器的核心体验之一就是“左边写右边看”的实时预览。听起来简单真正做好却有门槛。预览不是简单的“源码丢进去HTML 吐出来”就行。你要处理几件事解析器的选择与插件配置、渲染结果和源码位置的映射、同步滚动的稳定性、以及大文档下的渲染性能。解析器我用的是 markdown-it因为它插件生态丰富性能也够硬。默认配置下它支持 CommonMark通过插件可以支持 GFM 的表格、任务列表、删除线再自己写几个插件补上数学公式、代码块复制按钮、自定义容器。渲染结果会经过一层 CSS 主题处理保证跟导出 PDF 的样式完全一致——这一点非常重要否则会出现“预览里很好看导出来完全变了”的尴尬情况。同步滚动是另一个大工程。我参考了 VS Code 的思路按行构建源码区块和渲染区块的映射表滚动预览区时计算对应源码的位置反过来也一样。这里最怕的是“滚动抖动”尤其在长文档里两边不断互相触发滚动事件很容易死循环。解决办法是加一个互斥锁记录当前滚动方向只有用户主动滚动时才触发反向联动程序内部的联动不触发下一次事件。2.3 “好看”的工程化主题、字体与排版说实话“好看”这个目标比我想象中难。难就难在好看是非常主观的但必须用非常客观的手段去实现。我先把“好看”拆解成了可量化的指标间距系统、配色系统、字体栈、圆角与阴影的使用边界。间距系统我用的是 4px 基准网格所有内边距和组件间距都是 4 的倍数配色系统分亮色和暗色两套每套都固定了背景层、前景层、边框层、强调色、辅助色的层级关系字体栈中文字体用的系统级的“PingFang SC / Microsoft YaHei / Noto Sans CJK”等宽字体则内置了 JetBrains Mono 和 Fira Code用户可以在设置里切换。这里我想特别强调字体搭配。Markdown 文档通常中英混排中文字体和英文字体的基线高度、字重、行距如果不协调一眼看过去就是乱糟糟的。我在自定义的 CSS 里明确区分了font-family的优先级英文字体和代码字体优先用等宽字体中文回落到无衬线字体同时把line-height设置为 1.7 到 1.8这样中英文混排的行高就能基本对齐。暗色主题比亮色主题难做得多。很多人以为暗色就是把背景改黑文字改白实际远不止如此代码高亮的颜色要在暗色背景上重新设计对比度引用的背景色不能太突兀选区颜色要保证可读性。我花了整整两个晚上调整暗色主题的细节才做到“看久了不累”的程度。2.4 “彪悍”的功能矩阵表格、公式、导出、图片如果说好看是面子那功能就是里子。我给自己列了一个“必须做到”的功能清单每一条都对应一个真实使用场景。表格编辑是 Markdown 用户的长期痛点。手打表格语法简直是灾难所以我做了一个可视化的表格编辑器鼠标框选行列、自动生成对齐语法、支持从 Excel 或网页直接粘贴数据自动转成 Markdown 表格。这个功能实测下来用户反馈最好因为它彻底解决了“在 Markdown 里维护表格”的反人类操作。数学公式用了 KaTeX 而不是 MathJax主要是性能考虑。KaTeX 的渲染速度快得多对实时预览来说差距特别明显。行内公式和块级公式都支持LaTeX 语法覆盖度也够日常使用。导出方面我做了三套链路PDF、HTML、Word。PDF 用 Electron 的webContents.printToPDF但会先套用导出专用 CSS控制字体、分页、页眉页脚HTML 直接输出渲染后的完整文档Word 则调用 Pandoc 做转换当然前提是你本地装了 Pandoc。这个设计在第三章会详细讲。图片管理也花了不少心思。支持直接粘贴截图自动保存到assets目录支持拖拽图片进来自动复制并插入引用还提供一个面板统一管理文档里的所有图片引用一键重命名、一键清理未使用的图片。这个功能写文档和博客的时候极其管用。3. 实操过程从原型到能用分了四步走3.1 架构主进程、渲染进程与核心库整个项目的架构分成三层Electron 主进程、渲染进程、以及一个独立的解析核心库。主进程管的是窗口创建、文件读写、Pandoc 调用、PDF 导出这些需要 Node 能力的事情。渲染进程管的是编辑器界面、预览渲染、主题切换、快捷键响应。核心库则是一堆纯 JavaScript 模块专门处理 Markdown 解析、AST 构建、源码与渲染区块的映射关系这部分不依赖 Electron单独拿出来也能在浏览器或 Node 环境跑。这样分层的好处有三个一是职责清晰不会出现“文件保存逻辑写在界面组件里”这种烂账二是便于测试核心库的函数可以单独跑单元测试三是为后续做 Web 版留了后路核心解析和渲染逻辑可以直接复用。通信方面用了 Electron 的 IPC 机制但我在上面封装了一层类型安全的 RPC。渲染进程请求保存文件只需要调用invoke(file:save, { content })主进程响应后返回结果。所有文件操作的错误提示也统一在渲染进程做用户看到的错误提示跟操作上下文是匹配的。3.2 解析与渲染的流水线实现解析与渲染是整个编辑器最核心的流水线我把它拆成了四个阶段源码预处理、解析成 Token 流、Token 映射为 HTML、HTML 挂载到虚拟 DOM 渲染。源码预处理这个阶段很多人会忽略但它解决了不少实际问题。比如 Markdown 里常见的\r\n和\n混用问题预处理阶段统一成\n再比如行内代码里的特殊字符如果不做保护后续解析时会被误判成 Markdown 标记。我在预处理阶段做了一层“临时占位符”机制把这些特殊情况提前摘出来解析完成后再还原。解析阶段用 markdown-it 的 token 流。每个 token 其实是一个带map属性的对象记录着它在原始源码里的起止行号。这个map属性是同步滚动的基石我可以根据光源码行号精确找到对应的渲染 DOM 节点。渲染阶段我做了增量渲染的优化。不是每次输入都重新渲染整个文档而是通过 diff 算法找出变更的区块只更新那部分 DOM。实测下来一个五万字的文档正常打字输入时渲染延迟可以控制在 60ms 以内感知上就是“跟手”的。// 简化版的增量渲染流程 import { MarkdownIt } from markdown-it; import { diff } from ./render-diff; function updatePreview(source, previousTokens) { const newTokens parser.parse(source); const patches diff(previousTokens, newTokens); patches.forEach((patch) { if (patch.type replace) { const element document.querySelector([data-token-id${patch.id}]); element.replaceWith(renderToken(patch.newToken)); } }); return newTokens; }3.3 导出 PDF / Word / HTML 的实战路线导出功能是很多 Markdown 编辑器的软肋我这块花的时间也最长。PDF 导出走的是“网页打印”路线先在一个隐藏的窗口里加载导出专用 HTML套上导出 CSS再调用打印接口。但这里有几个容易被忽略的细节。第一个是中文字体如果目标机器没安装对应字体PDF 里会出现豆腐块。解决办法是导出时把字体以base64的形式嵌入 CSS代价是文件体积大一些但换来的是跨设备一致。第二个是分页控制要特别注意代码块和表格的page-break-inside: avoid属性否则会出现某行代码被分页切断的诡异效果。第三个是页眉页脚Electron 的 printToPDF 原生支持页眉页脚模板但需要自己写headerTemplate参数。Word 导出则完全换了一套思路。Word 本身不认 Markdown所以先要把 Markdown 转成 docx 格式。最可靠的方案是调用 Pandoc 命令行但 Pandoc 的默认样式非常朴素标题字体、间距都要自己定义 reference docx。我准备了一个排好版的reference.docx作为模板Pandoc 会根据模板里的样式来渲染最终 Word 文档。HTML 导出相对简单就是渲染后的 HTML 内联 CSS支持用户自定义模板。这个功能用来生成在线文档或者打包发给别人看特别方便。3.4 性能优化5 万字文档的流畅化改造性能优化是在原型跑通之后立刻就要做的事。之前提到的增量渲染解决了输入跟手的问题但长文档还有两个隐藏杀手大 DOM 树和同步滚动计算。五万字的中文文档渲染成 HTML 之后大概有一万多个 DOM 节点。一次性渲染出来就算不卡内存占用和首屏时间也很吓人。我引入了虚拟滚动只渲染可视区域附近的节点上下各留一个缓冲区域滚动时动态增删节点。这个改造让首屏时间从 800ms 降到了 200ms 以内。同步滚动的计算也做了优化。原始方案是每次滚动都重新计算两边的位置映射但滚动事件一秒钟能触发几十次每次都全量计算CPU 直接拉满。我改成“滚动结束采样 节流”只在滚动停止后的 50ms 窗口内做一次精确计算运动过程中用粗略估算先顶一下。用户体验上几乎无感知CPU 占用却降了七成左右。还有一个容易踩坑的地方是代码高亮。Shiki 按需加载语言包但如果文档里嵌入了几十种语言的代码块首次渲染会很慢。我的方案是只默认加载高频语言JavaScript、TypeScript、Python、Java、C、Bash其他语言按需动态加载并加一层缓存。4. 实测踩坑记录与问题排查4.1 长文档卡顿从 1.2s 到 60ms 的优化有段时间我在编辑器里写一篇约八万字的讲义写到后半段开始出现明显卡顿每次打字都要等上一秒多才能看到预览更新。一开始我以为是渲染太慢后来用 Performance 面板一测发现卡顿的根源在 React 组件的 re-render预览区那个组件每次状态更新都要重新走一遍虚拟 DOM diff。当时的解决方案是给组件加 memo避免无关状态变化触发重渲染同时把编辑器内核状态和 React 状态彻底解耦——编辑器内容变化不再直接驱动 React 重新渲染而是通过事件总线精确更新需要变化的 DOM 节点。这个改造做完同样一篇八万字的文档输入到预览更新的时间降到了 60ms 左右。这个经验我记了很多年任何时候把“状态管理”和“真实 DOM 更新”耦合在一起都注定做不出高性能的应用。4.2 预览和导出不一致根因在样式与解析器预览和导出不一致的坑出现过两次每次都很隐蔽。第一次是解析器之间的差异。预览用的是 markdown-it但早期导出 PDF 时走的却是另一个 markdown 解析库两个库对 GFM 表格的对齐语法、html 转义细节处理不一样导致同一段文档预览正常导出来却样式错乱。我最后把所有导出链路全部统一到同一套 markdown-it 上才彻底解决。第二次是样式差异。预览区默认是“编辑器 UI 样式”导出时换成“文档样式”两套 CSS 里h1的margin、page-break-after规则如果不一致导出的 PDF 看起来就跟预览完全不同。解决办法是把文档样式抽成一套独立主题预览和导出共用这一套主题编辑器的 UI 样式只负责工具栏、侧边栏这些非文档区域。4.3 图片路径失效资源管理的正确姿势Markdown 里插入图片路径问题真的是阴魂不散。写文档的时候用的是相对路径比如文档和assets目录在同一级没问题。但一旦把文档复制到别的目录或者导出 PDF 时没有把这些相对路径的图片一并打包图片就全部失效。我的解法是提供两种资源模式默认“复制模式”插入图片时自动把图片复制到assets目录并修改引用路径进阶“绝对模式”用户可以配置一个图床或外部目录编辑器会把图片上传到指定位置并用绝对 URL 引用。此外导出 PDF 前会做一次图片完整性检查有缺失就直接在界面上标红提示而不是等导出来才发现一堆裂图。另外要在编辑器里做一个图片路径自动修复功能。文件夹整体迁移后打开文档时能自动识别失效引用结合文件树推断图片新位置一键修复。这个功能看起来小众但真正经历过博客换目录、仓库重构的人都会感激它。4.4 避坑速查表我替你先踩过的雷坑点原因解决办法中文输入法光标漂移CodeMirror 对 composition 事件处理不完善监听 composition 事件在组合输入期间锁定光标同步滚动死循环双向滚动事件互相触发加滚动互斥锁程序联动不触发反向滚动字体回退混乱中文字体没有单独声明用列表依次声明英文字体、中文字体、通用字体Pandoc 找不到路径没有检测系统环境变量启动时检测 Pandoc 是否安装给出明确配置指引PDF 代码块被截断缺少分页保护属性CSS 中加入break-inside: avoid大文档卡顿组件状态更新驱动整棵 DOM 渲染状态模块化局部精确更新 DOM图片复制后路径错乱相对路径会随文件移动失效默认复制到 assets 目录并统一管理还有一个不得不提的细节自动保存的时机。太频繁了磁盘 IO 压力大太稀疏了又怕丢数据。我最终选了“文档变更后静默 3 秒自动保存”的策略同时保留 CtrlS 手动保底。用下来既不会频繁闪写也基本没丢过文字。5. 版本迭代与后续规划5.1 插件系统让编辑器长成用户想要的样子编辑器做到第二版的时候我开始收到一些内部用户的定制需求有人要支持 mermaid 流程图有人要支持书目管理还有人想把笔记自动同步到自己的博客平台。如果这些都堆在编辑器内核里很快就会变成一个大杂烩。于是我设计了一套轻量级的插件系统。插件系统核心是生命周期钩子插件可以监听文档打开、内容变更、导出前、导出后等事件也可以注册自定义命令和自定义渲染器。自定义渲染器是最灵活的部分——遇到特定语法块时插件可以覆盖默认渲染逻辑。比如 mermaid 插件就是注册一个render(codeblock, lang)钩子当语言标记为 mermaid 时走插件的渲染流程否则走默认的代码高亮。这套插件系统的 API 设计很大程度上参考了 VS Code 和 Obsidian 的思路小而明确权限分明。插件不能随意访问文件系统只能通过我封装好的安全接口操作这样用户安装第三方插件时不用担心本地文件被乱改。5.2 移动端、同步与开放生态很多人问我为什么不直接做移动端 App。我的想法是移动端的输入效率天然比桌面端低强行做一个功能对等的版本很蠢。移动端的正确形态应该是“阅读器 轻量编辑器”而不是桌面端的复刻。所以我规划的是先做本地文件接口的兼容这样用户用坚果云、WebDAV 这类网盘同步文件时移动端可以直接读取。同步功能我也收到过不少反馈。我的设计原则是“不做云只做桥”编辑器不维护账号体系而是通过 WebDAV、SFTP、Git 这类开放协议让用户接入自己已有的同步方案。因为 Markdown 本身就是纯文本最优雅的同步方式就是让文件系统的一致性由外部工具保证编辑器只需要在打开、保存、文件变更检测上做配合。5.3 给想自研编辑器的人的三条建议如果你也动了自研 Markdown 编辑器的念头我有三条经验想分享给你。第一条不要从零写解析器。Markdown 解析器的坑比你想的多得多commonmark-spec 里的测试案例就有六百多个自己写很难写全。直接用 markdown-it 或 remark把精力花在产品和集成上性价比高得多。第二条编辑器内核也别从零写。CodeMirror 6、Monaco、ProseMirror 都是成熟方案就算你觉得它们的扩展机制繁琐也不要在早期阶段就自研编辑器内核。编辑器内核是“看起来简单、做起来无穷无尽”的领域没有足够精力前站到巨人肩膀上。第三条先做最痛苦的那条链路。如果整理需求列表时你觉得“导出 PDF”最让你头疼那就先做导出 PDF。把最难的骨头啃下来再回头做界面的细节你会发现心态完全不一样。我自己的经验证明了这件事当导出链路稳定之后其他所有功能都只是时间问题。按我现在的规划下一步重点是插件市场和社区模板。让更多人用起来才能知道哪些功能是真实需求哪些只是我的个人偏好。编辑器这种东西单一开发者做出来也只能服务自己做成开放生态才有生命力。最后再分享一个小技巧不管你是用别人的编辑器还是自研都应该维护一份自己的“写作样式模板”。包括字体、标题层级、代码块样式、引用块样式统一之后Markdown 才能实现真正的“一处编写处处复用”。我这套模板现在已经在所有文档里推广了从博客到公司文档从 LaTeX 风格的数学笔记到周报视觉风格完全一致这也是我认为 Markdown 生态里最有价值的一件事。