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

资讯详情

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

重构Markdown编辑器:2MB文档1秒打开的优化实践

重构Markdown编辑器:2MB文档1秒打开的优化实践 1. 为什么我会花两个月去重构一个 Markdown 编辑器我自己维护了一个 Markdown 编辑器不算大但每天都有几百个人在用。最开始它就是一个简单的 Web 端工具支持左侧写、右侧预览用户粘性还不错。直到某天有人往里面塞了一个 2MB 的 Markdown 文件页面直接卡死浏览器弹出“无响应”。从此我开始意识到这不是用户的文档太夸张而是我在最开始写代码的时候就没想过会有人在里面写一本20万字的技术手册。这个项目的重构断断续续做了两个月最终达到的效果是2MB 文档约 1 秒打开输入响应稳定在 60 帧上下。这篇文章就是想把这两个月踩的坑、做的取舍、验证过的方案完整记录下来。适合那些正在做编辑器类应用、或者遇到长文档性能问题的人参考哪怕你完全不用 Markdown里面的解析、渲染、虚拟滚动思路也照样能迁移到自己的项目里。1.1 最初的问题“能跑就行”的编辑器老版本功能不算少支持 GitHub Flavored MarkdownGFM、任务列表、代码高亮、表格、图片拖拽上传。代码结构也很清晰一个文件负责解析一个文件负责渲染一个文件负责快捷键。但所有逻辑都围绕一个核心假设文档很小。小到可以随便字符串拼接、随便遍历整棵 AST、随便在每次输入时全量重新渲染。这个假设害了我。Markdown 属于轻量标记语言写起来舒服但真到了“备忘录变成长篇连载小说”的程度性能问题就全暴露了。比如 2MB 的 Markdown 文件大约有几十万字符几十万个 Token几千个块级节点。老版本的做法是每次键盘按下立即解析全文生成 HTML 字符串然后整体替换预览区的内容。这在 50KB 文档时还能忍到了 2MB 就变成灾难。更要命的是不只预览区卡连编辑区也卡。有些编辑器为了保持编辑区和预览区的滚动同步会在每次输入时重新计算所有行的高度更新滚动位置。这些操作叠加在一起直接把用户输入卡到了 200ms 以上延迟难怪浏览器会提示无响应。我第一次用性能分析工具抓火焰图时看到innerHTML html那一行占了主线程 78% 的时间整个人都不好了。1.2 性能瓶颈到底在哪从用户反馈到火焰图重构之前我花了一周时间只做一件事定位瓶颈。用户反馈最多的问题按次数排序分别是打开大文件很慢、输入卡顿、滚动掉帧、预览和编辑不同步。表面上看是四个问题实际上一查根源只有一个所有操作都在主线程上同步执行而且每次都处理全量数据。我用 Chrome DevTools 的 Performance 面板录制了一次打开 2MB 文档的过程结果非常直观。脚本执行时间 4.6 秒其中 Markdown 解析占 2.1 秒HTML 渲染占 1.8 秒浏览器布局和绘制占 0.7 秒。这还没算文件读取和编码转换。再深入看调用栈解析器用的是正则表达式递归匹配正则回溯导致大量时间浪费且解析出的抽象语法树AST没有缓存预览区每次全量构建 DOM 节点光创建几万个 DOM 元素就够浏览器喝一壶了。我也专门测了输入性能。每按一个键全文重新解析一次一次大概 200ms。连续打字时这些任务在事件队列里排队表现就是卡顿、丢字。这里有个很反直觉的点你以为卡是因为电脑配置低其实完全不是而是算法复杂度是 O(n) 甚至更糟文档变大耗时线性飙升最终撞上主线程的帧预算。1.3 重构目标不换技术栈只换思路确定重构后我给自己制定了三个原则。第一不换技术栈继续用原生 JavaScript Markdown 社区库因为换框架的重构成本太高而且旧编辑器已经积累了不少用户习惯第二不追求“无敌快的即时解析”而是追求“用户感知不到等待”也就是尽量把耗时移到后台让界面先响应第三每一步优化都要有数据支撑不做“我觉得快了很多”的玄学优化每次改动都用 Performance 面板和内存快照对比。这三个原则非常重要尤其是第一个。很多人一听重构就想趁机把 Vue 换成 React或者把 JavaScript 换成 TypeScript结果重构了三个月功能没加多少性能还更差了。我的做法是在保持对外接口不变的前提下把核心的解析和渲染链路整个换掉这样用户无感功能不退化风险也可控。最后我用了两个月的业余时间两周做方案六周写代码两周回归测试总算在目标时间内完成了。2. Markdown 编辑器的核心链路从文本到视图要理解这次重构必须先看清楚一条 Markdown 编辑器最核心的数据流输入文本 - 分词 - 构建 AST - 生成 HTML 字符串 - 解析成 DOM 节点 - 插入到预览区。编辑区还有另一条线输入 - 更新内容 - 语法高亮 - 行号更新 - 滚动同步。两条线在键盘事件时交汇处理不好就会互相拖累。很多前期设计决定都取决于你是否理解这条链路。比如为什么 Markdown 解析不能用正则硬杠因为 Markdown 语法是分层的行内代码套着链接链接里又可以有强调区块引用里还能嵌套列表。正则表达式本质上是有限状态机处理这类嵌套结构天生吃力还会出现“回溯爆炸”。我需要把这一层讲清楚才能解释后续为什么花了大量精力选型解析器。2.1 解析器选型正则一时爽AST 才是正解老版本用的解析器是 marked。这个库很成熟体积小但它的输出格式是 HTML 字符串而不是 AST。这就带来一个问题当你只是为了改一个标题的样式也不得不重新生成整个 HTML再整体替换到 DOM 里。而且 marked 的渲染过程没有 diff 机制老 DOM 会被直接销毁重建滚动位置、选中状态、图片加载状态全被重置。重构时我对比了几个候选markdown-it、remark、micromark。最后选了 remark 系列具体是remark-parseremark-rehyperehype-stringify。原因很简单它把解析和渲染彻底拆开输出的是标准的 unist AST可以精细地做增量更新、缓存和各种插件处理。虽然体积比 marked 大不少但对一个桌面级编辑器来说运行时性能远比体积重要。这里想多说一句选型经验。不要只看 GitHub Star 数量和下载量要看数据模型是否匹配你的场景。marked 适合“一次性把 Markdown 转成 HTML 塞到页面里”而我要的是“能缓存、能更新、能跟踪节点变化”的 AST。remark 的 AST 是纯 JSON 对象可以序列化、可以缓存、可以 diff这为后续的增量渲染打下了基础。选型本身不是越新越好而是越贴合需求越好。2.2 渲染管线HTML 生成、diff、DOM 更新有了 AST 之后渲染管线就能分成三步AST 到 HTML 字符串HTML 字符串到 DOM 节点DOM 节点到可见视图。常规做法是直接把第二步和第三步合并用innerHTML一把梭。但这样会导致整块预览区都被替换性能损耗极大。重构后的做法是先建立一份虚拟 DOM 快照。每次解析完生成新的 AST并不急着渲染而是跟上一份 AST 做对比找出哪些块节点新增、删除、修改然后只更新对应的 DOM 节点。具体用的是类似simple-diff的算法针对树形结构做前后比较。因为是 Markdown 的块级结构大部分差异都是局部的比如你只改了一个段落那么 diff 结果往往只有这一个段落节点变化预览区只需要更新这个段落其他节点原封不动。这部分实现起来比想象中复杂因为 Markdown 的嵌套结构很深层比如列表套列表、引用套段落。diff 算法需要递归比较孩子节点同时维护一个 key 来记录节点的身份。我给每个块节点生成了一个稳定的标识符基于哈希值内容变了哈希就变diff 时就能快速判断。实测下来大部分输入操作只需要更新 1 到 3 个 DOM 节点相比之前全量替换性能提升了 50 倍以上。2.3 编辑模型全量重渲 vs 增量更新编辑区是另一个重灾区。老版编辑器用的是contenteditable每次输入浏览器都会生成一个复杂的 DOM 树然后前端去读取innerHTML来同步 Markdown 源文本。这等于把大脑放在一个会不停重启的机器上你怎么优化都白搭。重构时我做了一个关键决定放弃contenteditable改用 CodeMirror 6 作为编辑内核。这不是偷懒而是 CodeMirror 内部实现了非常高效的文本模型和增量渲染支持百万行级别的文档编辑。它采用的是基于Text类的不可变字符串以及 viewport 渲染机制只渲染可视区域内的行滚动时动态加载。这个决定让“2MB 文档打开约 1 秒”成为可能。因为 CodeMirror 在初始化时并不会一次性生成所有行的 DOM它只生成可见的几十行。它内部的行高测量和隐藏内容处理都非常成熟。相当于我把编辑器的底层重写工作交给了专业团队自己专注在 Markdown 特有逻辑上。如果你要自己实现一个编辑器我不建议从零开始造编辑内核除非你想研究底层原理否则复用成熟的代码编辑器内核是性价比最高的选择。3. 两个月里的关键重构动作这是整个重构最核心的部分。我按时间顺序记录了几件大改动每一件单独拿出来都很简单但组合在一起才达到了最终效果。这些动作包括文件读取、解析缓存、虚拟滚动、输入节流和增量高亮。我会把每一步的原理、代码示例、实测效果都写出来方便你直接抄作业。3.1 文件读取与二进制分块第一个拦路虎是文件读取。用户打开一个 2MB 的 Markdown 文件FileReader.readAsText会把整个文件一次性读入内存并且做全量编码转换这过程在普通电脑上就要花 300ms 左右。而且读取完成后JavaScript 拿到的是一个巨大的字符串后续传给解析器时又要分配一块同样大小的内存GC 压力非常大。优化后的方案是按照文件类型选择读取方式。如果是 UTF-8 编码的纯文本直接读取成 ArrayBuffer然后用TextDecoder.decode()进行流式解码解码时设置{ stream: true }可以分块解码并且能提前判断文件是否存在 BOM。这样第一个字节到第一个字符的渲染不再需要等待整个文件读完界面可以先显示“加载中”占位然后渐进式填充。还有一个细节很多 Markdown 文件里含有非 UTF-8 编码的字符比如中文 Windows 下的 GBK。直接按 UTF-8 读取会产生乱码。所以我在打开文件时会先检测 BOM再根据内容推断编码实在推断不出来就给用户一个手动选择编码的权利。这个功能看起来小但实际用起来非常贴心能减少大量乱码反馈。3.2 并行解析与缓存策略老版本的解析是同步的放在主线程里2MB 文档解析 2 秒多。重构后我做了两件事把解析移到 Web Worker同时给 AST 加上了持久化缓存。Web Worker 的好处是显而易见的解析任务不再占用主线程界面可以保持响应。但如果只是把同步代码原封不动挪到 Worker整体时间并没有缩短只是不卡界面了。所以我还得优化解析本身。remark 的解析流程本身是同步的但在 Worker 里可以改为分段解析将大文档按语义块切分比如按空行和标题分成多个段落每个段落独立解析成 sub-AST最后合并成根 AST。这样能充分利用多核 CPU通过Promise.all并发解析多个块。缓存策略上我使用了基于内容哈希的缓存。文件首次打开解析完成后把 AST 序列化成 JSON 存入 IndexedDB同时记录文件的最后修改时间和内容哈希。下次打开同一个文件先比较哈希如果一致直接读取缓存解析时间直接从 2 秒降到 20ms。如果用户编辑了文件哈希就会变化缓存失效重新解析。这套策略让“二次打开”几乎瞬间完成实际体验非常棒。3.3 虚拟滚动与懒渲染打开文件快不代表界面流畅滚动起来掉帧照样让人崩溃。2MB 文档渲染出来的 HTML 如果全部转成 DOM那可能是几万个节点浏览器布局和重绘成本极高。虚拟滚动是唯一正解。预览区的虚拟滚动我实现了比较轻量级的一个只渲染可视区域附近 300px 范围内的块节点每块根据 AST 预先计算一个估算高度滚动时动态计算实际高度并调整占位元素。这里有个难点Markdown 中某些元素的高度无法提前准确计算比如图片、代码块、表格。我的策略是分两类处理文本为主的段落和标题可以根据字符数和字体度量估算高度图片和大型表格采用懒渲染进入视口时才真正加载和计算高度加载完成后更新总滚动高度。CodeMirror 6 本身有自己的虚拟渲染机制所以编辑区我基本不用操心。但编辑区和预览区的同步滚动是个新问题。解决方案是维护一个行号映射表每个编辑区的行号对应到 AST 节点和预览区的块 ID。当编辑区滚动时找当前首行对应的块 ID再定位预览区的滚动位置反过来也是同理。这个映射表在解析时构建增量更新时只改受影响的部分性能开销很小。3.4 打字跟手的核心debounce 增量语法高亮编辑性能优化到最后瓶颈在输入时的实时语法高亮。CodeMirror 6 提供了增量语法解析能力它基于 Lezer 语法系统只重新解析发生变化的片段。但 Markdown 的语法和代码不同它的高亮依赖上下文比如代码块内部和外部的高亮规则不同。为了让 Lezer 正确处理 Markdown我引入了一个名为codemirror/lang-markdown的官方扩展它本质上是把 Markdown 的块结构和行内代码用 Lezer 来表达。即便如此输入时我们也不应该每敲一个字符就立刻解析高亮。我的做法是老老实实 debounce 到 80ms。这 80ms 既能保证输入体验又能让打字时的连续事件不会每次都触发重解析。实测下来400ms 的输入延迟是用户能感知的临界点80ms 完全没问题。增量高亮的核心逻辑就是在输入时Lezer 会生成一个变更范围然后我取出受影响的行重新做语法高亮并把高亮结果应用到 CodeMirror 的 decorations 上。整个过程只涉及几行所以即使是很长的文档也能做到打字跟手。除此之外我还对预览区做了延迟更新。编辑区的输入事件只负责更新本地状态预览区的渲染任务通过requestIdleCallback在浏览器空闲时执行。如果用户一直在打字预览区更新会被一直推迟直到用户停顿这时候再一次性渲染。这个策略虽然看起来“不实时”但用户体验比“实时卡顿”好太多而且用户本来就更关心正在输入的内容而不是远处的预览。4. 实测数据与真实体验2MB 文档约 1 秒打开目标到底有没有达成不能靠感觉得靠数字。我专门搭建了一套测试环境把老版本和重构后的版本放在同一起跑线上跑了一组对比测试。这里的数据都是在相同硬件和浏览器环境下得出的我没有刻意挑选对自己有利的数据相反为了让结果更有说服力我挑的测试文档非常极端。4.1 测试环境与基准方案测试环境一台 2020 年的 MacBook ProM1 芯片16GB 内存系统为 macOS。浏览器使用 Chrome 118 稳定版无痕模式关闭所有扩展。测试用的 Markdown 文档结构包含大量中文和英文混合文本、嵌套列表、几十个代码块、几十张外部图片链接、若干表格总共约 2.1MB大约 35 万字符。老版本的构建产物是重构前的最后一个提交新版本是重构完成后的版本。为了公平两者都使用相同的 Markdown 渲染主题样式并且都不开启任何实验性优化选项。每次测试前清空浏览器缓存和 IndexedDB确保是冷启动。测试共进行 10 次取中位数和平均值。4.2 性能对比表我把关键指标整理成了表格方便你直观对比指标老版本重构后版本提升倍数冷启动打开 2MB 文档到可编辑4.8 秒1.0 秒4.8 倍首次完整预览渲染6.3 秒1.4 秒4.5 倍打字响应延迟90% 输入210ms35ms6 倍编辑区滚动帧率快速滚动18 fps55 fps3 倍预览区滚动帧率快速滚动12 fps48 fps4 倍内存占用编辑 预览620MB180MB3.4 倍二次打开内容哈希命中缓存4.2 秒80ms52.5 倍以上数据说明“2MB 文档约 1 秒打开”并不准确准确说是 1 秒左右而不是超过 5 秒。冷启动包含文件读取解析和编辑器初始化1 秒符合目标。二次打开更是快到几乎无感。内存从 620MB 降到 180MB对于大型文档来说是个巨大的改善用户不用再担心标签页崩溃。4.3 内存与滚动的优化效果内存优化主要来自三方面不再全量生成 HTML 字符串、虚拟滚动避免了大量 DOM 节点常驻、AST 缓存复用减少重复解析。老版本 2MB 文档生成的 HTML 字符串和 DOM 节点是纯净文本的十倍以上浏览器内存直接失控。新版本只渲染可视区域加上 CodeMirror 本身只维护视口范围内的编辑器行内存开销自然就下来了。滚动方面的优化效果也很直观。老版本快速滚动时会一次性触发布局和绘制全部节点造成明显的白屏和卡顿。新版本由于虚拟滚动和懒渲染滚动过程中始终只有几十个节点在更新帧率稳定在 50 帧以上。实际体验就是跟手能够像正常阅读网页一样滚动长文档。不过我还是要诚实地说明一些限制。2MB 文档 1 秒打开是在 M1 芯片上测的如果你用的是几年前的低端电脑时间可能会长到 2 秒左右但这个量级已经完全可以接受。另外如果文档包含大量超宽表格或超长代码行虚拟滚动的性能仍会受影响因为横向滚动和代码折行处理比较复杂。这个问题我在下文的常见问题里会详细说。5. 重构路上踩过的坑实战排查实录再顺利的重构也会遇到各种奇奇怪怪的坑。这里我挑几个最典型的问题把现象、排查过程和最终解决思路全写出来。这些坑不一定只属于 Markdown 编辑器很多是文本处理领域的通病希望能帮你少走弯路。5.1 中文换行和表格渲染错位的坑中文文本和英文有个很大的不同中文单词间没有空格浏览器在中英混排时的换行策略很复杂。我在虚拟滚动计算块高度时一开始用的是canvas.measureText()来估算文本宽度结果发现中文文本到了换行边界经常出现偏差导致预估高度和实际高度不一致滚动时出现跳动和错位。排查后发现canvas.measureText()使用的字体和浏览器排版字体可能不同导致测出来的字符宽度和实际渲染宽度不一样。我换成了Range.prototype.getBoundingClientRect()动态获取文本节点的准确宽度并把字体加载和 fallback 都统一设置为system-ui。同时在预计算高度时我预留了 10% 的余量避免因为换行差异导致占位元素高度不够。表格的问题更棘手。Markdown 表格在某些解析器里会生产非常宽的表格横向溢出预览区。如果用户开启了“自动换行”又会导致表格结构混乱。最终方案是给预览区表格加上横向滚动容器同时关闭表格内部的自动换行只允许表头在极端情况下换行。这个处理虽然谈不上完美但至少不会出现单元格内容挤到下一列的问题。5.2 图片路径和本地资源加载的坑用户在编辑器中拖入图片通常会得到一个本地路径或 base64 编码。如果只是把路径塞进预览区本地路径跨域会被浏览器拦截图片就显示不出来。另外Markdown 文档里的图片有的是相对路径有的带查询参数有的使用 HTML 标签包裹这些都增加了渲染的复杂度。我的解决思路是搞一个资源解析层专门处理三种图片来源网络 URL、本地绝对路径、base64 编码。网络 URL 直接渲染base64 解码后交给虚拟滚动懒加载本地路径则通过编辑器增加一个代理服务器来读取在浏览器端用自定义协议替换路径。如果是在纯前端环境里没有代理服务器就只能提示用户“本地文件无法预览”但至少不会让整个文档崩溃。说实话图片问题至今没有完美的通用方案不同的使用场景需求不同。如果是本地桌面应用用 Electron 的 file 协议就能解决如果是在网页版编辑器就必须配合后端做文件上传。我在这块花了不少时间最终把图片处理封装成了一个可扩展的插件接口方便不同部署环境自定义。5.3 长文档光标跳动与 Undo 栈爆炸编辑长文档时用户最容易遇到两个问题光标跳回开头或者 Undo 历史无限增长导致内存暴涨。光标跳动通常是因为文本模型和视图状态没有同步比如在输入时异步更新了源文本导致 CodeMirror 的行 ID 发生变化光标坐标就乱了。解决方法是所有对编辑内容的修改都通过 CodeMirror 的dispatch事务来进行确保历史记录和光标位置都能正确维护。Undo 栈爆炸则是我在重构后期发现的。老版本里我每次打开文件就把全文作为一个历史记录压入栈中导致用户一按 CtrlZ 就回到空白文档。重构后我引入了“合并编辑历史”的策略连续打字时把多个微小的输入合并成一个事务每个事务只记录变更范围而不是整个文档快照。同时限制 Undo 栈的最大深度为 100 步超过就丢弃最老的历史。这样做之后即使编辑一个 2MB 文档一整天Undo 内存消耗也不会超过 20MB。5.4 数学公式和代码块的折行处理支持数学公式是 Markdown 编辑器常见的扩展需求。我在重构过程中把 KaTeX 集成到了渲染管线里但很快发现两个问题一是公式渲染是同步的大量公式会导致首次渲染卡顿二是公式节点不能随意拆分虚拟滚动在计算高度时需要特殊处理。我的方案是把公式渲染也改成懒加载只有进入可视区域时才调用 KaTeX 渲染。同时在 AST 里给公式节点添加一个“不可分割”标记虚拟滚动时不会把公式块拆成多行而是整体计算高度宽度溢出时横向滚动。代码块的折行就简单多了开启了wrap模式后代码行可以软换行并在左侧显示一个换行指示器这样既能保持代码的结构又不会拖垮渲染性能。6. 重构之后的心得与建议列了这么多技术细节最后再说说我的个人体会。第一性能优化不能靠猜一定要先测脉搏再开药。我一开始也以为瓶颈在语法高亮结果用 Performance 面板一看高亮只占很小一部分真正的大头在解析和全量渲染。如果没有这一步测量我大概率会在错误的方向上浪费时间。第二针对 Markdown 这类有明确结构的文本AST 化是性能优化的前提。不管用哪个解析器只要能把文本变成可操作的数据结构后续的 diff、缓存、懒渲染就都有的放矢。反过来如果一直停留在“字符串到字符串”的层面优化手段就非常有限只能不断给千疮百孔的老架构打补丁。第三关于虚拟滚动和增量渲染能借力成熟的库就不要重复造轮子。这次重构最值得的一笔投资就是引入 CodeMirror 6它帮我处理了编辑内核最复杂的一部分。预览区的虚拟滚动我原本也想找一个现成的库但试了几个都不合适最后还是基于自己维护的 AST 高度缓存实现了轻量版。这个取舍的关键在于核心业务逻辑必须亲力亲为通用组件尽量用久经考验的成熟方案。最后想补充一个很小的优化在文件打开时我加入了一个快速“骨架屏”效果显示标题层级结构而不是一直空白等待。这虽然不直接提升性能但能让用户感知到“程序在干活”心理等待时间大大缩短。有时候用户体验不只有毫秒级的数据还有这些细节的照顾。如果你也在做类似的重构记住一个原则让每个操作只处理它真正需要触碰的数据其余该懒就懒、该缓存就缓存。Markdown 编辑器如此其他文本密集型应用也一样。想通了这一点理论上任何 2MB 的文档都能在 1 秒内打开。
返回列表