去年年中我收到一条用户反馈:粘贴一份两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万个节点就是另一回事了——即使是空div,30万个也足够让布局引擎卡顿。
所以核心策略只有一个:**让浏览器只处理用户当前看得到的那部分内容。**这就是虚拟滚动(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 | 约60fps |
| DOM节点数量 | 约32万 | 约600 |
| 全量解析耗时 | 约12秒 | 约60ms(增量解析) |
从用户体感来说,最大的变化是“编辑大文档不再有窒息感”。现在即使打开一个8MB的日志文件,也只是首次解析慢一点(约3-4秒),进入编辑状态后的操作流畅度和小文档基本一致。
7.2 优化投入产出比排序
如果让我给这套优化按性价比重新排个序,结论是这样的:
- 虚拟滚动——最核心的架构决策,解决了90%的视觉卡顿问题。但它也是工作量最大、坑最多的部分。
- 增量更新——用户“输入是否跟手”的胜负手,没有它,虚拟滚动只能让大文档“看得流畅”,改起来还是痛苦。
- 按块高亮 + 缓存——感知明显的加分项,实现中等难度,但收益显著。
- Worker化解析——在纯前端场景下价值最大,配合Electron或Web场景时收益更高。但单独使用收益有限,必须和增量更新配合。
7.3 一些个人体会
这次重构让我对“性能优化”有了更深的认识。技术上最难的其实不是某个算法,而是多种优化手段叠加时的相互影响。虚拟滚动改完,增量解析要跟着改;增量解析改完,高亮和滚动又要同步调整。性能优化本质上是系统工程,任何一个环节掉链子,整体体验都会被打回原形。
如果现在有人要做一个类似的项目,我会建议:**先明确你的目标文档量级,然后直接采用虚拟滚动和解耦渲染的思路。**不要在一个全量渲染的架构上去做局部优化——那只是拖延时间,不是解决问题。