
如果你维护过任何一个 Markdown 编辑器应该对下面这种场景不陌生用户丢进来一个 2 MB 的 Markdown 文档编辑器直接进入“假死”状态转圈十几秒才打开打开之后滚动还不流畅甚至敲一个字都要等半天。前阵子我就被这种反馈轰炸了一轮最后实在忍不了花两个月时间把编辑器从架构层面重构了一遍最终做到了 2 MB 文档约 1 秒打开滚动稳定在 60 帧输入延迟基本无感。这篇文章就是这次重构的完整记录从问题诊断、架构设计、关键代码到踩过的坑都会写清楚给正在做同类产品或准备做 Markdown 编辑器的朋友一个可落地的参考。1. 重构前的问题诊断2 MB 文档为什么会卡成幻灯片1.1 打开慢的根源不在 Markdown 解析而在渲染链路很多人一说到“Markdown 编辑器打开大文件慢”第一反应是“解析慢”。这个判断只对了一半。Markdown 解析本身其实没那么慢像 marked、markdown-it 这类库单文件解析速度都在每秒几 MB 的级别2 MB 文本虽然不算小但纯解析也就几百毫秒的事。真正要命的是解析完之后那套渲染链路。编辑器和编译器有个本质区别编译器追求的是“一次编译、极致优化”它可以容忍几秒甚至更长的编译时间但编辑器追求的是“持续输入下的低延迟响应”用户每敲一个字符都希望立刻看到反馈。编译器可以把整个文件当作一个整体来处理编辑器不行尤其是大文档场景下任何“全量”操作都会变成灾难。当时我的编辑器走的是一条非常经典的“教科书式流水线”读取文件 → 整篇解析 → 生成整棵 AST → 整篇渲染成 HTML 字符串 → 一次性插入 DOM → 浏览器触发重排和重绘。单看每一步都合理合起来放在 2 MB 文档上就全崩了。为什么因为整篇 HTML 字符串拼接本身是 O(n) 的内存操作字符串越长内存分配越频繁一次性插入几万个 DOM 节点浏览器要重新计算所有节点的布局信息主线程直接被锁死。这里有个简单的账可以算2 MB 的 UTF-8 文档按中英文混排估大概有 70 万到 100 万个字符。按平均每行 80 个字符算就是 1 万到 1.5 万行。每行渲染成段落或列表项至少要生成 2 到 3 个 DOM 节点那一篇文档全量渲染下来就是 3 万到 5 万个 DOM 节点。千万不要小看这个数字浏览器对几万个节点的首次布局和样式计算不是线性的文档越复杂排版引擎需要做的计算量越大尤其是表格、嵌套列表、代码块这类结构开销会成倍增加。1.2 用 DevTools 性能面板抓到的三个性能黑洞当时我在 Chrome DevTools 的 Performance 面板里录了一段打开 2 MB 文档的完整过程把问题定位到了三个具体的“黑洞”上。这也是我后来重构方案的直接依据。第一个黑洞是整篇 HTML 字符串拼接。我的老代码是先把所有 markdown 解析成 HTML 片段然后innerHTML一次性塞进容器。Chrome 的 Performance 面板里这个阶段出现了一个非常长的 Task耗时接近 4 秒而且内存曲线急剧上升。原因很简单反复做字符串拼接时JS 引擎需要不断分配新内存、复制旧字符串70 万字符的文档会产生大量中间字符串GC 也跟着频繁触发。这个阶段本质上是“内存带宽瓶颈”不是 CPU 计算能力的问题。第二个黑洞是同步解析阻塞主线程。因为解析器跑在主线程上解析期间整个页面完全无响应滚动、点击、输入全部卡死。虽然解析本身只花了大概 700 毫秒但这 700 毫秒是阻塞的用户体感上就是“死了几秒”。长任务把主线程的事件循环堵住了任何用户交互都得排队等它跑完。第三个黑洞是全量 DOM 插入导致的强制同步布局。当 innerHTML 把几万个节点一次性挂上去后浏览器为了确定页面上每个元素的位置必须做一次完整的 layout。如果用户此时恰好还在滚动或者页面里有需要动态计算高度的元素还会触发 layout thrashing——重复读取和写入 DOM 属性导致布局被反复计算。Performance 面板里表现为一长串紫色的 Layout 事件耗时比解析还长。这三个问题叠加在一起2 MB 文档的实测打开时间是 11 秒到 13 秒滚动帧率掉到 15 帧左右内存峰值接近 800 MB。不看数据还不知道一看数据我自己都沉默了。这哪里是编辑器这是幻灯片播放器。2. 架构重构的基本思路从全量渲染到分层增量2.1 设计一个不卡主线程的四层架构诊断做完重构方向其实就清晰了把“一次性全量处理”改成“持续增量处理”让主线程永远不干重活。我花了大概一周时间重新梳理架构最后定了一个四层结构每层职责单一层与层之间通过消息通信。解析层负责把 Markdown 源码解析成 tokens。只干活不渲染跑在 Web Worker 里。调度层负责控制解析和渲染的节奏。核心是分帧把大任务拆成小块每帧只处理一小部分给浏览器留出响应交互的时间。渲染层负责把 tokens 变成 DOM。采用虚拟滚动 增量更新只渲染可视区附近的行。交互层负责光标、选区、滚动、输入等用户操作。所有交互都优先响应渲染任务往后排。这个分层最核心的转变是把“渲染”从一个动作变成“一组动作”。过去是一口气把整篇文章画完现在是把文章切成几百个片段每一帧只画其中几个片段画完立刻让出主线程。用户感觉不到这个过程因为每帧只占 10 到 20 毫秒浏览器能在空隙里及时处理滚动和输入事件。这套分层设计还带来一个额外好处每一层都可以单独测试。解析层不依赖 DOM可以在 Node 环境里跑渲染层不关心 Markdown 语法只负责把 token 变成节点。后面调试问题的时候这种解耦帮了大忙我可以逐步定位到底是解析错了还是渲染错了。2.2 为什么把解析器丢进 Web Worker在四层架构里解析层放 Web Worker 是最没有争议的决定。Markdown 解析本质上是 CPU 密集型任务放在主线程必然抢占渲染和交互的时间。尤其是 2 MB 这种量级的文档解析要跑几百毫秒放在主线程上用户就能明显感觉到掉帧。但 Web Worker 不是丢进去就能用有几个关键细节必须处理干净。第一是数据传递方式。如果直接把大字符串 postMessage 给 Worker浏览器会做一次结构化克隆2 MB 的文本倒是还好但如果是几十 MB 的文件克隆本身就要几十毫秒。解决方案是把文本转成 ArrayBuffer 再传ArrayBuffer 走的是 Transferable 通道转移之后原线程不再持有数据不会发生复制。第二是解析结果的回传。tokens 是嵌套结构直接 postMessage 同样有克隆开销。我的做法是把 tokens 扁平化用数组加索引的方式传递Worker 端只返回必要的字段渲染层按需取用。这个优化看起来不起眼但在大文档下面少了几百毫秒的克隆时间体感差异非常大。第三是 Worker 的生命周期管理。编辑器在单页应用里经常被反复创建和销毁Worker 也必须跟着创建和销毁。如果复用同一个 Worker任务队列一旦堆积反而会造成卡顿。我的做法是每次打开文档就 new 一个 Worker文档关闭就 terminate简单粗暴但不会出状态残留的问题。2.3 虚拟滚动在编辑器场景里的取舍虚拟滚动是解决几万个 DOM 节点的最直接手段。普通列表的虚拟滚动很简单固定行高算一下可视区范围只渲染范围内的元素就行。编辑器场景复杂一些因为 Markdown 渲染出来的每一行高度不固定代码块、表格、图片都能把行撑得很高。而且编辑器对虚拟滚动还有三额外要求光标必须准、选区必须准、滚动条比例必须准。如果光标定位错了用户根本没法输入滚动条比例错了用户拖滚动条的体验会很奇怪。所以我在实现虚拟滚动时没有用现成的虚拟列表库而是自己写了一套“三区渲染”策略可视区当前视口内能看到的行必须完整渲染。上缓冲区和下缓冲区可视区上方和下方各多渲染一部分行防止快速滚动时出现白屏。隐藏占位区可视区和缓冲区之外的区域不渲染 DOM但为了保证滚动条高度正确通过一个撑高容器来占位。撑高容器的原理很简单把所有行的高度累加起来作为容器的总高度子元素用绝对定位放到对应的 Y 坐标上。这样滚动条大小和位置都取决于容器高度而实际渲染的 DOM 节点只有几十个。实测下来全量渲染时候的 3 到 5 万个 DOM 节点在虚拟滚动之后只剩下 80 到 120 个节点。DOM 节点数量直接降了 99% 以上首次布局和样式计算的时间降到可以忽略不计这就是打开速度和滚动流畅度提升的最主要原因。3. 实操落地的关键代码与调优参数3.1 分帧解析让 2 MB 文档不再一次性算完解析层放进 Worker 之后主线程不会被阻塞了但 Worker 内部如果还是“一口气解析完”UI 依然要等待很久才能看到第一屏内容。所以我把解析也做了分帧处理按行分块每帧只解析一定数量的行解析完一块就通知渲染层画一块。核心代码大概长这样// worker.js let currentIndex 0; const lines []; // 从主线程接收到的全部行 self.onmessage (event) { const { type, payload } event.data; if (type init) { lines.length 0; lines.push(...payload.lines); currentIndex 0; processNextChunk(); } }; const CHUNK_SIZE 200; // 每帧解析 200 行 function processNextChunk() { const end Math.min(currentIndex CHUNK_SIZE, lines.length); const tokens []; for (let i currentIndex; i end; i) { tokens.push(parseLine(lines[i], i)); } self.postMessage({ type: chunk, tokens, start: currentIndex, end }); currentIndex end; if (currentIndex lines.length) { setTimeout(processNextChunk, 0); // 让出 Worker 事件循环 } else { self.postMessage({ type: done }); } }这里有两个细节值得注意。一是 CHUNK_SIZE 的选取200 行是我在真机上调出来的值。太大单帧解析时间偏长首屏依然慢太小postMessage 的频率太高线程间通信开销变大。200 行在普通 PC 上单帧解析约 8 到 12 毫秒正好卡在 16 毫秒帧预算之内是我的目标区间。二是分块不能“傻分”。Markdown 有代码围栏和表格这类跨行结构如果从中间切开解析出来的 token 就是错的。我最初的实现就在代码块上踩了坑一个开头的代码块如果没找到闭合的会一直吞噬后面的行。后来我给解析器加了一个状态机追踪遇到代码块的开始标记时会直接把整个块标记为“跨块结构”等收集到下一条闭合标记再完整解析。虽然多了一点状态管理但换来了解析结果的准确性这笔账是划算的。解析结果传到渲染层之后还要按顺序拼起来。这里也要注意不能让用户看到“文章上半部分还没画完下半部分已经出来了”的撕裂感。我的做法是在渲染层维护一个 loadedTokens 数组每次收到新的 token 块就在原数组基础上追加并触发对应行的增量渲染。因为主线程不再一次性处理几万行而是每帧只处理一两百行的新增 DOM所以整个渲染过程是平滑的用户很难察觉到“内容正在逐步出现”。3.2 基于脏标记的增量渲染分帧解析解决的是“打开变快”的问题但用户在实际使用中还可能遇到编辑时卡顿。比如在文档中间插入一段文字如果渲染层收到变更就重渲整篇文章那和之前的全量渲染没有本质区别。这里我用的方案是“脏标记 按行动态更新”。核心思路是把文档按逻辑行维护一个渲染状态表每一行有一个版本号。任何编辑操作只影响一个或少数几行渲染层只重绘状态表中标记为“脏”的行其他行不动。// renderer.js let rowStates []; // { version: number, dom: HTMLElement|null } function markDirty(startLine, endLine) { for (let i startLine; i endLine; i) { rowStates[i].version; } scheduleRender(startLine, endLine); } function scheduleRender(startLine, endLine) { requestAnimationFrame(() { for (let i startLine; i endLine; i) { const row rowStates[i]; if (row.dom) { const newDom renderRow(i); row.dom.replaceWith(newDom); row.dom newDom; } } }); }这段代码看起来简单但里面有几个小坑。一是行号会随着插入删除而变化不是固定不变的。所以我维护了一个“行索引映射表”编辑操作发生时先更新映射表再根据新旧行号算出脏区间。这个映射表本质上是一个有序数组支持高效的插入删除我用的是分段数组避免大数组 splice 的性能问题。二是渲染行和编辑光标的位置关系。如果用户的光标正好落在脏行里重渲染会重建 DOM可能导致光标被重置。我处理的方式是在渲染前记录光标所在行和列渲染完成后手动把光标恢复到原来的位置。三是滚动位置的保持。如果是文档中间插入内容后面的行都会发生位移但用户希望滚动条保持在原位附近。我的处理是记录滚动容器顶部对应的第一行行号渲染完成后重新定位到这个行让视觉上尽量稳定。这套脏标记方案上线之后输入卡顿的问题基本消失。因为用户每次输入最多影响一两行重渲染开销可以忽略不计甚至 2 MB 文档里在中间打字也感觉不到延迟。3.3 优化前后的数据对比重构完成后我特意用同一个 2 MB 的测试文档在同样的设备上做了前后对比。这里没有用压测工具全部是 DevTools Performance 面板录出来的真实数据指标重构前重构后打开时间11.6 秒0.9 秒首屏可见时间9.8 秒0.3 秒滚动帧率15 FPS60 FPS输入延迟500 ms 以上小于 20 ms内存峰值780 MB185 MBDOM 节点数约 4.2 万约 100最让我意外的是内存峰值能从 780 MB 降到 185 MB。原因很直接全量字符串拼接产生的中间字符串以及几万个 DOM 节点本身占用的内存在虚拟滚动和分帧解析之后都被干掉了内存占用当然大幅下降。这也验证了一个经验——很多“内存泄漏”其实不是泄漏而是全量操作导致的瞬时内存峰值。打开时间从 11.6 秒到 0.9 秒DOM 节点数从 4.2 万到 100两者虽然不是严格的因果关系但方向是一致的减少主线程重活 减少浏览器排版压力大文档就不再是编辑器的“安乐死”场景。4. 常见问题与排查技巧实录4.1 中文/英文混排时的行高跳动Markdown 编辑器在东亚语言环境里有一个很隐蔽的性能和体感双问题中英文混排时的行高跳动。中文的基线对齐方式和英文不一样如果一行里有中文、英文、数字混排浏览器计算行高时会出现 1 到 2 像素的误差。单独看一两行没感觉但如果文档很长行高偏差累积起来会导致滚动的虚拟定位发生偏移用户拖滚动条时明显感觉“对不上”。这个问题排查了很久最后落到两个点一是不能依赖字体默认的 line-height必须显式设置一个统一的基准值比如 1.6 或 1.7二是给包含 CJK 字符的行加line-break: strict让浏览器按统一的规则换行避免不同段落的换行位置和行数不一样。还有一个 Markdown 特有的换行坑Markdown 语法里纯文本换行如果不加两个空格或空行渲染出来后是合并成同一段的。这和大多数编辑器的直观行为不一样很多用户会疑惑“为什么我回车了没有换行”。我在重构时特意在状态栏加了一个行尾标记提示编辑模式下用浅灰色显示“空格空格”的可视符号提醒用户这里需要换行符。这个小改动虽然不直接提升性能但能减少大量关于换行的工单。4.2 大段粘贴造成卡顿的排查分帧解析上线后打开大文件已经很快了但我接到一个新的反馈用户从网页里复制一大段带格式的文本粘贴到编辑器里会卡住几秒钟。一开始我以为是渲染问题后来发现是粘贴事件的处理没有走分帧逻辑。问题出在粘贴事件里我拿到剪贴板文本后会先做一次同步的 Markdown 解析然后直接生成 DOM 插入。如果用户粘贴的是 1000 行以上的文本这个同步解析和插入就会把主线程堵住。解决方式很直接粘贴的文本不立刻解析而是先塞进“待解析队列”让 Worker 按分帧节奏处理。Worker 解析完成后渲染层再按增量渲染的方式把新内容逐块画出来。这里有一个操作细节值得说一下粘贴之后的光标位置处理。如果内容是分帧慢慢渲染出来的光标如果在粘贴内容之后用户可能感觉到光标“被推着走”。我的处理是在粘贴时先记录光标位置等所有解析块渲染完成后一次性把光标定位到粘贴内容的结尾。中间过程虽然内容在逐步增多但光标不会跳动用户感知上整个过程是流畅的。4.3 Markdown 表格与大代码块的性能陷阱重构过程中我发现Markdown 文档里最“吃性能”的结构不是长文而是表格和超大代码块。这两类结构在普通段落渲染里不会触发的高级布局算法在它们身上全都会触发。表格的高开销来源是列宽计算。Markdown 表格的每一行列数可能不一致渲染层必须扫描整张表才能确定列宽。如果一张表有 1000 行、8 列列宽计算就是 8000 次单元格尺寸分析这在全量渲染时代是个大灾难。我的处理方式是限制表格的默认最大宽度超过一定宽度的单元格内容走省略号截断避免浏览器为了一格超长文本去重新计算整列宽度。同时在渲染层对表格做整表缓存只要表格内容不变就不重新计算列宽。大代码块的问题在行号生成上。如果给每一行代码都创建一个 DOM 节点做行号一个 5000 行的代码块就会产生 5000 个额外的节点。我的优化是行号区域用 canvas 画滚动时只更新可视区域的行号这样即使代码块有 1 万行浏览器也不需要维护 1 万个行号节点。实测下来canvas 绘制行号在普通 PC 上每帧耗时不到 1 毫秒远远优于 DOM 方案。图片路径也要单独提一下。很多 Markdown 文档会引用本地图片全量渲染时如果直接给所有 img 标签设置 src浏览器会同时发出大量图片请求导致加载变慢甚至卡死。我在渲染层做了图片懒加载只有进入可视区域的图片才真正赋值 src其余一律用一个轻量的占位符替代。这个细节对包含大量图表的长文档非常关键。4.4 性能优化的优先级与基线建设这次重构给我最大的教训是性能优化不能凭感觉必须建立基线。我重构到第三周的时候一度觉得“应该差不多了”但手上没有任何数据能证明它比以前好也不知道接下来该优化哪一块。后来我强制自己建了一套简单的基线指标每次改动后都跑一遍打开时间从点击打开到首屏完整显示的时间。输入延迟从键盘事件触发到屏幕显示新字符的时间。滚动帧率滚动过程中 FPS 的均值与最低值。内存峰值打开大文档后浏览器的内存占用峰值。这四个指标我用脚本自动化采集每次提交代码后都跑一轮发现哪一项有回退就立刻排查。有了基线之后整个优化过程就从“玄学”变成了“科学”每个改动都能看到明确的数字变化。我后来还发现性能优化的优先级应该以用户感知为序打开速度排第一输入延迟排第二滚动流畅度排第三内存占用排第四。前三个是用户每次打开编辑器都会直接感受的内存占用虽然也重要但只要不导致崩溃用户感知不明显。按照这个优先级投入精力性价比最高。这次重构下来我个人的一个核心体会是编辑器类应用的本质竞争力就是“手感”而手感是由每一帧的响应速度堆出来的。全量渲染在代码上看起来“简单省事”但它把成本一次性压给了浏览器和用户最终一定会以难用为代价还回来。分帧、增量、虚拟滚动这些方案单独看都不算新奇难的是把它们组合起来让每一步都恰到好处。如果你也想重构自己的 Markdown 编辑器我建议先从自己的性能基线和用户反馈入手把最痛的点找出来再做架构调整千万别一上来就推倒重来。