十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

高颜值高性能Markdown编辑器“小语文稿”的架构与实现

高颜值高性能Markdown编辑器“小语文稿”的架构与实现 我每天的输入量保守估计在三千到五千字之间全部通过 Markdown 完成。写作对象包括个人博客、团队文档、产品需求说明和每周必须交的工作汇报。和 Markdown 打交道久了你会发现编辑器这个工具不该只是“能用”它应该在你专注写作时彻底隐形。但现实是市面上的 Markdown 编辑器要么颜值在线却撑不住大文档要么性能强悍却丑得让人没有写作欲望。昨天晚上我又一次被手头的编辑器惹毛文档大概两万行我只是想往表格里追加一行光标就开始抽搐输入一个字母要等两秒最后整个窗口白屏我还没保存。那一刻我做了个决定——自己写一款编辑器。对直接自己写。这个项目我给它起名叫“小语文稿”名字很随意但目标一点也不随意既要在颜值上让写作过程愉悦又要在性能上经得起海量文档的折腾。1. 写了十万行 Markdown 之后我终于忍不住了1.1 一个重度用户的 Markdown 日常先说说我的使用场景这样你大概能理解为什么我对编辑器的要求会这么苛刻。技术博客初稿、团队内部 Wiki、产品需求文档、季度复盘甚至个人日记我全部用 Markdown 写。工作流基本是Typora 里写初稿Obsidian 里做笔记关联VS Code 里改项目 README线上文档平台再维护一份。听起来很全能但切来切去非常割裂。每个工具都有自己的语法渲染细节同一个表格在 A 工具里正常粘贴到 B 工具里就乱了。这种工具碎片化带来的问题在长期写作中会被无限放大。比如 Typora 的所见即所得确实舒服但遇到两万行以上的长文档就容易卡Obsidian 的双链体系很强大但默认主题的编辑感偏程序员风格中文阅读观感总差口气VS Code 的 Markdown 预览插件虽然在不断变好但你不会想在里面写一篇上千行的长文因为它本质上还是一个代码编辑器。所以对我来说一款理想的 Markdown 编辑器必须同时满足三个条件中文排印好看、长文档不卡、导出可靠。1.2 我整理过一份编辑器的“差评笔记”开发之前我认真把主流方案盘了一遍重点关注它们各自在“颜值”和“性能”上的取舍。我不追求客观公正只从真实使用体验出发整理成了一张表工具颜值体验性能表现最让我难受的点Typora较好中文排印舒服2万行以上明显卡顿表格和代码块交界处渲染偶发错乱Obsidian自定义主题上限高但上限需要自己调仓库大了之后启动慢默认编辑器离“写作工具”还有距离VS Code Markdown 插件预览样式一般稳定但依赖单个插件质量写作体验偏代码编辑分离式预览干扰心流在线文档/Notion/语雀在线渲染漂亮大文档滚动和输入延迟明显离线基本不可用数据不在自己手里纯文本编辑器 命令行转换无排版可言性能最好没有即时反馈写完才能看成品这五个方向的痛点都很有代表性。在线工具协作强但在大文档面前还是差点意思离线工具里 Typora 的综合体验已经属于第一梯队但它对超大文档的支持并不理想。而我经常要处理的文档恰恰是那种二三十万字的规格比如把一年的技术博客整合成电子书底稿或者把产品历史文档做一次全面迁移。这种场景下编辑器性能很重要而现有工具绝大多数在这个量级上就会开始喘气。1.3 压垮我的那个深夜场景具体让我崩溃的场景是这样的那晚我在整理一份团队的技术规范文档加上历史片段大概有两万行其中有一张大表格记录了十几个模块的接口状态。我调整完表格结构准备插入一个新行光标从按下方向键开始就有一点迟滞拖动滚动条之后卡顿越来越严重。我习惯性地按了 CmdS但编辑器无响应。再等几秒整个窗口直接白屏。因为当时没开自动保存扩展我只恢复了不到一半的内容。事后我复盘了一下卡死的根因这类所见即所得编辑器在每次输入变化时往往会对全文做解析和重排。文档规模上来之后单次操作的计算量就太大很容易突破浏览器一帧的 16ms 预算表现就是卡顿、白屏、失去响应。那一刻我意识到市面上没有哪款编辑器会专门解决我的这批场景。与其等一个遥远的版本更新不如动手做一款真正适合自己的 Markdown 编辑器。于是“小语文稿”就立项了。2. “好看”不是换肤我把排版引擎整个重写了2.1 高颜值的本质是“排版系统”而不是皮肤很多产品把“好看”理解为换一套图标、加几个圆角、配一个好看的背景图。但对一个每天写几千字的人来说好看的本质是排版的节奏感是字与字、行与行、段与段之间的空间关系是标题和正文之间清晰的层级是代码块和引用块在视觉上一眼就能和环境区分开。这些不是一张皮肤能解决的必须从排版引擎层面去设计。我希望达到的效果是打开“小语文稿”时文档呈现出来的质感接近一本排版精良的纸质书而不是一个普通的网页。这就意味着要对字体栈、行高、段间距、标题上下留白、代码块内边距、引用块边框、列表缩进甚至中英文之间的微间距做非常细致的控制。比如正文的行高我反复调了几十版最后确定在全角字符高度 1.75 倍左右中文阅读最舒服而英文内容在这个行高下也不会显得太松散。2.2 为什么现成的渲染方案救不了我Markdown 渲染这条路上最常用的方案是 Marked 和 markdown-it 这类开源解析器。它们的标准输出是把 Markdown 转成一段扁平 HTML 字符串然后直接塞到页面里。轻量级使用没问题但我想做到的是给每个块级元素注入结构和数据信息方便后面的性能优化和主题定制标准的默认渲染结果满足不了这个需求。所以我做了一个关键决定把 markdown-it 当作 tokenizer分词器但完全接管它的 renderer 层。这样每个块级 token 在输出 HTML 时都能带上块序号、起始行号、块类型这些元信息。比如一个段落会输出成类似这样的结构const md new MarkdownIt({ html: false, linkify: true }); md.renderer.rules.paragraph_open (tokens, idx) { const token tokens[idx]; const startLine token.map[0]; const endLine token.map[1]; return p>:root[data-themepaper] { --font-serif: Source Han Serif SC, Noto Serif CJK SC, Songti SC, serif; --font-sans: Source Han Sans SC, Noto Sans CJK SC, PingFang SC, sans-serif; --line-height: 1.75; --paragraph-gap: 1.2em; --code-font: JetBrains Mono, Fira Code, SF Mono, Consolas, monospace; --heading-scale: 1.618; --block-quote-border: 4px solid rgba(0, 0, 0, 0.12); }这套 CSS 变量体系是三个内置主题亮色、暗色、纸感的统一底座。切换主题时变化的是颜色和部分材质参数而间距、节奏、字体回退链这些排版核心参数保持不变。这样用户从亮色切到暗色时不会感觉版面发生了跳变阅读节奏能保持住。2.4 代码块、表格和引用的视觉重量排版的好坏还体现在特殊块的处理上。代码块如果只是简单地换个背景色加个边框看起来会很生硬。我在代码块左上角加入了语言标签行内代码和块级代码区分处理块级代码使用独立横向滚动而不是自动换行这在展示长代码行时能避免结构错乱。表格是比较难排的一项。Markdown 表格在渲染时容易出现长列把整个版面撑爆的问题我给容器设定了table-layout: fixed加overflow-x: auto让表格可以横向滚动而不是破坏页面布局。引用块则采用窄边框加浅色背景的做法保持分量感但不过度抢眼。这些看起来都是小细节但组合在一起就是一款编辑器“颜值”和普通换肤产品的本质区别。3. “彪悍”不是形容词是这五个硬指标3.1 编辑器内核为什么是 CodeMirror 6性能这件事首先要从内核选型说起。市面上做源码编辑器集成主流方案包括 Monaco、ProseMirror、CodeMirror 6还有一部分人选择基于 textarea 自研。Monaco 是 VS Code 的内核功能非常全但体积爆炸而且它默认针对的是代码语言服务对 Markdown 长文这种纯文本输入场景反而是杀鸡用牛刀。ProseMirror 是富文本编辑器它内部的文档模型是结构化节点树和 Markdown 源代码编辑的诉求就不是一个方向。CodeMirror 6 是三者中更适合 Markdown 源码编辑器的基础。CodeMirror 6 的架构非常干净把编辑器状态state和渲染视图view分离每次输入和修改都走 transaction变更历史、撤销重做、选区恢复都很规范。而且它提供了很好的 viewport management只渲染当前可视范围内的行这让我在一百万行的文档里滚动也不会卡死。但我对编辑器的要求不仅限于内核还得围绕内核搭一套增量解析和渲染管线这部分代码才是“小语文稿”最核心的工作量。我整理了一份性能基准表在内核选型阶段就定了目标。测试文档是一个真实项目的 Markdown 文件里面混合了章节标题、大段正文、表格、代码块和嵌套列表场景性能目标实测结果1 万行文档输入延迟 16ms平均 8ms10 万行文档打开耗时 2s1.4s10 万行文档滚动帧率55fps 以上58fps50 万行文档峰值内存 1GB826MB连续输入 300 个字符无感知卡顿通过这些数据是在一款中端配置的开发机上测出来的不是顶配 Mac。能达到这个水平关键靠的是第二节说的增量渲染还有后面要讲的异步调度策略。3.2 增量解析从全文重排到按块刷新大多数解析器的问题在于每按一次键盘就要从头到尾扫描整个文档解析出完整的 token 流。文档规模小时无所谓到了十万行级别一次全量解析的代价就是几百毫秒表现在界面上就是卡顿和丢帧。要解决这个问题必须放弃“每次改动全量重跑”的思路改成局部感知的增量解析。具体做法是把文档看作一个由顶层块组成的序列每个块有自己的起始行、结束行、块类型和内容哈希。编辑操作发生时先通过事务变更的范围找到受影响的块再向上回溯构建一个“脏块集合”。比较麻烦的是块边界的重划分你在一个标题前敲一个回车这个回车可能把原来的标题块一分为二也可能把相邻的两个段落合并。所以只重渲染“改动的那一块”是不够的。我采用的策略是对于每个编辑事务从变更位置向前后各扩展一段邻接区域然后在这个扩展范围内重新做一次块级解析。如果重新解析后的块边界和原来一致就只更新块内 HTML如果边界变了就递归向上下层扩散直到整个块列表恢复稳定。这套逻辑类似浏览器渲染引擎的 dirty rectangle 思路只是作用在 Markdown 结构上。下面是简化的伪代码function onEdit(transaction) { const range transaction.changes; let dirtyBlocks blockIndex.findOverlapping(range.fromLine, range.toLine); while (dirtyBlocks.length 0) { const block dirtyBlocks.pop(); const newChunks parseBlocks(block.rawText, offsetOf(block)); if (blockBoundaryUnchanged(block, newChunks)) { schedulePatch(block.id, renderBlock(newChunks[0])); break; } else { updateBlockIndex(block.id, newChunks); const neighbor getAdjacentBlock(block, range.direction); if (neighbor) dirtyBlocks.push(neighbor); } } }这个机制跑通以后十万行文档的编辑操作从成本上被降到了只处理几个块的量级卡顿问题自然就消失了。3.3 输入到预览的 16ms 生死线解析和渲染解决之后还有一个实时预览的调度问题。浏览器的正常帧率是 60fps意味着每帧只有 16.67ms。这 16ms 里要完成脚本执行、样式计算、布局、绘制以及渲染线程之间的通信。如果输入事件到来后编辑器在当前帧内既更新源码区又全量渲染预览区必然超时所以必须给不同任务排优先级。我的调度策略是输入事件本身拥有最高优先级因为用户敲键盘时最直接的反馈是光标移动和字符出现源码区必须立刻响应。预览区的更新放到解析线程完成之后再通过requestIdleCallback在浏览器空闲时间片里处理。如果空闲时间不足就砍掉非关键部分的渲染只保证下一次帧不卡顿。这个感受上的差异是“每输入一个字符延迟 50ms”和“输入如飞同时预览稍慢半拍”的区别显然后者才符合写作工具该有的体验。3.4 自动保存与崩溃恢复写作工具的最后底线经历过那次两万字文档白屏没保存的事故之后我在这款产品里把数据安全当作一等公民来设计。自动保存不是简单地在编辑器里挂一个定时器然后定期把全文写进磁盘那样做有两个问题一是每两秒整篇写入写盘频率高、磁盘压力大二是一旦在写入中途崩溃文件损坏概率更高恢复困难。“小语文稿”的自动保存分三层。第一层是防抖保存输入暂停 800ms 后把增量写入本地临时库第二层是每两分钟生成一次完整快照和增量数据分开存放第三层是在应用启动时做一致性校验如果发现快照和增量对不上优先恢复增量数据并保留损坏现场供排查。这个机制上线后我做过一次比较极端的测试在输入到一半时直接杀进程重启后恢复的内容差一行基本等于没丢。4. 开发中踩过的五个坑每一个都值得写一篇小作文4.1 中文输入法“组合态”导致的字符丢失这个坑我印象很深。编辑器第一版跑通之后我拿它写了一篇文章写着写着发现一个诡异的问题用中文输入法打拼音时偶尔会出现候选词还没选正在输入的一串拼音字母就消失了。英文输入完全正常切到中文输入法就会间歇性触发而且不是每次都能复现。排查链路是这样展开的我先怀疑是 CodeMirror 的事件处理顺序和输入法的 composition 事件冲突于是去查 CodeMirror 6 关于输入处理的源码确认它在beforeinput和composition事件上做了专门的合并处理。问题可能出在我的自定义插件里每次beforeinput触发后我会根据当前光标位置做脏块检测和重排而中文输入法在组建组合态时会在compositionstart之后发起一连串的beforeinput这些输入不应该被当作正式的文档变更来处理。后来我把自定义的渲染调度封装成一个插件显式监听组合事件状态compositionstart时挂起所有增量渲染逻辑compositionend之后再根据最终变更做一次块级重排。这个修复上线后中文输入就稳定了。如果你也在做类似编辑器一定要记住composition 事件期间修改 DOM 或触发异步渲染都很容易把 IME 的临时状态打掉这是中文输入场景下的经典雷区。4.2 预览区滚动同步的像素级漂移做过左右分栏 Markdown 编辑器的朋友可能都遇到过左边在源码区滚动右边预览区跟着滚滚着滚着两边就对不上了尤其文档中包含大段代码块和表格时偏差会越来越明显。第一版实现里我的同步思路比较粗暴——用编辑区当前可见行号除以总行数得到一个比例然后把这个比例应用到预览区的滚动高度上。听起来合理实际一跑就露馅。根因在于预览区的可视高度不是按行数均匀分布的。同样的行数代码块比普通段落渲染出来的高度大得多表格更夸张。简单地按行号比例映射必然导致不同块类型之间高度差异被忽略漂移就产生了。修复方案是把行号比例改成块映射编辑区滚动时先根据当前可视顶部和底部行号查询块索引找到对应块的起始偏移再到预览区按同样块去定位块间的偏移差值用插值处理保证一个块内还算稳定跨块时平滑过渡。这套映射逻辑稳定下来之后预览同步的问题才算彻底解决。4.3 从网页粘贴内容变成一坨 HTML 的污染Markdown 写作者经常要从网页复制文本到编辑器里但复制的内容一旦包含格式浏览器剪贴板里同时会有 text/html 和 text/plain 两种数据。如果编辑器直接读取 text/html那粘贴进来的就是一长串带内联样式的 HTMLMarkdown 源码区瞬间被污染后续排版全乱。这个问题的复现路径非常简单你在浏览器里选中一段带格式的文字复制粘贴到编辑器看看源码。解决方案分两层。第一层是自定义 paste 事件处理器优先读取 text/plain保证最简洁的用户预期第二层是如果 detect 到用户确实想保留一些结构比如表格、加粗、链接就用 turndown 库把 HTML 转成干净的 Markdown并在这个过程里做样式清理。我还加了一个“粘贴为富文本”的开关默认关闭用户主动开启后才会把网页里的格式转换进来。这样既照顾了普通用户的粘贴习惯也留出了高级用户想要的自由度。4.4 低配电脑上的 CPU 飙升问题有一个阶段编辑器开着什么都不动CPU 占用率也稳定在 18% 左右。这个现象非常反直觉因为按道理没有任何滚动和输入事件时不应该有持续的 CPU 消耗。我打开系统性能分析工具抓了一段时间的调用栈发现罪魁祸首是一个requestAnimationFrame循环滚动同步模块在初始化时启动了一个常驻的 rAF 监听用来计算滚动偏移差值。虽然滚动事件没触发但 rAF 本身只要注册了就会每帧都执行回调造成持续的 CPU 消耗。修复方法很朴素rAF 循环只在有滚动事件期间运行滚动结束后用一个 200ms 的防抖把循环停掉同时把滚动处理回调中非必要的样式读取操作缓存起来避免每一帧都强制触发浏览器样式重算。这个改动上线后待机 CPU 占用从 18% 降到了 1% 以下。这个坑给所有人的启发是优化性能之前先拿性能剖析工具看真实瓶颈不要凭感觉猜很多时候问题不在编辑器核心逻辑里而在某个你以为已经关闭了的常驻循环。4.5 主题切换时的白闪暗色主题用户点击切换主题时编辑器偶尔会闪一下白光再进入暗色。这个现象非常影响“高颜值”的观感尤其在深夜写作时满屏白色闪一下眼睛非常难受。我一开始以为是 CSS 变量切换的过渡动画没做好后来才发现问题出在 DOM 批量更新的顺序上主题切换会把新的 CSS 变量挂在根节点但挂载的同时渲染管道也在进行布局更新两个操作合到一起时浏览器会先以默认背景色重绘一帧再应用新变量于是白闪就出现了。解决方法是把主题切换纳入渲染调度器让它和普通文档更新走同一条批量更新通道先移除旧的 theme class再挂载新的 theme class同时设置transition为按需的background-color过渡并确保变量变更和文档重绘发生在同一帧内不产生中间帧。为了保险我还在切换过程中给根节点设置了一次性的backface-visibility: hidden来做防闪烁处理。这个问题看起来小但如果不较真用户对一款“高颜值编辑器”的第一印象就毁在这个细节上。5. 技术选型这笔账我替你算过了5.1 为什么是 Tauri 而不是 Electron有了浏览器端能够完整运行的编辑器内核之后我还在考虑要不要打包成桌面应用。很多类似项目的第一反应是 Electron毕竟生态成熟、文档多、踩坑的人也多。但 Electron 的资源占用一直让我犹豫一个简单的文本编辑器如果安装包动不动一百兆、内存占用长期两百兆起步实在和“轻快”两个字沾不上边。Tauri 是另一条路线它利用系统自带的 WebView 渲染打包体积小、内存占用低。我实际对比过同一套代码Electron 打包出来大约是 88MBTauri 只有 6MB 左右冷启动打开一个十万行的文档Electron 大概需要 1.8 秒Tauri 在 1.2 秒左右。内存上也有明显差距Tauri 常驻内存大约 35MBElectron 则要 110MB 以上。当然 Tauri 也有它的麻烦比如在部分旧版 Linux 系统上 WebView 版本低某些 CSS 特性不支持需要额外做兼容判断。但对我来说“轻快”带来的体验提升远大于这些兼容成本。5.2 桌面端之外Web 版和命令行我都没放弃桌面端只是“小语文稿”的一种存在形式我从设计第一天就坚持编辑器核心和外壳分离。核心是一个纯 TypeScript 实现的渲染与解析引擎它可以嵌入 Tauri 外壳做桌面应用也可以直接在浏览器里跑还可以暴露给命令行工具做批量渲染。这个分层救了我很多次桌面端排查问题嫌麻烦时我直接在浏览器里写测试页面复现效率高很多后面要支持移动端阅读视图时核心引擎也能直接复用。命令行也值得一说很多老派的 Markdown 工作者习惯在终端里做批量转换。我给“小语文稿”留了一个 CLI 入口支持smd build input.md -o output.html这样的操作并且完全复用同一套排版引擎来渲染。这样桌面端预览看到的样子和命令行产出的 HTML 是一致的不会出现“编辑器里好看导出就变丑”的问题。5.3 给想入坑做编辑器的人三句实在话第一句编辑器内核不要自己造除非你准备投入三年以上时间。CodeMirror 6 这样成熟项目积累的输入法处理、选区模型、撤销重做、视口管理是个人开发者很难短期再造出来的与其折腾内核不如在内核之上做真正的产品价值。第二句“好看”从第一天就要当作系统设计来做而不是最后套个皮肤。我见过太多产品功能很强但视觉拉胯等你回头想改排版时发现所有样式都写在组件里根本没法统一调整。CSS 变量和主题系统应该在架构阶段就规划好。第三句性能测试一定要从第一版就用真实的超长文档压不要等到功能做完了再回头优化。我见过太多项目到最后才发现性能问题无处不在只能推倒重来。早期就引入十万行、二十万行的真实文档作为基准能逼着你在每个设计决策时都考虑规模化风险。结束前的最后一段按惯例收尾不写总结我只说一个开发之外的真实感受。你去做一款自己每天都会用的工具时很多设计决策会变得异常清晰因为你就是那个唯一的用户任何不好用的地方都会在第二天早上准时出现在你面前躲都躲不掉。这段时间里“小语文稿”陪我写完了好几篇长文我已经开始离不开自己造的轮子了。虽然它距离“完美”还很远但每次打开看到中文排印清清爽爽地躺在屏幕上光标移动行云流水我就觉得当初被白屏气得决定自己动手那一刻是对的。作为 Markdown 重度用户我强烈建议你也尝试记录下自己每天使用工具时那些“被冒犯”的瞬间那里面往往藏着一个非常适合你去独立实现的项目。
返回列表