
1. 一个 2 MB 文档让整个编辑器原地退休先说结论我花了两个月重构了一个 Markdown 编辑器重构前打开 2 MB 的文档要 8.7 秒界面完全无响应重构后同样一份文件冷启动打开到可编辑、可预览稳定在 1 秒左右。这篇文章不是来晒功能的而是把这两月里怎么定位问题、拆掉重建、又被各种边角问题反复折磨的过程完整记录下来给正在做编辑器、富文本或者任何内容型性能优化的朋友一点参考。故事得从一次用户反馈说起。当时这个 Markdown 编辑器已经上线一年多基础功能都有主要给内容团队写技术文档和接口说明这类长文。有一天运营同事丢给我一个文件说这个文档打不开一直转圈。我看了一眼文件大小2.1 MB纯文本六千多行。当时心里想的是这么小的文件怎么会打不开结果在自己电脑上一跑直接卡了十几秒编辑器勉强能动了但每敲一个字符要等一两秒才响应。这件事让我意识到问题根本不在文件大小而在编辑器本身的架构。小文档撑着没事一到大文档所有性能债一次性暴露。我给自己定了一个两个月周期目标只有一个2 MB 文档打开时间进 1 秒编辑过程不掉帧。下面的内容就是这次重构从解剖到重建的全过程。2. 给编辑器做体检先把慢的原因钉死2.1 全链路打点别靠感觉猜瓶颈我做的第一件事不是改代码而是在现有代码里埋计时点。从文件读入磁盘、读取内容、初始化编辑器实例、挂载 DOM、执行 Markdown 解析、渲染预览区这六个环节全部打上 performance mark再用浏览器 Performance 面板记录主线程每段任务的耗时。实测下来一次 2 MB 文档的完整打开流程总共耗时 8.7 秒。其中 Markdown 解析占 5.2 秒预览区重新渲染占 2.6 秒编辑器初始化和 DOM 挂载只占 0.9 秒。结论一下就清晰了瓶颈不在编辑器壳子而在解析和渲染这两条底层的核心链路。旧架构里这两件事是在同一条主线程上同步执行的。用户输入一段文字主线程要先跑一次全文 Markdown 解析再比较前后两次解析结果找到变化的部分然后把整个预览区重新渲染一遍。文档小的时候这个流程问题不大因为每次解析只需要处理几 KB 字符串一旦文档膨胀到 2 MB解析器就要对六千多行文本做全量处理而渲染层又把整个 HTML 全部重画一遍两个 O(n) 叠加主线程直接被焊死。2.2 正则解析器在大文件面前的指数级灾难进一步剖析为什么解析会这么慢。旧解析器的实现方式非常典型用一长串正则表达式按标题、加粗、行内代码、链接、列表等规则逐行匹配每一行都要跑多个正则每个正则还要处理回溯。Markdown 语法本身是上下文相关的一个列表项内部的代码块、一个引用块里嵌套另一个引用块用纯正则描述非常吃力。一旦规则之间出现重叠就会发生灾难性回溯。我当时用 console.time 粗略统计过第 1000 行到第 2000 行的解析耗时是第 1 行到第 1000 行的 2.3 倍。这种非线性增长就是回溯带来的直接后果。更麻烦的是每次击键都会触发一次全文解析。用户输入时主线程既要处理输入法事件、更新编辑区内容又要同步跑一次 Markdown 解析还要更新预览区 DOM。三者叠加任何一项卡顿都会被用户直接感知为编辑器卡了。这里顺带提一句如果你也维护类似的编辑器遇到大文件卡顿不要先去优化框架本身先确认解析和渲染是不是被重复执行了。很多项目里的性能问题不是某个算法不够快而是同样的活儿被反复干了太多遍。2.3 把目标拆成可验收的数字定位瓶颈后我把2 MB 文档 1 秒打开拆成了三个可量化的子目标解析阶段控制在 300 ms 以内渲染阶段控制在 500 ms 以内编辑器初始化加 DOM 挂载控制在 200 ms 以内。需要注意这里指的是从文件内容到达编辑器到屏幕出现可编辑、可预览界面的完整时长。后面的所有重构动作都围绕这三个数字来验收每完成一个阶段就在同样的机器上重新测一遍。这样既不会被过程中新增的其他亮点带偏也能在出现性能回退时第一时间发现。3. 第一场硬仗把解析器从正则流重写为单遍 AST3.1 为什么必须重写而不是继续给正则打补丁刚开始我也犹豫过旧解析器有上千行正则在上面直接改比推倒重来省事。但分析下来发现正则在 Markdown 这种上下文相关语法面前结构性缺陷很难靠补丁解决你要兼容列表嵌套就要增加正则回溯层级要处理代码块和 HTML 块不解析 Markdown就要在匹配之前做大量前置判断。每加一条规则其他规则的匹配路径都会被拖慢。与其继续堆补丁不如按状态机的思路写一个真正的解析器逐字符扫描遇到不同语法特征就切换状态每个字符只处理一次整体复杂度 O(n)。这个方案风险确实高因为解析器是编辑器的地基一旦行为跟旧版不一致用户之前写的所有文档都有可能被渲染歪。所以我在立项时留了三条原则第一优先兼容 CommonMark 的核心语法不追求冷门特性第二保留一份旧解析器的渲染结果作为对照跑批量差异测试第三解析器内部产出中间层 AST不直接输出 HTML这样后续渲染策略怎么改解析层都不受影响。3.2 解析器内部结构Token 流到 AST新解析器用 TypeScript 实现核心分两层。第一层是词法扫描按行读入文本根据当前状态普通段落、列表、引用、代码块、HTML 块等切出 Token每个 Token 带类型、起始偏移、结束偏移和所属的块级上下文。第二层是块级组装Token 流进来后按块级语法规则组织成 AST 节点比如## 标题生成 heading 节点- 列表项生成 list_item 节点行内则进一步拆出 strong、em、code、link 等子节点。这套设计的核心优势在于行内的加粗、斜体、行内代码这些细节不需要依赖大量正则而是在词法状态机里用当前是不是处于强调标记内这样的布尔状态来跟踪。举个例子解析加粗时词法扫描遇到连续两个星号就把状态切到 in_bold后面所有字符先收进 bold buffer直到再遇到两个星号再切回来。整个过程中没有回溯每个字符最多被扫描一到两次。实测下来2 MB 文档的单遍解析耗时大概在 240 ms 到 290 ms 之间比旧正则方案快了一个数量级以上。这里还做了个关键决定AST 节点只保留纯数据结构不保存任何 DOM 引用。这意味着 AST 可以跨线程传递可以序列化缓存也可以做结构化克隆。实践证明这个纯数据约定给后续的 Worker 化改造省了非常多的事。3.3 增量解析与 AST 缓存不让每次击键都全量重算单遍解析已经很快了但如果每次按键都全量解析2 MB 文档依然会有明显延迟因为输入过程中解析任务会被高频触发。我的做法是引入块级增量解析把文档按空行分隔成若干个 block每个 block 维护一个自增版本号。编辑时只把变更行所属的 block 标记为 dirty解析器只处理这些 dirty block未变化的 block 直接从 AST 缓存里读取。缓存的结构也很简单一个以 block id 为 key 的 Mapvalue 是{ version, ast, renderedHtml }。每次编辑事件进来先确定变更行落在哪个 block重新解析这个 block再更新它对应的 AST 和 HTML 片段。因为 Markdown 的块级语法天然以空行为边界这种方案对绝大多数文档都是准确的。唯一需要处理的是内容跨块的情况比如当前 block 末尾的列表延续到下一个 block我会在解析完后检查前后两个 block 的语法上下文是否连续不连续就向上合并一个块直到上下文稳定。另一个容易被忽略的点是增量解析之后光标的偏移位置和 AST 节点之间的关系会变化。我维护了一个行号到 block id的索引编辑区每次滚动时通过这个索引快速定位当前视口内的 block再在 block 内部根据字符偏移找到具体 AST 节点。这样即使局部 re-render也不会因为找不到对应节点而被迫回退到全量解析。3.4 把解析塞进 Web Worker主线程腾出来解析链路优化完成后2 MB 文档的解析耗时从 5.2 秒降到了 270 ms 左右但有个问题仍然存在解析是同步的执行期间主线程依然会卡住。对于打开文件这种一次性操作卡 270 ms 可能还能接受但如果做的是实时预览每次输入都要卡一下体验依旧很糟。所以我在解析层之上加了一个 Web Worker。主线程拿到文本后把字符串通过 postMessage 丢给 WorkerWorker 内部完成词法扫描、块级组装、AST 生成再把序列化后的 AST 传回主线程。传回的 AST 不直接用于渲染而是先放入缓存渲染层按需取用。这样主线程在整个解析期间可以继续处理用户输入、滚动、快捷键等交互不再被解析任务阻塞。这里踩过一个重要的坑postMessage 的结构化克隆并不是免费的。2 MB 文档生成的 AST 如果对象拆得特别碎序列化和克隆本身可能要花 60~100 ms。解决办法是控制 AST 节点的粒度同一行的多个行内节点合并成紧凑数组不用每个节点都包一层对象同时把整棵 AST 的引用关系转成整数索引显著减小克隆体积。如果对性能有更高要求还可以用 SharedArrayBuffer 做共享内存但那会引入跨线程并发写的问题我自己实测下来收益不高、复杂度上升明显非必要不建议用。4. 第二场硬仗渲染层从整页重建走向管好你眼前的那几行4.1 虚拟滚动6000 行的预览区不能全量渲染解析器重写完成后最突出的瓶颈变成了渲染。旧渲染逻辑是预览区拿到完整 HTML 字符串一次性设置 innerHTML再由浏览器完成全部排版和绘制。6000 行内容的 HTML 字符串可能有几 MB浏览器解析这个 HTML、构建所有节点、执行样式计算、完成布局绘制整个过程可能要跑 2 秒以上而且期间页面完全动不了。重构后我换成了虚拟滚动方案。无论文档有多少行预览区始终只渲染视口内可见的那部分行以及视口上下各一段预渲染缓冲通常是 300 px其余行用占位符撑住滚动条的高度。具体实现上维护一个visibleBlocks数组滚动时根据当前 scrollTop 和缓存的 block 高度算出哪些 block 应该在视口内然后只对这部分 block 执行渲染。滚动条的总高度则由所有 block 高度之和来模拟保证滚动条能真实反映全文长度。虚拟滚动的改造让预览区的 DOM 节点数从几千个降到了几十个首次渲染耗时从 2.6 秒降到 500 ms 以内。但虚拟滚动也引入了新的麻烦滚动过程中如果 block 高度计算错了会出现内容跳动、位置偏移、白屏闪烁。这个问题我在下一个小节细说。4.2 行高缓存与滚动定位让滚动条不再发抖虚拟滚动的第一个难点是高度估算。Markdown 渲染出来的内容高度不是固定的一行纯文本可能只有 20 px一个代码块可能有 400 px一张大图可能直接撑出 800 px。如果渲染前不知道每个 block 的真实高度滚动条总高度就只能靠猜用户滚到一半就会发现内容位置和滚动条位置对不上。我的方案是分两阶段先用估算高度渲染占位块根据字符数和行数估算普通文本按每行 20 px标题按语义加权重代码块按代码行数乘行高等到 block 真正进入视口并完成实际渲染后再用 ResizeObserver 或者直接测量 offsetHeight 得到真实高度写回高度缓存。缓存中保存的是{ heightMap: MapblockId, number, offsetCache: MapblockId, number }offset 在滚动前根据 heightMap 累加计算得出。刚开始实现时我忽略了 offset 缓存的更新导致滚动到中段时 block 位置全部便宜出现跳动现象。排查链路是这样的用户反馈往下滚几屏之后内容突然跳到别的段落。我在滚动事件里加日志发现 scrollTop 是连续变化的但视口内的 block 列表在某个位置突然从一个区块跳到很远的另一个区块。原因就是高度缓存没有随 block 高度校正而更新offset 累积误差越来越大。修复方法是每次 heightMap 更新后只对受影响 block 之后的 offsetCache 做一次重算而不是全量重算。因为全量重算在 6000 行的文档上也要 200 ms 以上得不偿失。4.3 用空闲时间分片渲染把主线程还给用户即使只渲染视口内的 block一个复杂 block比如超长代码块的渲染也可能占用 50~100 ms如果一次性处理多个还会造成明显的掉帧。我把渲染可见区块这个任务拆成了小片每片只处理 2~3 个 block放在 requestIdleCallback 里执行。浏览器一旦有空闲时间就处理一片如果下一帧即将开始或者有用户输入就暂停把主线程让出去。这样做最大的收益是编辑器在滚动和输入过程中主线程始终有响应余量用户不会觉得很卡只会觉得内容在快速跟上我的操作。分片渲染的核心参数是每片任务的预算我一开始设的 16 ms结果遇到复杂代码块还是会有超过 40 ms 的长任务。后来改成每个 block 内部再按子节点拆片一个 200 行的代码块会被拆成 20 片每片只高亮 10 行长任务就彻底消失了。代价是代码高亮这类任务不能一次性拿到完整信息但我做了缓存同一个 block 第二次渲染时不需要重新高亮直接复用上一次的分片结果。4.4 图片懒加载与代码高亮按需加载渲染层还有一个隐蔽的性能炸弹图片和代码高亮。旧实现里只要预览区渲染出来所有 img 标签都会开始请求图片资源。一个包含 100 张截图的文档打开瞬间会吐出 100 个并发请求不仅图片加载慢还会挤占文档本身的资源加载带宽。重构后我给预览区的图片统一加了loadinglazy同时用 IntersectionObserver 监听图片是否进入视口进入后再把真实的 src 写到 img 标签上。这样屏幕外的图片完全不触发网络请求用户滚动到对应位置时才加载。代码高亮是另一类问题常见做法是把 highlight.js 的所有语言包全量引入光 JS 就几百 KB解析高亮时还要遍历全部语言规则。一些大文档里代码块的占比很高全量高亮会非常耗时。我改成了按需动态引入检测到代码块时先只渲染纯文本等到该代码块进入视口并检测到具体语言再通过import()动态加载对应的语言包。如果语言包已经在缓存里就直接复用。实测下来包含大量代码块的 2 MB 文档打开时高亮这部分的时间从 900 ms 降到了 120 ms 以内内存占用也低了不少。5. 编辑态的一地鸡毛光标、撤销栈、输入法5.1 虚拟滚动导致的光标瞬移排查全链路预览区虚拟滚动顺利落地后我松了口气但很快用户反馈又来了在长文档里编辑时按方向键向下移动光标滚到屏幕边缘位置光标会莫名其妙跳到第一行。这是一个非常典型的改了渲染层、忘了编辑层的连锁问题。排查链路大概是这样先在自己的账号里复现发现不是每次都发生但连续按方向键向下滚动 3~5 屏后必现。打开控制台看 scroll 事件和虚拟列表的渲染日志发现当滚动容器触发 scroll 事件时虚拟列表会立刻重建视口内的编辑行 DOM。重建之后光标原本对应的 DOM 节点已经被卸载了浏览器只能把 focus 状态恢复到文本起始位置。这就解释了为什么光标总是跳到第一行——不是逻辑算错了而是 DOM 节点的生命周期和虚拟滚动的事件节奏没对齐。修复思路分两层。第一层在虚拟列表重建之前把所有当前编辑 session 的状态当前行号、光标在行内的字符偏移单独保存下来等新的视口行渲染完成后再恢复光标位置。第二层把重建 DOM和恢复光标放进同一个微任务队列避免因为滚动事件和渲染事件交错而丢失状态。恢复光标的代码逻辑上有点像这样// 保存当前光标坐标到逻辑位置 const saveCursor () { const range selection.getRangeAt(0); const pre range.startContainer; const lineBlock pre.closest([data-line-id]) as HTMLElement; return { lineId: lineBlock.dataset.lineId, offset: range.startOffset }; }; // 虚拟列表重新渲染后根据逻辑位置恢复光标 const restoreCursor (pos: { lineId: string; offset: number }) { const line container.querySelector([data-line-id${pos.lineId}])?.firstChild; if (line) { const range document.createRange(); range.setStart(line, Math.min(pos.offset, line.textContent?.length || 0)); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); } };这套方案在大多数情况下生效但还有一个特殊情况如果用户连续快速滚动光标所在行可能还没进入视口此时无法恢复。解决办法是如果目标行不在当前虚拟列表内就先滚动到目标行等它渲染完成后再恢复光标。这里要特别提醒一点不用把逻辑坐标做得太复杂数据驱动才是关键因为每次滚动都重建 DOM 之后所有 DOM 引用都是不可靠的。5.2 撤销栈吃内存从 GB 级降到 200 MB 以内重构完成后2 MB 文档基本能秒开但编辑一段时间后内存又开始一路上涨。用 Chrome 的 Memory 面板抓了一圈发现罪魁祸首是撤销栈。旧实现里每次编辑都会把整个文档内容作为快照 push 进撤销栈。2 MB 文档每敲一个字就保存一个接近 2 MB 的字符串副本用户连续编辑 200 次内存就涨到 400 MB 以上如果中间还涉及大块代码块的替换内存会直接冲到 GB 级。解决方案是把全文快照改成操作日志模式撤销栈里只记录在哪个位置删除了什么插入了什么而不保存每次的全量状态。撤销时先回退一段操作日志把文档恢复到上一个状态。文档越大这种方案的节省效果越明显2 MB 文档下内存峰值从 1.2 GB 降到了 180 MB 左右。当然操作日志模式有个坑如果某次操作涉及超大块内容比如一次性替换了 2000 行日志里保存的内容也会很大。我的处理是给操作日志设置阈值超过阈值的操作保存引用快照而不是内容副本即新快照通过生成一个轻量的文档版本指针指向全文缓存。这样极端情况下不会造成内存爆炸只是撤销逻辑会稍微复杂一点。5.3 中文输入法组合态一个容易被忽略的 bug编辑 Markdown 的人很多是在写中文技术文档。中文输入法的 bug 在测试英文环境时几乎不会暴露但实际用户一用就炸。现象是输入拼音的过程中只要预览区触发了重渲染正在编辑的那段拼音就会被清空或者复选文字被强制打断导致用户根本没法连续打一串汉字。原因在于输入法组合状态composition期间textarea 或者 contenteditable 内的 DOM 结构非常脆弱任何外部 DOM 操作都可能让浏览器终止组合。我在重构渲染层后对编辑区的 HTML 操作变频繁就撞上了这个问题。排查时我先加了compositionstart和compositionend事件监听确认组合期间确实有预览区重渲染发生再针对组合态做了处理在 composition 期间暂停增量解析任务和虚拟列表重建。组合结束后再一次性处理。这个处理思路很简单但实现时要小心不能漏掉边界情况。比如 composition 结束后内容可能由输入法直接插入插入的位置在 AST 里对应哪个 block 需要重新计算。我当时的实现是在 compositionstart 时记录当前的 block id 和文档版本号compositionend 后重新解析该 block并更新相关的增量索引。如果用户在组合期间滚动到很远的其他 block则等滚动稳定之后再做刷新。6. 验收时刻2 MB 文档 1 秒打开的数据与复盘6.1 实测数据对比两个月的重构结束后我在同一台开发机上跑了完整的对比测试。测试条件如下MacBook ProApple Silicon、Chrome 最新稳定版、同一个 2.1 MB 纯文本 Markdown 文档文件内容包含大量标题、列表、代码块、表格和若干网络图片链接。为了保证公平重构前和重构后都采用冷启动方式即每次打开前都清理页面缓存重新加载编辑器页面。指标重构前重构后打开总耗时可编辑、可预览8.7 秒1.02 秒Markdown 解析耗时5.2 秒275 ms预览区首次渲染耗时2.6 秒480 ms连续输入时的响应延迟1200 ms/次32 ms/次打开后内存峰值1.2 GB180 MB滚动时的帧率8~15 fps50~60 fps可以看到三个子目标全部达标解析 275 ms、渲染 480 ms、初始化约 265 ms。唯一没有完全达到预期的是初始化加 DOM 挂载这一项因为它包含编辑器壳子加载、插件注册、缓存重建等杂项要在 200 ms 以内需要做到更激进的资源预加载但从用户体感来看1 秒打开已经完全可用了。6.2 过程中最值得记住的三个教训第一性能优化不要靠猜先埋点打时间。很多次我以为瓶颈在 A结果测下来是 B。没有全链路打点之前我甚至一度想换编辑器内核框架后来测量发现壳子本身只占 0.9 秒换框架解决不了 5 秒的解析和 2.6 秒的渲染问题。第二做架构级重构一定要把兼容性放在性能前面。如果用户看不了旧文档哪怕秒开也没有意义。这次重构里我保留了旧解析器的渲染快照写了批量 diff 脚本对所有内置案例跑了一遍才敢上线。即便这样还是漏了一些冷门语法的差异最后靠线上反馈补了几个兼容补丁。这个经验让我确信解析器的输出一致性比解析速度更难做。第三别为了顺手优化引入不必要的复杂度。比如 SharedArrayBuffer 和跨线程共享内存我调研了两周测试后发现收益有限还带来一堆 bug最后放弃。虚拟滚动 增量解析 分片渲染这套组合已经足够解决问题把精力集中在真正影响用户体验的细节上反而更容易出效果。6.3 后续还可以继续扩展的方向这次重构做完后我自己在持续使用的过程中又整理出几个可以继续深挖的方向一是把 AST 缓存持久化到 IndexedDB这样用户重新打开同一份文档时可以直接加载缓存不用重新解析二是把代码高亮的分片结果也做成缓存减少滚回到同一位置的重复计算三是把编辑区本身也做更细粒度的增量渲染目前编辑区在超大行比如一个超长代码块处的渲染仍有一定压力需要进一步优化。如果你也在做类似的内容型编辑器我的建议是先把打开链路打点做出来再用数据驱动重构最后用测试验证每一项改动。过程很辛苦但看到同事重新打开那个 2 MB 文档时秒开的表情这两个月值了。