
去年年中我收到一条用户反馈粘贴一份两MB左右的Markdown技术文档进编辑器直接白屏。本地复现了一下等了小半分钟才看到内容光标点一下要两三秒才响应。当时的第一反应是“用户文档太大了”但冷静下来一想两MB在今天的知识库、API文档、日志整理场景里真不算极端。这个问题的本质是我们的编辑器在架构上就没为这种量级做准备。接下来的两个月我把编辑器的解析层和渲染层推倒重写。重构完成之后同一份2MB文档从打开到可交互大约1秒输入延迟降到16ms左右滚动基本稳定在60帧。这篇就聊聊整个重构过程中的思路、方案和踩过的坑。1. 一个2MB文档为何能让编辑器彻底“罢工”1.1 用户场景还原不是极端场景是日常场景2MB是什么概念纯文本的话按平均每行80个字符估算大约是25万行。如果文档里全是表格、代码块、嵌套引用行数可能少一些但单个节点的复杂度会更高。这种规模在真实场景里一点不罕见开发者的接口文档聚合动辄上万行博客作者的长篇合集或迁移导入数据分析师直接粘贴日志片段企业内部的知识库一次性导入所以问题不是“用户为什么会用这么大的文档”而是“编辑器凭什么扛不住这么大的文档”。答案很简单大多数Markdown编辑器的架构默认文档在几千行以内一旦数量级上去瓶颈是全方位的。1.2 性能瓶颈拆解四个层面“叠加Buff”我把当时的卡顿拆成四个独立的瓶颈层后来优化时也是逐个攻破解析层对全文做同步正则解析25万行的文档一次性跑完直接阻塞主线程好几秒。这个过程是纯CPU密集型的没有任何异步空间。渲染层解析完生成整棵虚拟DOM再一次性挂载到页面上。浏览器要同时创建几十万个DOM节点布局和绘制的开销非常恐怖。高亮层代码块和行内样式的高亮是全文本扫描。正则引擎在最坏情况下是指数级回溯恰好Markdown里全是星号、反引号这种容易触发回溯的结构。交互层每一次输入都要触发“全量重解析 全量重渲染 全量高亮”。输入一个字符所有工作从头再来延迟自然越来越高。这四个瓶颈是叠加的不是独立的。它们共同作用的结果就是文档越大编辑器越慢用户越烦躁。1.3 重构的边界条件重构之前我先明确了不能动的底线编辑体验不能退化实时预览、光标定位、基础快捷键必须保留用户的数据始终是纯Markdown文本不改变存储格式插件机制向后兼容已有扩展尽量少改这些约束决定了后续的很多取舍。比如实时预览必须保留那就不能用“编辑器只做输入、预览区按需渲染”的回避式方案必须从渲染管线本身开刀。2. 先立靶子再开枪性能基线是怎么测出来的2.1 用真实案例替代“我感觉很卡”重构最忌讳的就是凭感觉优化。我做的第一件事是找一份有代表性的测试文档把性能基线量化出来。那是一个接近1.8MB的真实Markdown文件结构如下大约21万行文本138个代码块其中最大的一个约5000行大量表格约2000行嵌套引用和列表比例不低这个文件后来成了我整个优化周期的“标尺”每次改动都拿它跑一遍数据防止“修好一个地方又搞坏另一个地方”。2.2 三个关键指标我定义了三个核心指标之后所有优化都围绕它们打分指标含义优化前实测可交互时间TTI打开文档到可以正常点击光标的时间约28秒期间页面完全无响应输入响应延迟单次击键到光标移动/内容更新的间隔约1.2秒到3秒不等视文档位置而定滚动帧率快速滚动时页面的渲染帧率低于5fps画面撕裂严重这三项数据一出来团队对“必须重构”就不再有任何异议了。性能优化第一步永远是建立可重复的测量方法没有基线就没有优化。2.3 用Profile数据锁死热点Chrome Performance面板在这个阶段起了决定性作用。录完一份2MB文档从打开到可交互的完整Profile结论非常清晰解析阶段占了45%的主线程时间用的是同步正则解析器DOM构建占了25%一次生成约32万个节点高亮占了18%全文正则扫描其他GC、样式计算、布局合计12%我当时的判断是解析和DOM构建这两块必须重写高亮可以优化但不用推翻。后来的事实证明确实如此。3. 渲染层重写从“全量渲染”到“可视区调度”3.1 一个反直觉的结论问题出在DOM数量如果你用Chrome DevTools数一下一个25万行的Markdown文档展开后DOM节点数量会在30万以上。浏览器对付三五百个节点轻松自如但对付30万个节点就是另一回事了——即使是空div30万个也足够让布局引擎卡顿。所以核心策略只有一个**让浏览器只处理用户当前看得到的那部分内容。**这就是虚拟滚动virtual scrolling的基本思路。虚拟滚动的核心是把“真实的滚动高度”和“实际渲染的DOM数量”解耦。整个文档有25万行但视口内最多显示70行左右所以我只需要渲染70行加少量缓冲区总DOM节点控制在数百个以内。3.2 行高一致性与占位符方案虚拟滚动有个前提要么所有行高一样要么能快速估算每行的高度。我的实现选择了固定行高加估算的方案// 虚拟滚动核心逻辑简化版 interface VirtualItem { index: number; // 原始文档中的行索引 offsetY: number; // 距离顶部的偏移量 height: number; // 预估高度 } class VirtualScroller { private items: VirtualItem[] []; private viewportHeight 600; private rowHeight 24; // 基础行高 // 拿到当前滚动位置计算出需要渲染的起始索引和结束索引 getVisibleRange(scrollTop: number): [number, number] { const startIndex Math.max(0, Math.floor(scrollTop / this.rowHeight) - 10); const endIndex Math.min( this.items.length, Math.ceil((scrollTop this.viewportHeight) / this.rowHeight) 10 ); return [startIndex, endIndex]; } }但文章里总有代码块、表格这种跨越多行的块级元素。我的处理方式是**块级元素单独算高度文本行统一用基础行高。**解析阶段拿到AST后预计算每个顶层块的高度虚拟滚动时按块索引而非行索引计算可视区。这个方案让“2MB文档的DOM节点数”从32万降到了大约600个。滚动性能立刻就有了质的提升。3.3 可视化渲染的调度策略虚拟滚动本身不复杂复杂的是和编辑器的光标、选区、IME输入法状态做交互。这里我用了一个双缓冲的思路// 双缓冲渲染调度 class RenderScheduler { private rafId: number | null null; private dirty false; scheduleRender() { if (this.rafId ! null) return; this.rafId requestAnimationFrame(() { this.render(); this.rafId null; }); } private render() { // 第一次画占位符让滚动位置稳定 this.renderPlaceholders(); // 第二次画真实内容 requestAnimationFrame(() { this.renderRealContent(); }); } }这里关键点是滚动过程中先用占位符撑住高度占位符就是普通div高度等于估算行高滚动结束后再渲染真实内容。这样能避免快速滚动时白屏闪烁也减少了滚动过程中的重复渲染。在实测中这个改动让快速滚动从“5fps的幻灯片”变成了“约60fps的流畅滚动”代价是有轻微的内容滞后感但视觉上完全可以接受。4. 解析器与增量更新从“全量重来”到“打补丁”4.1 把解析工作扔给Worker虚拟滚动解决了DOM层的问题但每次输入时全文重新解析这件事还没解决。25万行文档即使渲染层不卡了解析层每敲一个字都全量跑一遍照样不可用。首先是把解析移到Web Worker里彻底释放主线程// 主线程通过postMessage发送原始文本 worker.postMessage({ type: parse, payload: fullText }); // Worker内解析完成后回传 worker.onmessage (e) { const { tokenBlocks, version } e.data; applyTokensToView(tokenBlocks); };Worker方案有一个必须注意的坑**postMessage传大对象是有开销的。**一个2MB文档解析完的AST可能是几十MB的JS对象如果每次输入都全量传一次光序列化和结构化克隆就能把收益吃光。所以Worker方案不能单独用必须搭配增量更新。4.2 脏行标记与局部重解析增量更新的核心思想是每次输入时先判断哪些行受到了影响只重新解析这些行。实现思路如下class IncrementalParser { private lines: string[] []; private dirtyRange: [number, number] | null null; markDirty(startLine: number, endLine: number) { this.dirtyRange [ Math.min(this.dirtyRange?.[0] ?? startLine, startLine), Math.max(this.dirtyRange?.[1] ?? endLine, endLine) ]; } update(text: string, startLine: number, endLine: number) { // 替换区间内容 this.lines.splice(startLine, endLine - startLine 1, ...text.split(\n)); // 标记脏区域 this.markDirty(startLine, startLine (text.match(/\n/g)?.length ?? 0)); // 重新解析脏区域 this.reparseDirtyRange(); } }但Markdown解析有个天然难题**块级结构是跨行的。**比如你正在一个代码块中间输入只重新解析当前行是不够的因为代码块的起始点可能在100行之前。表格也类似表格头的分隔线确定了整个表格的边界。我实测下来一个可靠的策略是**从脏区域往前回溯到最近的“安全边界”再开始重新解析。**安全边界包括空行、标题行、列表结束位置、代码块围栏——这些位置的解析结果通常不依赖前面的上下文。这个方案让单次击键的解析量从21万行缩减到几十行左右即使偶尔需要回溯也不会超过几百行。在主线程上执行都可以接受Worker反而成了可选优化。4.3 输入到预览的延迟增量更新的实测数据优化后的输入链路是这样的击键触发文本变更编辑器标记脏行区域在主线程同步重解析脏区域几十行耗时约1-2ms更新受影响的虚拟DOM节点预览区异步渲染对应块级区域这里我保留了主线程同步解析原因很简单**输入过程中解析结果必须立即应用到光标和代码高亮上同步能保证视觉一致性。**实测单次击键的总延迟稳定在16ms以内符合60fps的标准。5. 语法高亮与代码块的性能博弈5.1 全量高亮是第二个隐藏杀手解析和渲染都优化完后我把Profile又跑了一遍发现高亮渐渐成了新的瓶颈。原因很直接之前高亮是全文扫描21万行里哪怕只有1万行代码块正则处理起来也够呛。高亮优化的第一个动作是范围裁剪。既然虚拟滚动已经知道哪些行可见了高亮就不需要做全文只需要处理可视区附近的行。但这里有个bug隐患代码块如果从不可见区域开始可视区的某一行可能处于代码块中间单行高亮会破坏代码块的上下文。我的解决方式是按块高亮从AST中拿到当前可视区覆盖的顶层块只对落进可视区的块执行完整的高亮解析高亮结果按行缓存滚动回来后直接读缓存// 按块高亮的简化逻辑 function highlightBlock(block: Block, visibleRange: Range) { if (!block.tokens || block.tokens.dirty) { // 只对实际可见的块重新计算高亮 block.tokens runHighlighter(block.text); block.tokens.dirty false; } return block.tokens; }5.2 高亮缓存与失效策略缓存最大的问题是“什么时候失效”。我一开始图省事只要块内内容不变就不清缓存结果用户在输入时高亮经常“慢半拍”。后来参考了CodeMirror 6的状态管理思路**给每个块加一个基于文本内容的hash输入后增量对比hash变了才重新高亮。**块内单行变化通常只需要重跑这个块的高亮不会波及整篇。这里有一个性能数据很能说明问题优化前打开一份包含大型代码块的文档高亮阶段耗时4.8秒优化后首次打开约400ms滚动过程中高亮基本无感知。5.3 高亮线程与渲染线程的协作高亮计算放主线程还是Worker我最后的决策是首屏高亮放主线程滚动高亮放Worker。原因是首屏只涉及少量块主线程同步算完直接渲染视觉上最快滚动过程中需要高亮的块数量多丢给Worker算算完再传回来。但跨线程通信有个网络延迟的代价实测在低端手机上postMessage一个较大块的高亮结果约需2-3ms。这在高性能要求下是不能忍的所以我在Worker里做了高亮结果缓存池滚动时如果块内容和上次一样直接读缓存返回。6. 两个月重构踩过的那些坑6.1 虚拟滚动下光标“失踪”问题这是我在虚拟滚动上线后收到的第一个严重bug。现象是在大文档中点击中间某一行光标不见了滚回顶部再滚下去发现光标停留在正确的位置旁边。根因排查过程很典型一开始怀疑是contenteditable的光标定位问题排查了半天方向错了后来发现是虚拟滚动中行偏移量计算误差导致的。当代码块高度从24px变成70px时块内后续行的offsetY全部要重新计算但我的VirtualScroller在更新高度时只刷新了当前可视区没有刷新整个文档的偏移缓存修复方案是显示高度变化后把该块之后的所有行偏移量整体平移而不是逐行重算这个坑的本质是虚拟滚动把“每行高度”变成了一个需要全量同步的全局状态任何局部变化都要考虑对全局偏移的影响。6.2 增量解析的“表格边界”陷阱有一次用户反馈在一个表格中间编辑预览区表格错乱。我测试后发现是增量更新时“安全边界”回溯得不够远。表格的AST结构是“表头行 分隔行 数据行”分隔行影响到整个表格是否成立。如果用户在某个单元格里加了一个换行Markdown解析器会把当前行截断成两行表格的“块边界”就被打破了。此时如果我只回溯到空行表格的解析结果就是错的。修复思路是修改文本后优先检测当前是否存在未闭合的表格、代码块、引用块如果有回溯到这些块的开头重新解析。这个坑给了我一个很重要的经验增量解析的正确性完全取决于“如何确定安全边界”这个边界宁可保守也不能激进。多回溯100行损失1ms但少回溯1行可能导致整屏渲染错乱。6.3 网络传输与文件导入性能这个和编辑器本身关系不大但重构过程中暴露出来了。很多用户不是手动粘贴大文档而是通过网盘或Git同步文件。文件导入时如果走的是FileReader.readAsText然后全量替换内容2MB文档会导致编辑器两次全量渲染一次替换contenteditable一次触发虚拟滚动重新计算。优化措施是**导入时跳过虚拟滚动的全量重建直接重置scrollTop并让首屏渲染器工作。**这样第一次展示只需要渲染第一屏的内容后续滚动时再按需渲染。实测导入2MB文档到首屏可见约耗时350ms整个文档完全可滚动约1秒。7. 重构后的数据与复盘哪些优化性价比最高7.1 性能数据对比两个月结束后我用同一份基准文档重新跑了所有指标指标重构前重构后2MB文档可交互时间约28秒约1秒输入响应延迟1.2-3秒约16ms滚动帧率低于5fps约60fpsDOM节点数量约32万约600全量解析耗时约12秒约60ms增量解析从用户体感来说最大的变化是“编辑大文档不再有窒息感”。现在即使打开一个8MB的日志文件也只是首次解析慢一点约3-4秒进入编辑状态后的操作流畅度和小文档基本一致。7.2 优化投入产出比排序如果让我给这套优化按性价比重新排个序结论是这样的虚拟滚动——最核心的架构决策解决了90%的视觉卡顿问题。但它也是工作量最大、坑最多的部分。增量更新——用户“输入是否跟手”的胜负手没有它虚拟滚动只能让大文档“看得流畅”改起来还是痛苦。按块高亮 缓存——感知明显的加分项实现中等难度但收益显著。Worker化解析——在纯前端场景下价值最大配合Electron或Web场景时收益更高。但单独使用收益有限必须和增量更新配合。7.3 一些个人体会这次重构让我对“性能优化”有了更深的认识。技术上最难的其实不是某个算法而是多种优化手段叠加时的相互影响。虚拟滚动改完增量解析要跟着改增量解析改完高亮和滚动又要同步调整。性能优化本质上是系统工程任何一个环节掉链子整体体验都会被打回原形。如果现在有人要做一个类似的项目我会建议**先明确你的目标文档量级然后直接采用虚拟滚动和解耦渲染的思路。**不要在一个全量渲染的架构上去做局部优化——那只是拖延时间不是解决问题。