两个月前,我接到一个看起来不算复杂的任务:把团队内部用的 Markdown 编辑器搞快一点。用户反馈很简单,一个 2MB 的 .md 文件,打开后要喝杯水才能等出界面,滚动一下又要卡两三秒,再大一点的文件干脆直接白屏。重构前我评估了一下,整个编辑器代码量不大,功能也不算花哨,无非就是编辑、预览、导出,最大的痛点只有一个:文件一大,全部完蛋。
这个“大文件卡顿”的问题,几乎是所有自研 Markdown 编辑器都绕不过去的坎。如果你正在用 Typora、Obsidian 或者自己写过编辑器,多少也遇到过类似场景:文档写到几万字之后,输入光标开始变肉,代码块一多滚动就拖影,全文搜索能卡到风扇起飞。这篇文章我把这次重构的思路、具体实现方案、踩过的坑按顺序拆给你看,尤其是“2MB 文档约 1 秒打开”这个目标是怎么一步步实现的。适合正在写编辑器、或者接手了性能有问题的 Web 项目的同学参考,哪怕你打算直接用开源编辑器换个皮肤,里面的某些设计思路也值得借鉴。
1. 为什么慢:先弄懂 Markdown 编辑器卡顿的根源
1.1 旧架构的瓶颈分析
重构之前,编辑器的处理流程是典型的“串行全量链路”:读取整个文件到内存,一次性用正则和字符串替换把 Markdown 转换成 HTML 字符串,再塞进 DOM,最后由浏览器渲染出来。这套流程在 100KB 以内的文档上表现还过得去,但一旦文件到了几百 KB 甚至上 MB,麻烦就来了。
核心问题有三个。第一,全量解析。整篇文档无论是否可见,都会被完整跑一遍语法解析。Markdown 解析本身并不算重,但如果是自己写的基于正则的解析器,遇到嵌套列表、复杂表格、行内代码混合加粗斜体这些情况,正则回溯会直接带来灾难性的耗时。第二,全量渲染。解析出来的 HTML 字符串会一次性写入 DOM,浏览器需要重新计算样式(reflow)和重绘(repaint)。DOM 节点一旦上万,主线程会长时间被占用,界面就像死掉一样。第三,主线程阻塞。所有解析、渲染、格式化、搜索都在主线程执行,用户输入和滚动都排在任务队列后面,体验自然“丝滑不了”。
这里面最关键的问题不是某个算法不够快,而是整个架构没有区分“解析”和“渲染”的边界,也没有考虑“局部更新”这件事。每次输入一个字符,结果就是把整篇文档又重新走了一遍完整链路。两三百 KB 的文档勉强能忍,到了 2MB,每一次按键都意味着几百毫秒甚至秒级的全量处理,这已经不是优化一两行代码能解决的了。
1.2 看看一个 2MB 文档到底意味着什么
很多人对“2MB”没有直观概念。这里算一笔账:一个纯中文文档,按 UTF-8 编码一个汉字 3 字节,2MB 大概能装 70 万个汉字;如果是中英文混合、带大量 Markdown 语法符号和代码块,也至少有四五十万个字符。这四五十万字符,解析出来大约会产生 20 万到 30 万个语法 token,渲染成 HTML 后通常对应 3 万到 8 万个 DOM 节点。
浏览器的 DOM 节点数量,常规建议是单页不超过 1500 个左右就可以保持良好性能,超过 1 万个就会明显出现交互延迟,到了 5 万个以上基本就是灾难。一个 2MB 文档打开后生成几万个 DOM 节点,再叠加全文一次性 reflow,浏览器直接被拖垮几乎是必然的。所以旧编辑器“2MB 打开要几分钟甚至直接崩溃”,不是使用姿势不对,而是架构在物理层面就已经扛不住了。
想清楚这一层,重构的目标也就明确了:不能追求“让一次全量解析更快”,而是要让整个流程“根本不会发生全量解析”。后面的所有方案都是围绕这个思路展开的。
2. 重构方案选型:放弃“改改看”,直接换架构
2.1 调研了几条路线,为什么没选重框架
刚开始我考虑过几条现成的路线。第一条是把编辑器底层换成 CodeMirror 6,它自带虚拟滚动和增量解析能力,社区也验证过能处理较大的文档。第二条是用 ProseMirror,基于文档树来做富文本编辑,功能强大,但它的模型适合结构化协作场景,纯 Markdown 编辑反而显得重。第三条是像 Typora 那样基于 CodeMirror 魔改。
最后我没有直接整体引入这些框架,原因有点现实:编辑器不仅仅是编辑区域,还包含自定义的暗色主题、文档目录树、图谱预览、导出 PDF 等一系列业务逻辑,全部迁移到新框架相当于整体重写产品,两个月的周期风险太高。更合理的路线是保留已有的编辑交互外壳、自定义渲染层,把底层的数据结构和渲染机制逐步换成性能友好的实现。相当于换发动机,而不是换整车。如果你没有历史包袱、从零开始做一个编辑器,那我反而建议直接上手 CodeMirror 6;但如果是重构存量项目,保持外部接口稳定、内部渐进替换,是更务实的做法。
2.2 整体架构:解析、数据、渲染、交互四层拆开
重构后的架构我拆成了四层:
- 数据层:负责文本内容的存储和管理,用 Piece Table 结构替代原来的纯字符串。
- 解析层:负责把 Markdown 源码解析成 tokens,放到 Web Worker 里跑。
- 渲染层:负责把 tokens 渲染成 DOM,只渲染视口内的内容,并用缓存管理块高度。
- 交互层:负责输入、滚动、搜索、目录跳转等用户操作,通过事件与渲染层解耦。
这四层之间用清晰的数据接口通信。数据层是唯一的数据源,解析层只处理数据层的快照,渲染层只消费解析层的 tokens,交互层不直接碰 DOM 节点,而是通过渲染层暴露的 API 来操作。这样拆分后,每一层都可以独立优化和测试,不会出现“改一行渲染代码导致输入卡顿”这种互相拖累的情况。
这个分层思路在重构前就在设计文档里定死了。我最大的教训是:性能优化最怕“东打一枪西打一枪”,今天调个正则,明天换个缓存策略,看似都有收益,但整体结构不变,很快又会撞到下一个瓶颈。先把边界画好,后面每一笔投入都落在该落的位置上。
3. 核心实现细节:那些真正提升速度的代码与设计
3.1 文本模型:用 Piece Table 代替一个大字符串
很多编辑器性能差,第一步就死在字符串操作上。JavaScript 字符串虽然底层做了了很多优化,但大字符串的切片、拼接、插入仍然有 O(n) 级别的成本。2MB 的字符串,每次插入一个字符,如果直接str.slice(0, pos) + 'x' + str.slice(pos),就是在完整复制一份两百万字符的字符串,再组合成新的字符串,代价极高。
我改用了一种叫 Piece Table 的数据结构,这也是 VS Code 文本编辑器的核心数据结构之一。它的核心思想非常朴素:不直接存储编辑后的完整文本,而是维护一个原始文本缓冲区、一个追加缓冲区,以及一个由“片段(piece)”组成的链表或数组。每个片段记录一段连续文本的起始位置和长度,编辑操作时只需要修改片段列表,而不需要复制大块字符串。
class Piece { constructor(source, start, length) { this.source = source; // 'original' 或 'append' this.start = start; this.length = length; } } class PieceTable { constructor(originalText) { this.original = originalText; this.appendBuffer = ''; this.pieces = [new Piece('original', 0, originalText.length)]; } // 根据全局偏移找到对应的 piece 和局部偏移 locate(offset) { let current = 0; for (let i = 0; i < this.pieces.length; i++) { const p = this.pieces[i]; if (offset <= current + p.length) { return { pieceIndex: i, localOffset: offset - current }; } current += p.length; } return { pieceIndex: this.pieces.length - 1, localOffset: this.pieces[this.pieces.length - 1].length }; } insert(offset, text) { const { pieceIndex, localOffset } = this.locate(offset); const piece = this.pieces[pieceIndex]; const appendStart = this.appendBuffer.length; this.appendBuffer += text; this.pieces.splice(pieceIndex, 1, new Piece(piece.source, piece.start, localOffset), new Piece('append', appendStart, text.length), new Piece(piece.source, piece.start + localOffset, piece.length - localOffset) ); } delete(offset, length) { // 先定位起始和结束位置,再做范围删除 // 核心逻辑:拆开头、拆结尾、删中间 } }上面这段代码是简化版,重点看insert方法:无论插入多长的文本,都只是拼了一个字符串(这个操作不可避免),然后把一个 piece 拆成了三个 piece。没有复制整篇文档。删除操作类似,也是拆 piece 再合并中间区间。这个结构在内存和耗时上几乎做到了最优,配合后面的增量解析,输入延迟大幅降低。
3.2 解析层:Web Worker 加增量解析,双管齐下
换完数据结构之后,下一步是解决“解析一整篇文档太慢”的问题。原先解析 2MB 文档大概需要 800ms 到 1.2s,虽然比渲染 DOM 快,但放在主线程上仍然是不可接受的卡顿。
我的方案是双管齐下:把解析整体搬到 Web Worker,同时实现增量解析。Web Worker 保证了即使单次解析耗时再长,也不会阻塞用户界面;增量解析保证大多数时候不需要重新解析整个文档。
Web Worker 的做法不复杂:主线程把文档快照(从 Piece Table 导出的全文)发给 worker,worker 里跑解析器,把 tokens 序列化后传回主线程。这里有个性能细节需要注意:大字符串通过 postMessage 传给 Worker 时,浏览器会做一次结构化克隆,2MB 的字符串克隆本身也要几十毫秒。为了进一步优化,可以把文本转换成一个 ArrayBuffer 传入并在名字里带上 transferList,这样字符串底层内存会被“转移”而不是“复制”,主线程不再持有这份数据,能省一次拷贝。对于全文首次解析来说,这个收益非常明显。
增量解析的逻辑稍微绕一点。核心思路是:编辑操作发生在某个位置,只影响该位置附近的段落,不需要全部重新解析。我的做法是维护一个“脏段落区间集合”,每次编辑后,把受影响段落标记为脏,然后在 Web Worker 里只重新解析这些段落,再和原有 tokens 做合并。
// 主线程侧:编辑后标记脏区间,并派发增量解析请求 function handleEdit(editRange) { const dirtyRanges = editorModel.markDirty(editRange); editorModel.getDirtyRanges().forEach(dirty => { worker.postMessage({ type: 'parse', text: editorModel.getTextRange(dirty.start, dirty.end), startLine: dirty.startLine, endLine: dirty.endLine }); }); } // Worker 侧:只解析脏区间 self.onmessage = (e) => { const { type, text, startLine } = e.data; if (type === 'parse') { const tokens = parseMarkdown(text); self.postMessage({ type: 'parsed', startLine, tokens }); } };这里要注意一点:增量解析不能把段落之间的上下文完全切断,因为 Markdown 语法有跨段落的语义,比如列表的缩进层级、引用块内嵌的段落、代码块的开闭标签。我的处理办法是,每次解析脏区间时多取前后各一行作为上下文,解析完再把上下文部分丢弃。这样绝大多数情况都能正确解析,偶尔遇到极端情况,就退化为全量重新解析,保证正确性优先。
3.3 渲染层:可视区域渲染加块级高度缓存
解析速度上来了,但还不能直接全量渲染。几万行 Markdown 对应的 tokens 如果全部生成 DOM 节点,一样会卡死。这里必须上虚拟列表(Virtual List)的机制。
虚拟列表的思路是:只把当前视口内能看到的内容渲染成真实 DOM,其余部分用一个占位符撑住高度。实现时我按“块”来划分文档,一个块大致对应 Markdown 语法里的一段(段落、标题、列表项、代码块、表格行等),每个块解析后计算出自己的渲染高度,所有块的高度列表保存在渲染层。
滚动的时候,根据scrollTop算出当前可视区覆盖了哪些块,然后只更新这些块的 DOM。向下滚动时,把离开视口的块从 DOM 中移除并缓存起来,避免 DOM 节点无限增长。这样渲染层的常数级成本只取决于屏幕高度,而不是文档长度。
块高度的计算是这个方案里最容易出问题的环节。很多虚拟列表实现会卡在这个地方:文本有没有被渲染,高度是不知道的,但如果不提前知道高度,滚动条就会跳动或回弹。我的做法是对每个块先做一次“隐藏渲染”,在一个visibility: hidden的容器里渲染该块内容,读取实际高度后缓存。因为每个块通常只有几十行以内,这个测量成本可控。对于固定行高的代码块,我进一步优化为按行数乘单行高度直接估算,不进入隐藏渲染流程。
3.4 搜索与高亮:分块扫描,而不是全文正则
大文档里的另一个隐藏性能杀手是搜索和关键字高亮。旧实现是每次打开搜索框就在全文跑一次正则,遍历所有文本节点做高亮,2MB 文档直接卡到界面失去响应。重构后我改成按块搜索:先构建一个行号索引,搜索时按块依次查找,找到的块打上高亮标记,并只重渲这些块。搜索结果的跳转也只需要计算目标块所在的滚动偏移,不需要全文滚动。
这里有个体验上的取舍:高亮标记不应该破坏旧的搜索结果,所以在每次搜索词改变时,先清除旧高亮,再执行新搜索。清除和搜索都是按块操作,不会出现“清除高亮就卡一下”的问题。
4. 实测数据与踩坑实录
4.1 重构前后的性能对比实测
重构完成后,我做了几组对比测试。测试文档是从真实项目里抽出来的一份 2MB Markdown 文件,包含约 50 万字符、900 多个段落、几十个代码块和多张 base64 内嵌图片。测试机器是一台普通配置的开发机。以下是实测数据:
| 指标 | 重构前 | 重构后 |
|---|---|---|
| 打开文件(到可交互) | 约 40 秒,期间界面冻结 | 约 1 秒左右 |
| 输入一个字符后的响应延迟 | 300-800ms | 10ms 以内 |
| 滚动流畅度 | 有明显的卡顿和拖影 | 稳定 60fps |
| 全文搜索耗时 | 约 8 秒 | 约 1.2 秒 |
| 峰值内存占用 | 约 380MB | 约 120MB |
这个结果基本达到了“2MB 文档约 1 秒打开”的目标。需要说明的是,这里的“打开”我定义为用户能看到内容并可以开始输入,而不是全部渲染完成。全部块高度计算完做的是懒加载,首屏之外的部分在滚动过程中逐步计算,这样给用户的第一体验是“秒开”,后续滚动虽然有极短暂的块渲染过程,但因为块很小,感知不到卡顿。
4.2 避坑经验:我在重构过程中踩过的几个关键坑
第一个坑是 Worker 消息体过大导致的性能回退。一开始我把整个解析结果以 JSON 字符串的形式从 Worker 传回主线程,结果发现 2MB 文档的 tokens 序列化后接近 5MB,加上 JSON.parse 的时间,整体性能不升反降。后来我把 tokens 结构改成了扁平数组的形式,只记录类型、起始位置、长度、嵌套层级这几个字段,序列化后体积压缩到 1MB 左右,再配合 transferable 的 ArrayBuffer 传输,才真正把开销降下来。
第二个坑是虚拟滚动下的锚点跳转问题。目录树点击跳转到某个标题时,如果目标块尚未渲染,直接设置 scrollTop 会发现位置偏差几像素到几十像素。原因是前面若干块的高度缓存还没有全部计算出来。我的解决办法是:建立块高度缓存后,跳转前先强制触发一次“从可视区到目标块”的懒计算,把目标块之前的块高度全部测量完毕,再做滚动定位。虽然多了一点点耗时,但换来的是定位准确。
第三个坑是图片加载带来的重排问题。编辑器里经常有 base64 内嵌图片,图片尺寸未知,加载后高度会变化,导致虚拟列表高度缓存失效,滚动条来回跳。我后面统一做了图片尺寸探测,探测失败就按一个默认长宽比占位,图片真正加载完成后再触发一次高度校正。这个处理让滚动稳定性好了很多。
第四个坑是输入法组合态下的增量化问题。中文输入法在组合期间会连续触发 composition 事件,如果每个组合事件都去做增量解析和虚拟列表更新,会导致输入法候选框跳动。处理办法是:compositionstart 到 compositionend 期间,只更新 Piece Table 的文本内容,不做渲染和解析,等输入结束时再一次性处理。这个细节不处理,中文用户会明显觉得编辑器“不好用”。
4.3 常见问题排查速查表
| 问题现象 | 可能原因 | 排查与解决方法 |
|---|---|---|
| 打开大文件仍然卡顿 | 首屏块数量太多,块高度测量耗时过高 | 检查首屏渲染的块数量限制,优先用估算高度代替精确测量 |
| 滚动时出现白屏或跳动 | 虚拟列表高度的缓存失效 | 检查图片、代码块等动态高度元素,做尺寸探测和修正 |
| Worker 解析结果回来很慢 | postMessage 传输的体积过大 | 使用扁平 tokens 结构 + transferable ArrayBuffer |
| 搜索时界面短暂冻结 | 搜索循环里做了 DOM 操作 | 搜索过程只做匹配,高亮通过渲染层的批量更新接口处理 |
| 中文输入光标跳动 | composition 事件触发频繁渲染 | composition 期间暂停渲染,结束后一次性更新 |
最后再分享一个小技巧
重构结束以后,我自己养成了一个习惯:每次改动编辑器相关代码,都会用那个 2MB 的测试文档跑一遍“打开时间、输入延迟、滚动帧率、内存峰值”四件套,并且把这些数据记录在 CI 的 PR 描述里。性能这东西特别容易被一个不经意的改动打回原形,只有持续盯住基准数据,才能保证“2MB 约 1 秒打开”不是一次性的成果,而是一个长期稳定的能力。
如果你也正在折腾编辑器或者遇到类似的大文件卡顿问题,我的核心建议只有一个:不要先去调渲染函数里的循环,先看数据是怎么流动的。把文本存储、解析、渲染、交互拆开,找到一个不能被全量执行的点,拦住它,性能问题往往就解决了一大半。