
前阵子整理电脑里的文档发现自己过去五年写的东西几乎全是 Markdown技术博客草稿、会议笔记、项目 README、甚至给家人写的食谱都是 .md 文件。作为一个每天要跟 Markdown 打交道的重度用户我电脑里装过的 Markdown 编辑器一只手数不过来但无一例外都在某个点上让我觉得“差一口气”。时间久了我攒了一肚子怨气也攒了一脑子想法最后决定自己动手写一款既好看又彪悍的 Markdown 编辑器。这篇文章就是整个项目的完整复盘从痛点拆解、需求定义到内核选型、即时渲染、硬骨头功能实现再到性能调优和日常踩坑记录全部摊开来讲。如果你是 Markdown 的重度用户或者对编辑器类工具的开发过程感兴趣又或者你也在犹豫“要不要自己做个轮子”这篇应该能给你一些实打实的参考。1. 重度用户被逼到自研市面上的编辑器为什么没能留住我1.1 我的使用场景不只是写博客那么简单先交代下我自己的使用场景。写博客是最基础的需求但我的日常远不止“写一篇 Markdown 然后发布”。我经常要处理上万行的技术方案文档里面混着大量代码块、数学公式和 Mermaid 图表我记课堂笔记和会议纪要时习惯用任务列表和嵌套引用我还要频繁地把写好的内容导出成 PDF 发给同事或者转成 Word 让不懂 Markdown 的合作伙伴去批注。这些场景凑在一起对编辑器的要求就变得很具体渲染要好看但也要经得住大文档折腾语法支持要全但操作要轻快导出要靠谱但最好不要逼我去装一堆命令行工具。问题就出在这里——市面上几乎没有一款工具能同时满足这些要求。1.2 主流编辑器的共性和短板我用过的编辑器不算少挑几个有代表性的说说。Typora 的即时渲染体验确实好界面也干净但它有两个问题我始终绕不开一是闭源收费之后插件扩展能力一直没跟上二是它的 PDF 导出走的是自身渲染引擎对主题样式的控制非常有限想高度定制页眉页脚、字体间距得靠主题作者去填各种坑。我一度很依赖它但每次导出 PDF 都像开盲盒。VS Code 加上 Markdown 插件是另一个极端。插件生态无敌导出方案也够硬核但 VS Code 本质上是个代码编辑器Markdown 只是它处理的一百种语言之一。界面默认样式对纯写作场景来说太“硬”了行宽、字体、焦点模式都要自己折腾而且即时渲染依赖插件实现偶尔会有光标漂移的毛病。Obsidian 最大的优势是双向链接和知识库管理文档多起来之后它的搜索和关系图确实好用。但 Obsidian 对纯 Markdown 用户有一个隐蔽的坑它会在文档里写一堆 frontmatter 元数据如果你像我一样希望文件拿到任何工具里都能直接读这种“生态绑定”就会变成负担。这三款基本代表了市面上三条主流路线但它们各自的短板恰好触到了我的痛点。还有一个让我很崩溃的共同问题大文档性能。它们的架构设计都偏向“通用场景”没有针对超长 Markdown 文档做极限优化。我手头有一份两万多行的测试记录在好几款编辑器里打开都要卡好几秒滚动的时候更是肉眼可见的掉帧。1.3 自研的边界我只解决我真正在乎的问题所以我决定自己写一个。但一开始我就给自己划了一条线不做大而全的“平台”只解决我在日常写作中真正在乎的四件事——渲染好看、编辑顺滑、语法完整、导出可靠。我甚至列出了几个明确的不做清单不做多用户协作、不做双链知识图谱、不做插件市场、不做移动端。因为一旦把这些加进来开发周期会翻好几倍而且偏离了我自研的初衷。想通这一点之后项目的所有技术决策都变得清晰了只要某个方案能让我写作更舒服就值得做只要某个功能会让编辑器变重、变慢就直接砍掉。2. 需求定义先行把“好看”和“彪悍”各自拆成可落地的功能点2.1 “好看”到底是什么排版、字体、主题一个都不能糊弄“好看”是个主观词但如果拆成可落地的技术指标其实没那么玄。首先是排版。Markdown 最终阅读体验好不好很大程度取决于排版参数行宽、行高、字号、段间距、中英文混排时的字形处理。我参考了一些排版规范最后定的参数是正文行宽 780px行高 1.75正文 16px段间距 20px。这些参数不是拍脑袋定的而是用了一段包含英文、中文、代码块、公式的混合文本反复试出来的。然后是字体。中英文混排最怕字体回退混乱中文用思源宋体英文用 Inter代码块用 JetBrains Mono并且在 CSS 里用 font-family 逐层声明了回退顺序。这一点在 Windows 上尤其重要不然系统会用默认的宋体把整个界面搞得非常“古早”。最后是主题系统。我不想要“换身皮”就算完事的主题方案而是把主题做成了基于 CSS 变量的完整体系。编辑区、侧边栏、状态栏、弹窗、滚动条全部走变量这样用户改主题不用去翻几百行样式表改几个颜色变量就够了。2.2 “彪悍”的功能列表语法、性能、扩展三件套“彪悍”我用三个维度来定义。第一个维度是语法支持的完整性。GFMGitHub Flavored Markdown是底线在这个基础上还要支持数学公式、Mermaid 图表、目录生成、脚注、任务列表、删除线、表格、行内代码高亮。这基本覆盖了我日常用到的全部语法也覆盖了社区里高频出现的 Markdown 需求。第二个维度是性能。我把“打开 3 万行文档不卡顿、输入不丢字”作为硬指标。这个后面会讲到具体实现总之绝对不能用“每次输入都全量重渲染”的方式。第三个维度是扩展能力。我需要能自定义快捷键、能写简单的插件。但我不打算做成一个完完整整的插件系统而是通过事件订阅机制暴露一部分内部接口让懂技术的人能做定制。2.3 MVP 与不做清单克制也是一种设计定了三件套之后我反而花了更多时间做减法。以下功能我明确不做不做所见即所得的“富文本模式”只做源码编辑 即时渲染 双栏预览三种模式不做浏览器内在线版只做桌面端应用避免处理浏览器兼容性分散精力不做云同步文件管理交给用户自己的同步盘不做多人协作这个工作量太大而且不是编辑器该干的事。砍掉这些之后项目的范围立刻清晰了。我可以专注打磨核心体验而不是把精力耗在那些“听起来很酷但对写作没有直接帮助”的功能上。3. 编辑器内核选型为什么是 CodeMirror 6 而不是自己写解析器3.1 三条技术路线的对比CodeMirror、Monaco、ProseMirror编辑器内核是所有功能的地基选错了一辈子都在填坑。我当时认真对比了三套方案。方案核心思想优点不选它的理由Monaco Editor代码编辑器内核补全、语法高亮强大VS Code 同款体积大定位偏向代码编辑对长文档中文排版支持一般ProseMirror富文本编辑器内核文档结构化为节点树适合做复杂的富文本得自己维护 doc 模型的序列化反序列化和 Markdown 源码的映射很费劲CodeMirror 6灵巧的 Text Editor 内核模块化设计、编辑能力与视图状态分离、装饰器机制强大需要自己组装插件上手成本略高这里要说一个非常容易踩的坑如果你打算做一个“以 Markdown 源码为存储格式”的编辑器千万不要选择富文本内核。ProseMirror 这类内核会把文档变成一棵结构树当你输入**粗体**时它内部的模型状态和你屏幕上的源码是不一致的每次同步都要做序列化格式复杂一点就会出现“源码读回去和原样不一样”的诡异问题。Monaco 的问题在于它天生是为代码编辑优化的。它对等宽字体、缩进线、代码折叠的优化做得很好但对行内 Markdown 格式的即时渲染支持偏弱——想做“输入**粗体**时星号隐藏、文字实时变粗”这种效果得在装饰器上做很多额外工作。所以我最终选了 CodeMirror 6。它的核心设计把“文本状态”和“视图渲染”分开了我可以把 Markdown 源码始终作为唯一的事实来源然后通过装饰器做各种视觉增强这套机制跟 Markdown 编辑器简直是天作之合。3.2 CodeMirror 6 的核心概念View、State、Decorate、PluginCodeMirror 6 最核心的几个概念我用自己的话翻译一下。EditorState编辑器的数据模型保存文本内容、光标位置、选区等它是纯数据。EditorView负责把 EditorState 渲染到屏幕上处理输入事件和布局。插件Extension一切功能都是插件从语法高亮到按键绑定再到自动补全全都可以组合。装饰器Decoration在屏幕显示层面给文本加效果而不会改动底层文本。比如把**加粗**的星号隐藏、把链接变成可点击的蓝色文字都是装饰器的工作。这种设计最大的好处是我可以放心大胆地在装饰器里做各种渲染增强因为无论怎么装饰底层文本永远是最干净的原始 Markdown。用户随时可以切换到“纯源码模式”看到的内容和文件里存的完全一样。初期代码大概是这样的import { EditorState } from codemirror/state import { EditorView, keymap, lineNumbers } from codemirror/view import { markdown } from codemirror/lang-markdown const state EditorState.create({ doc: # Hello World, extensions: [ lineNumbers(), markdown(), keymap.of([...]) ] }) const view new EditorView({ state, parent: document.getElementById(editor) })这段代码虽然简单但已经把整个编辑器的骨架立起来了。接下来的所有功能都是在 extensions 数组里不断叠加“插件”的过程。3.3 为什么不自己写一个 Markdown 解析器很多教程会诱导你去自己写 Markdown 解析器说“就几百行正则的事”。这句话大错特错。Markdown 看着简单真正做起来全是边界情况。行内代码和嵌套强调的优先级怎么处理下划线在中文输入里经常被误判成斜体要不要管表格单元格里塞代码块怎么办脚注和链接的上下文解析怎么算正确这些细节每个都能写出一篇论文。市面上的解决方案早就比我这种半路出家的人想得周全。我选了解析层的路线是remark 生态unified 家族。它有成熟的语法树规范官方解析器和社区插件都很多我可以把 Markdown 解析成一棵标准的语法树再从语法树反向生成装饰器效果非常稳定。后面做导出功能时也受益于此——想转 HTML 就把语法树交给 remark-rehype想转 Word 就让语法树走 pandoc根本不需要自己重写转换逻辑。4. 即时渲染的实现从“源码预览”到“写哪渲染哪”的关键机制4.1 即时渲染的本质装饰器不是魔法很多人以为即时渲染是“边打字边把 Markdown 变成 HTML”这个理解不太对。如果真是这样那每次输入都要经历“源码→HTML→渲染回编辑器”的循环光标位置早就乱了。CodeMirror 6 的即时渲染思路是文本始终是 Markdown 源码只是在显示层面做了装饰。比如你输入# 标题编辑器读取这一行知道它是一个标题于是用一个大号字体的装饰器包裹这一行同时把#前缀用透明色隐藏。你看到的是标题效果但底层文本一行都没变。4.2 行内格式正则分词 Decorate 的实现流程行内格式是即时渲染里最容易翻车的部分。要实现的效果是输入**强调**时两个星号淡出中间文字变粗输入[链接](https://example.com)时方括号隐藏链接文字变成蓝色可点击。我的实现流程是监听文档变更事件用解析器拿到当前文档的语法树遍历语法树把需要装饰的节点强调、加粗、链接、行内代码等翻译成一组装饰器把装饰器交给 CodeMirror 的 View 去渲染。这里有个很重要的性能细节不要每次输入都全量遍历整个语法树。我做了分块缓存只重新解析光标所在段落以及它前后可能受影响的段落。比如光标在第 10 段移动第 1 段到第 9 段的语法树缓存直接复用。// 伪代码段落级解析缓存 let cache new Map() function getDecorationsForLine(line) { if (cache.has(line.number) line.rev cache.get(line.number).rev) { return cache.get(line.number).decorations } const decos parseLineAndCreateDecorations(line) cache.set(line.number, { rev: line.rev, decorations: decos }) return decos }行内代码的处理也值得一提。Markdown 里行内代码靠反引号包裹但如果反引号里又有反引号语法是允许嵌套的。这个解析用纯正则很容易挂最后我是靠 remark 语法树里inlineCode节点的正确边界才解决的。4.3 块级元素代码块、公式、任务的懒渲染方案行内格式用装饰器直接处理就行但块级元素会麻烦一些。代码块如果直接在装饰器里硬拼高亮遇到大代码块性能会很差。我的做法是只对当前可见区域内的代码块做语法高亮滚动离开后回收高亮装饰器只保留基础底色。这就是虚拟化思路在编辑器渲染层的应用。数学公式这里又是个特殊场景。KaTeX 的渲染结果是 HTML 片段如果我把 HTML 片段直接塞进 CodeMirror 的装饰器编辑器可能无法正确处理光标定位因为装饰器里的内容被编辑器视为“不可编辑区域”光标很容易跳来跳去。我的解法是先用一个占位符装饰器标记公式区域然后在这个区域上方绝对定位一个透明层把 KaTeX 渲染结果放上去。这样底层文本是干净的$$...$$视觉呈现是排版好的公式光标行为也不会乱。Mermaid 图表同理。图表区域在源码里是一个代码块我用占位符定住位置异步调用 Mermaid 渲染渲染成功后把 SVG 覆盖上去。4.4 预览模式的防抖、滚动同步与滚动锚定除了即时渲染我还保留了双栏预览模式毕竟阅读长文时整页预览更舒服。预览逻辑的核心是防抖。编辑器里的每次输入都会触发预览更新但不立刻执行而是等 300ms 无输入后再渲染。实测下来这个延迟感知不到但明显减少了无用渲染频次。滚动同步的方案是通过监听滚动事件计算当前源码行在文档中的比例再把预览滚动到对应位置。这个方案在纯文本场景下已经够用但如果文档特别长会出现比例跳变的小瑕疵所以我后来换成了“锚点定位”——取光标最近的一个标题作为锚点在预览里找到该标题并滚动到附近位置。效果比比例定位稳定得多。5. 最难的从来不是语法图片、表格、公式、导出这些硬骨头怎么啃5.1 图片路径的自动识别与悬停预览Markdown 里图片路径是最容易让新手懵的地方相对路径、绝对路径、网络 URL 三种情况混在一起经常图片说挂就挂。热词里天天有人在搜“markdown 图片路径”说明这问题实在太普遍了。我在编辑器里做了这几件事智能判断路径类型。相对路径基于当前文档所在目录解析绝对路径直接读系统文件网络 URL 直接加载。悬停预览。鼠标移到图片引用上时弹出一个缩略图预览窗不用打开图片 app 就能确认内容。路径不存在时给出错误提示并列出候选的同名文件方便一键纠正。这里有个很关键的体验细节绝对路径在使用同步盘时极易失效因为同步盘的本地根路径在不同机器上不一样。我后来在设置里加了一个“路径变量映射”的功能可以定义$vault、$assets这类变量让文档在不同设备上通过变量解析路径。这个功能虽然实现不复杂但实用价值极高。5.2 表格输入体验从 Markdown 源码到类 Excel 编辑Markdown 表格是很多人的噩梦对齐符号手敲到崩溃单元格里塞长文本时源码里根本看不出哪列是哪列。热词里“markdown 表格复制”“markdown 表格转换 excel”说明大家被表格折磨得不浅。我的编辑器做了一个折中方案表格区域不在源码里硬写而是当光标进入表格时自动弹出一个表格编辑器浮层。这个浮层长得像简化版 Excel支持 Tab 键跳格、方向键导航、自动新增行列编辑完自动同步回 Markdown 源码。另外我还实现了粘贴增强从 Excel 或网页表格里复制数据后粘贴到编辑器时会自动转成 Markdown 表格语法。这个功能的原理是监听粘贴事件检测剪贴板里的文本是否含有 Tab 分隔符如果有就把整个文本解析成行列结构再生成对应的 Markdown 表格。// 伪代码Excel 粘贴转 Markdown 表格 function clipboardToMarkdownTable(text) { const lines text.trim().split(\n) const rows lines.map(line line.split(\t)) const header rows[0] const separator header.map(() ---) const body rows.slice(1) return toMarkdownTable([header, separator, ...body]) }这个小功能上线后我几乎再也没手动敲过表格。5.3 数学公式KaTeX 的按需加载与渲染缓存数学公式支持我用的是 KaTeX而不是 MathJax。原因很简单KaTeX 渲染更快体积更小API 也更干净。但 KaTeX 有个硬伤——它的自动渲染不支持在动态变化的文本里做实时渲染需要手动调用 API 指定渲染区域。我在代码里做了两层优化。一是按需加载只有当文档里出现$或$$时才去加载 KaTeX 的脚本和样式避免拖慢编辑器启动。二是渲染缓存同一个公式如果在文本里出现多次只渲染一次结果其他位置直接复用缓存公式内容没变的情况下绝不重复渲染。这里顺便说一句渲染数学公式需要的不是“极其复杂的解析”而是“稳定的错误处理”。公式写错符号是常有的事KaTeX 渲染失败时会抛出异常如果处理不好整个编辑器都会卡死。我在异常处理上做了兜底公式渲染失败时只在该位置显示原始源码加一个醒目的错误角标绝对不会影响文档其他部分的编辑。5.4 导出 PDF 和 Word 的完整链路导出功能是另一个大坑。Markdown 编辑器如果只能编辑不能导出那基本是个半成品但做导出时我踩了不止一次坑。先说说 PDF。很多人提到 Markdown 转 PDF 就想到要先装 pandoc 或者 princexml 这类的命令行工具但这个思路对普通用户非常不友好。我最终选的是内置渲染引擎方案先把 Markdown 通过 unified 生态转成 HTML再交给 Electron 的打印模块进行 PDF 输出。关键点在于我完全控制了样式表页边距、页眉页脚、代码块换行策略全部自定义。这样导出的 PDF 跟编辑器预览里看到的视觉效果一致不玩“开盲盒”。再说 Word。导出 .docx 我用的是 pandoc 作为后端但为了让用户不用去安装命令行工具我把 pandoc 的可执行文件直接打进了应用包。这个方案成熟稳定但有个适配细节pandoc 输出的 Word 文档默认样式比较丑我在转换前先传入一个自定义 reference docx把标题颜色、正文字体、中文字体都预置好。pandoc input.md -o output.docx --reference-docmy-reference.docx这个命令一行但背后的适配我调了一下午。6. 常见踩坑与调优事件簿性能、输入法、粘贴和自动保存6.1 性能从打开 3 万行文档卡顿到秒开性能问题是这个项目里花费时间最多的一部分。刚开始做原型的时候我用一段 3 万行的 Markdown 做压力测试结果打开要等 5 秒滚动时帧率跌到个位数。性能优化的过程是一条完整的链路我按顺序做了一轮第一层是解析层优化。全文解析的时候remark 会构建一整棵语法树3 万行文档这棵树要占到几十 MB 内存。我先砍掉了解析哪些暂时用不到的节点——比如目录生成、脚注关联这些功能不需要的时候就不解析。然后做分段解析缓存只有视图可见区域附近才做完整解析。第二层是渲染层优化。CodeMirror 6 本身的虚拟渲染机制已经很好但我发现一个隐蔽的坑如果装饰器数量过多每次视口变化都要重建所有装饰器性能开销巨大。优化方式是给装饰器加位置排序和分层管理把行内装饰器和块级装饰器拆开处理。第三层是输入路径优化。每输入一个字符都触发一次全量语法树重新解析是最大的性能杀手。我做了脏区块追踪输入时只重新解析受影响的段落。实测优化之后3 万行的文档打开耗时降到 1 秒以内滚动也能稳定在 60 帧。6.2 中文输入法组合态不丢字中文用户最容易遇到的编辑器 bug 就是“输入法候选框里选字的时候编辑器疯狂跳光标”或者“刚打完的字莫名其妙丢了”。这个问题本质上是编辑器没有正确处理输入法组合态。CodeMirror 6 对组合态的处理已经比较成熟但我在接入即时渲染时还是出了一个 bug当用户正在输入中文组合态还没确认时我的装饰器更新逻辑会抢先触发文本重绘导致候选框消失。解决办法是监听compositionstart和compositionend事件在组合态进行中暂停所有装饰器更新等确认后再统一刷新。这个改动很小但直接决定了编辑器对中文用户是否友好。6.3 粘贴富文本自动转 Markdown 的迷你实现从网页或者 Word 复制内容后直接粘贴到 Markdown 编辑器通常会出现两种情况要么是带着各种样式标签的富文本被编辑器过滤得乱七八糟要么是粘贴进来的是没有任何格式的纯文本标题、列表、加粗全丢了。理想的方案是把富文本自动转成等价的 Markdown。标准的做法是用 turndown 库它能识别 HTML 结构并生成对应的 Markdown 语法。我在此基础上做了一些定制遇到中文网页时保留中英文之间的空格处理遇到已经有 Markdown 语法的文本时避免二次转义。import TurndownService from turndown const turndownService new TurndownService({ headingStyle: atx, codeBlockStyle: fenced }) editor.addEventListener(paste, (event) { const html event.clipboardData.getData(text/html) if (html) { const markdown turndownService.turndown(html) insertText(markdown) event.preventDefault() } })实测下来从知乎、掘金、语雀这些平台复制内容再粘贴基本能还原九成以上的格式。剩下那一成主要靠人工微调但已经大大减轻了搬运负担。6.4 自动保存与崩溃恢复再也没丢过文稿写作最怕的就是丢稿。我见过太多人在浏览器里写长文结果浏览器崩溃后一篇文章灰飞烟灭。桌面应用不解决这个问题就说不过去。我的自动保存方案分三层每 5 秒保存一次当前文档到用户指定的工作区临时目录每次保存时写一个时间戳版本的临时快照保留最近 20 个快照启动时检测到有未正常关闭的会话提示用户恢复最近一份快照。这里有个细节千万不要用“覆盖原文件”的方式做自动保存万一写入中途程序崩溃原文件就废了。正确姿势是先写临时文件写完之后再做原子替换。// 伪代码原子写入 fs.writeFileSync(tmpPath, content) fs.renameSync(tmpPath, targetPath)这套机制上线后我再也没有丢过稿子。虽然看似是个不起眼的功能但对天天写作的人来说安全感真的拉满。整个项目做到现在我最大的感受是编辑器这种工具功能做多不难难的是在“好看”和“彪悍”之间找到平衡。好看的皮囊和好用的内核缺一不可而这两者都需要建立在极端克制的产品判断之上。以后如果继续迭代我会重点打磨插件接口把更多扩展能力开放给社区让这款编辑器从一个私人工具变成一个真正能被更多 Markdown 重度用户用起来的产品。