从5.8秒到1秒:Markdown编辑器大文档性能重构实战
2026/9/16 5:30:59 网站建设 项目流程

1. 两个月的重构不是拍脑袋,是卡到忍无可忍

1.1 老版本的真实状态:一个 2MB 文件把整个应用拖垮

我接手这个 Markdown 编辑器的时候,它其实已经上线跑了大半年,功能不缺,该有的都有,代码高亮、实时预览、表格支持、导出 HTML 和 PDF,甚至在用户群里还有一批死忠。真正让人崩溃的是性能。当时群里最经典的一句话是:"想用你们的编辑器写长文档?先准备好一杯咖啡。" 这话听着像玩笑,但你真去打开一个 2MB 的 markdown 文件就知道,一点都不夸张——文件打开后界面白屏好几秒,然后风扇开始狂转,鼠标还能动,但整个窗口像是被什么咬住了一样,拖拽都费劲。2MB 是什么概念?纯文本 markdown 的话,大约是 45 到 50 万个字符,按平均每行 40 个字符估算,大概有 1.2 到 1.5 万行。这个体量放到今天任何一个正经编辑器里都应该是秒开的,可老版本就是扛不住。

我自己做了一轮基准复现:用同一个包含大量代码块、表格、多级列表和引用块的 markdown 文档,一次次冷启动打开,测出来平均耗时 5.8 秒,期间 UI 完全无响应。在文档里每敲一个字符,输入延迟大约在 300 到 500 毫秒,也就是说你连续输入一行字,屏幕上的光标会滞后小半拍。滚动就更痛苦了,不是卡顿是直接漂移,滚动一下,整个文档要重新绘制一遍,肉眼能看到明显的白屏闪烁。

用户反馈集中在三个场景:第一,打开大文件;第二,在长文档里搜索替换;第三,同时打开两个大文档做对比。这三个场景本质上都指向同一个问题——这个编辑器在处理大宗文本时,存在某个被放大了的计算瓶颈。刚开始我们以为是硬件问题,后来发现用户用 i7 处理器和 16GB 内存也一样卡,那就不是硬件能救的了。

1.2 目标不是推翻重来,而是把三条主链路打穿

重构之前,团队内部其实是吵过一轮的。有一半人主张直接用 Electron 上成熟的 Monaco 或者 CodeMirror,换内核,省事;另一半人觉得插件、自定义语法、现有主题体系都绑定在自研内核上,全换掉等于产品重做,风险太大。最后我们选择了一条折中路:保留自研内核,但推翻内部的核心架构,用两个月时间重写解析和渲染两条链路。为什么敢这么干?因为我们确认过,功能代码本身没有大问题,问题出在数据从文件进来之后,到界面画出来这一段的处理方式上。

这次重构只给三个月,实际上压缩到两个月,所以范围必须收得很死。我们定了三条硬指标:

  • 冷启动打开一个 2MB 标准 markdown 文档,耗时不超过 1.2 秒,目标 1 秒;
  • 文档内连续输入时,字符到屏幕的延迟不超过 50 毫秒;
  • 长文档滚动不掉帧,滚动过程不出现全量重绘的白屏。

同时明确不做的事情也很重要:不做新的主题系统,不改插件 API,不动命令面板。所有跟编辑体验无关的边缘功能,一律冻结。这样做的目的只有一个——让两个月的窗口期全部花在性能主链路上,不被周边问题稀释。

现在回头看,这个范围隔离是整个项目能按期交付的关键。很多重构项目死在"顺手改一下""顺便优化下"这种心态里,改着改着就变成了无限扩张。我们当时立了一条规矩:任何与三项目标无关的代码 merge request,直接打回,不讨论。

1.3 重构组的内部结构:三个人,三条线,一个公共测试集

这次重构实际投入的是三个半人,我负责整体架构和解析器,另一位同事负责渲染层和虚拟滚动,还有一位前端工程师负责衔接层、事件系统和自动化测试。测试集是整个重构的地基,我们找了几十份公开的 markdown 文档,从几 KB 到 4MB 不等,覆盖了 README、技术博客、API 文档、长篇小说甚至爬下来的网页转 markdown 文件。每份文档都配上统一的基准脚本,统计打开耗时、内存峰值、滚动帧率、输入延迟四类数据。

在动第一行业务代码之前,我们先花了一周做性能剖析。这里我要多说一句,这周看起来没有产出,但恰恰是最值的。因为只有先把瓶颈定位到具体函数和具体算法上,后面每一个改动才有依据。否则凭感觉"优化优化",最后大概率是瞎忙。具体怎么定位,下一节展开讲。

2. 卡顿的根子在解析链路,不在渲染本身

2.1 Performance 面板里三个扎眼的数字

我调试性能有个习惯:先用 Chrome DevTools Performance 面板录一段完整的冷启动过程,从文件读入到界面首帧出来,然后把火焰图导出来逐段看。老版本打开 2MB 文件,录完放大一看,时间基本被三块吃掉:

第一块是文件读取和预处理,占大概 300 毫秒左右,这里面包括把文件内容按行拆分成数组、做 UTF-8 转码、简单语法扫描。第二块是 markdown 解析,也就是把文本转成内部 AST,这一下直接干掉 3.2 秒,是整个流程里最夸张的耗时点。第三块是渲染,把 AST 转成 DOM 并挂载到页面,又花掉 1.8 秒。剩下的一些小头尾,加起来几百毫秒。

这三个数字一出来,问题就清楚了:解析占了整体耗时的 55%,渲染占了 31%。打开文件慢,主要就是这两条链路的锅。但更让我意外的是,老版本在输入时也一样卡,而输入过程理论上只需要解析变化的那一小块内容。我继续录输入事件,发现每次按键,代码居然会重建整棵 AST,然后重新渲染整个文档。也就是说,你在第 5000 行敲一个字,前面 4999 行也得跟着重新解析一遍。这个设计非常不合理,但你要说它错吧,它也不是没有道理——很多编辑器为了正确性,都采用"全量重建"这条路,因为增量更新太难做了,容易在各种奇葩 markdown 语法组合上翻车。可全量重建的复杂度是 O(n),n 是文档总行数,文档一大,线性增长也扛不住。

2.2 三个叠加的瓶颈:全量扫描、同步解析、DOM 全量重建

打开慢和输入卡,表面上看是两个问题,其实是同一批底层机制的三个叠加效应。

第一个是全量扫描。每次渲染前,编辑器会把整个文档内容重新"过"一遍,不是只过变化区域。这个在文档小的时候无所谓,比如一个 20KB 的 README,全量扫描也就几毫秒。但当文档到 2MB,字符量 50 万级别,全量扫描的代价就开始显现。更糟糕的是,老版本解析的过程不是一次性的,它内部有多层处理:先是按行分词,然后构建块级结构,再对行内做标记解析,比如加粗、链接、行内代码。每一层都要遍历一次全文,等于一个 2MB 文件要被来回扫好几遍。

第二个是同步解析。JavaScript 是单线程的,老版本的解析器是纯同步代码,从第一行跑到最后一行中间不交还控制权。这意味着浏览器主线程被完全占死,UI 渲染、事件响应全部排队。2MB 文档同步解析耗掉 3 秒多,这三秒里整个应用就是"假死"状态。用户感知到的白屏、拖不动,都是因为主线程被解析器堵死了。

第三个是 DOM 全量重建。解析完成后,渲染层会为每一个文本块、标题、代码块、表格创建对应的 DOM 节点,然后一次性替换掉旧节点。2MB 文档粗略估算有上万个块,光 DOM 节点就是十几万个。浏览器面对十几万个节点的插入和样式计算,即使什么都不做,也要花个一两秒。更致命的是,这种全量替换会破坏页面滚动位置,所以老版本里打开大文件之后,滚动条位置经常重置到顶部,体验非常糟。

这三个瓶颈叠在一起,才造成"2MB 文档约 1 秒打开"听起来像天方夜谭。但实际上,如果我们能做到只解析必要部分、异步分片执行、渲染时按需创建节点,哪怕 4MB 文档也能控制在 1 秒内。

2.3 为什么 2MB 恰好是那道分水岭

很多用户不太理解,为什么 200KB 的文档还行,一到 2MB 就崩。这背后其实是一个"复杂度台阶"问题。当文档小于某个阈值,比如 200KB,全量解析需要的时间大约 300 毫秒左右,虽然也能感觉到轻微卡顿,但还能忍。文档一旦涨到 2MB,需要解析的字符数翻了 10 倍,但解析器内部有些操作的复杂度是超线性的,比如临时字符串的拼接、嵌套数据结构的递归、DOM 节点的样式重算,这些都不是严格 O(n),而是接近 O(n log n) 甚至更差。所以文档从 200KB 涨到 2MB,耗时不是涨 10 倍,而是从 300 毫秒涨到 3 秒多,直接越过用户心理承受线。

另一方面,2MB 也是很多真实场景的"最长最长"场景。一本长篇技术文档的 markdown 源码,通常也就 300KB 到 800KB;一个大型开源项目的 README 加上 docs 目录合并,偶尔能到 1MB 出头。2MB 已经属于极端情况。我当时跟团队说,如果我们能把 2MB 压到 1 秒,那么日常 90% 的文档都会是秒开,感知性能会提升得非常明显。所以这个目标不是一个空洞的 KPI,它是一条有实际意义的性能分界线。

2.4 用 1 秒反推技术方案:哪些必须做,哪些可以不碰

定了 1 秒的目标之后,我们把预算拆了一下。按 2MB、50 万字符算,理想流程是这样的:文件读取和预处理 100 毫秒以内,增量解析首屏需要的那部分 200 毫秒以内,渲染首屏可见区域 200 毫秒以内,其余时间留给调度、初始化、后台陆续解析不可见区域。总预算控制在 700 到 800 毫秒,留出 200 毫秒余量。这个拆分看起来粗暴,但它让我们明确了三件事:第一,解析器绝不能同步一次性跑完整个文件;第二,渲染绝不能一次性创建全部 DOM;第三,需要一种增量调度机制,让文档在后台继续解析,而不是等全部解析完成再显示。

方案定下来三个关键词:异步分片、增量解析、可视区渲染。后面所有代码都是围绕这三个词展开的。中间的取舍也很直接——用户打开文件第一眼看到的应该是"能看、能滚、能输",至于文档尾部还没解析完,完全可以在用户翻到那里之前悄悄补完。这也是很多现代编辑器能在超大文件上保持流畅的核心思路。想清楚这一点,架构改造的方向就清晰了。

3. 重写解析器:从全量构建到增量分片

3.1 解析链路的新架构:fast scan、块级 AST、延迟任务

老版本解析器是一口气把整个文件变成一棵完整 AST,再交给渲染层。新的架构把它拆成了三层。

第一层叫 fast scan,也叫快速扫描。它的任务只有一个:把原始文本按行切分成有类型的块,比如标题、段落、代码围栏、列表项、引用块。这一层不解析行内语法,不做嵌套结构,只是用正则和状态机按顺序过一遍,速度非常快。实测 2MB 文档的 fast scan 大概 80 毫秒出头。扫描产物是一个扁平数组,每个元素对应一个块,记录它的行号范围、块类型、层级深度和原始文本。这个阶段几乎不产生新对象,只复用原始字符串的 subrange,所以内存开销很低。

第二层是块级 AST 构建。在 fast scan 得到的块数组基础上,按照 markdown 的块级规则把嵌套关系组装起来,比如列表里嵌套子列表、引用块里包含代码块和标题。这一层同样不做行内解析。组装结果是标准的块级树,每个节点关联它在源文件中的行号范围。块级 AST 的好处是它比全 AST 轻得多,数百万行文档的块树节点数通常只有行数的几分之一,而且构建快、便于做局部的子树替换。

第三层是懒解析任务队列。行内标记,比如**加粗**[链接](url)`行内代码`,全部推迟到一个按需调度的任务队列里。渲染器只对当前可见的块发起行内解析请求,不可见块保持未解析状态。当用户滚动到某个未解析的块附近,滚动调度的空闲回调会立刻把它解析出来,这个延迟非常低,用户几乎感知不到。

新架构还有一个核心特性:解析结果缓存。每个块保存一个内容哈希,只要源码没变,解析结果就直接复用。对于大文档来说,用户通常只修改某个局部区域,其余上千个块的解析结果都是有效的,完全不需要重新计算。

3.2 增量更新的脏区域算法:不是重跑全文,而是打补丁

增量更新是这个架构里最核心也最容易出错的部分。我把它设计成一个"从编辑位置向外扩散"的脏区域算法,触发点很简单:用户输入、删除、粘贴、undo/redo,都会在文档模型里产生一个变更事件,包含变更起始行号、删除行数、插入行数以及变更后的文本内容。

收到变更事件后,编辑器不会对整个文件重新 fast scan,而是先把变更点对应的原始快加入一个"重解析队列"。然后执行一个循环:从变更行开始,解析一段范围内的行,看它能否与之前的块结构正确衔接。如果解析结果在边界处与现有块树匹配不上,就把范围向上下扩展,直到边界对齐为止。这个"扩散"最多进行有限轮次,超过阈值后就退化为从最近的"稳定锚点"开始重新解析。锚点通常是文档开头、某级标题、顶层列表项或者代码围栏的开始行,它们天然具有"打断上下文依赖"的能力,从锚点重新开始解析能保证正确性。

举个例子,用户在列表中间按回车新增了一个空行,这个变更不会影响列表前面的任何内容,脏区域最多覆盖当前列表项到下一个顶层标题之间的范围。在这个区域重新 fast scan 和构建块树,再把结果替换到原有块数组中,整个操作可能只花几毫秒。输入性能从原来 300 多毫秒的延迟直接降到了个位数毫秒。

下面是脏区域扩散的简化实现思路,用 TypeScript 描述核心逻辑:

type ChangeEvent = { line: number; oldLineCount: number; newLineCount: number; text: string; }; function applyChangeToBlockTree(root: BlockNode, change: ChangeEvent) { let start = change.line; let end = change.line + change.newLineCount; const block = locateBlockAtLine(root, start); // 尝试用最小范围的源码重建这段块结构 let rebuilt = scanAndBuildBlock(start, end); while (!canMerge(root, start, end, rebuilt)) { // 边界不匹配,向上下各扩展若干行 start = Math.max(0, start - EXTEND_LINES); end = Math.min(totalLines, end + EXTEND_LINES); rebuilt = scanAndBuildBlock(start, end); if (extendCount++ > MAX_EXTEND) { // 退化为从最近的稳定锚点开始重建 const anchor = locateAnchorAbove(root, start); rebuilt = scanAndBuildBlock(anchor, end); break; } } replaceBlockRange(root, block, rebuilt); root.rebuildLineIndex(); }

这个算法最大的好处是:90% 的编辑操作都只需要重建几十行范围内的块结构,真正的"扩展到全文档"几乎不会触发。代价就是实现时必须仔细处理块与块之间的"上下文依赖",比如列表嵌套是否闭合、引用块的续行规则、代码围栏是否处于打开状态,这些边界问题稍不留神就出错。我们后来在 6.1 节会详细聊踩过的坑。

3.3 行内解析的按需调度和结果复用

行内标记解析和块级解析是彻底解耦的。块级解析完成后,文档已经可以渲染成一个"半成品":能看到标题、段落、列表和代码块的基本轮廓,但行内字体加粗、斜体、链接样式还没有。这些样式由行内解析器按需补齐。

调度器是一个简单的高性价比任务队列:当主线程空闲时(用 requestIdleCallback),从当前滚动位置上下各取一个一定数量的 block,执行行内解析,如果用户在滚动,就优先处理滚向方向的块。实测中,文档滚动到从未访问过的区域时,从"触发滚动"到"行内样式完整显示"的间隔大约 20 到 40 毫秒,基本感知不到。

为了避免同一个块被重复解析,每个块维护一个inlineState,取值是unparsedparsingparsed三种。内容哈希是判断是否过期的关键——只有当块内容发生变化时,状态才会重置为unparsed。这套缓存机制在长文档来回滚动、编辑后撤销恢复的场景下收益特别明显,因为同一块通常会被反复访问,缓存命中能省掉大量重复计算。

3.4 多行语法跨块:这些边界必须单独设计

新架构的增量更新最大的难点,集中在"一个逻辑块实际上横跨多个物理行,而且它的开始可能在变更区域之外"。典型的有三种情况。

第一种是代码围栏。文档里某个位置出现了开头,后面 100 行都是代码内容,直到遇到另一个才闭合。如果用户在代码块中间修改了某一行,增量更新不能只把这一行当普通段落处理,它必须知道当前处于代码围栏内部,并且整块代码文本都需要作为纯文本保存,不能解析里面的 markdown 标记。我们的 fast scan 在发现```时会记录状态,如果变更点的起始位置已经在一个未闭合的围栏里,就强制把变更区域的起点拉回到这个围栏的起始行,然后再重建。

第二种是引用块和列表的"续行规则"。markdown 里一行> 引用内容后面跟一个没有>前缀的普通行,在标准实现中通常被视为引用块的延续。列表项同理,缩进的下一行即使没有-前缀,也可能属于上一个列表项。这意味着块边界的判定不能只看单行文本,还要参考上下文的缩进和前缀。我们在脏区域算法里处理这个问题的办法是:每次重建都会带上区域首部之前的一到两行作为"上下文行",这些行不参与更新,只用于判定边界是否连续。实际用下来,这种带上文的方式,比"只看区域内行"的正确率高得多。

第三种是表格。markdown 表格表头、分隔行、数据行的依赖关系非常强,分隔行一旦缺失或者列数不对,表格结构就会整段变质。我们单独把表格识别做成一个"表格块":fast scan 遇到表格起始行后,会贪婪地往后吃所有表格行,直到遇到空行或非表格行。增量更新如果落在表格内部,必须整块重建表格块,不能只更新表格中的某一行。

这些边界在最初几周测试里疯狂暴露,我们最后把整套边界用例整理成了三个测试文件,接近 400 个测试点。可以说,增量解析的工程量,一大半都在这些看起来不起眼的边界处理上。

4. 编辑器视图层改造:虚拟滚动和按需渲染

4.1 为什么打开文件的大头在 DOM 装配

解析器改造完成后,打开文件的时间从 3.2 秒降到了大概 300 毫秒左右,但整体耗时仍然有 2 秒多。继续用 Performance 面板看,剩下的瓶颈几乎全在 DOM 装配上。

老版本的做法是把 AST 一次性转换成 HTML 字符串,然后设置成一个巨大容器元素的 innerHTML。听起来很高效对不对?实际上不是。首先,2MB 的文档转换成 HTML,字符串本身就超过 5MB,innerHTML 解析这么长的字符串,浏览器要经过 HTML 解析器、DOM 树构建、CSS 样式计算、布局、绘制等一系列过程。即使 innerHTML 的底层是 C++ 实现的,数据量摆在那里,最终也要落成几十万个 DOM 节点。其次,渲染之后的样式回调、事件委托注册、代码高亮等后处理,同样要遍历所有节点。这个"DOM 爆炸"才是打开文件耗时的主要来源。

所以视图层的方向很明确:不要让所有节点一次性出现在 DOM 里。基于这个思路,我们做了两件事——虚拟滚动 + 可视区内渲染。这两件事相辅相成,共同把 DOM 总量限制在几百个节点内。

4.2 虚拟滚动实现要点:行高缓存、缓冲区、占位符

虚拟滚动的原理不复杂:不管文档总高度多大,实际上用户的屏幕只能看到一部分内容,我们只需要渲染视口内以及上下缓冲区的块,其余区域用空白的占位元素把滚动条高度撑起来。听起来简单,落地的时候有几个细节特别容易踩。

首先是总高度的计算。如果一个文档有 8000 个块,每个块的渲染高度各不相同,直接说"总高度 8000 * 平均高度"那是不准确的,会导致滚动条位置错乱。我们的方案是给每个块维护一个渲染高度,默认给一个估计值,实际渲染完成后用真实高度回写。同时维护一个前缀高度索引,便于从滚动偏移量快速定位当前应该显示哪些块。这个索引用类似"跳表"的结构,两层就够,查找复杂度是 O(log n),滚动时定位当前首行只要几微秒。

其次是缓冲区的设置。我们做了一个双向缓冲:视口上方 3 屏、下方 3 屏之内的块会提前渲染出来,超出这个范围就销毁。这样快速滚动时,新进入视口的块不会因为"刚要渲染"而闪白屏。缓冲区的大小要根据块的渲染成本调整。对代码高亮这种成本比较高的块,缓冲区还可以再加大。实测中,3 屏缓冲区在普通鼠标滚轮的滚动速度下完全够用,但如果是触控板快速甩动,偶尔会看到短暂的空白,后来我们把缓冲区改成 5 屏,才彻底消掉。

虚拟滚动和系统原生滚动条也有一层配合关系。我们把整个编辑区包裹在一个普通 div 里,overflow: auto,内部放一个高度等于文档总高的占位容器,再用绝对定位把当前块列表渲染在对应的位置上。这种"绝对定位按偏移量摆放"的方式,比每次滚动都重排容器内容要稳定得多,也是社区里主流虚拟滚动库的做法。我们最开始是自己手写了一个简单的版本,后来发现边界情况太多,干脆基于成熟的虚拟列表逻辑改造,省了不少事。

4.3 预览区和编辑区的双屏联动优化

这个编辑器有双栏模式,左边编辑原文本,右边实时预览渲染后的 markdown。老版本的预览区在超大文档下比编辑区更卡,因为它是完全独立于编辑器视图的又一套 DOM,而且每次输入都要全量重新渲染。重构后,我们把预览区也接入同一套增量解析结果:编辑区产生的块树是"源数据",预览区本质上只是同一棵块树的另一种视图。

预览区和编辑区共用一个滚动位置,通过一个联动状态同步。当用户滚动编辑区,预览区会自动滚到对应的块的位置;反之亦然。这个联动的计算依赖块级 AST 中的行号映射——编辑区滚动到第 2000 行,预览区就找到与这个行号最近的块节点,并滚到该块预览视图对应的高度。这样拖动滚动条时,两边节奏是完全同步的。

预览区的渲染也做了按需处理:只有接近视口的块才真正解析行内语法并渲染成 HTML,其他块用轻量的占位文本替代。这个优化让双栏模式的大文档体验,从"卡成幻灯片"提升到"跟单栏模式差不多流畅"。用户测试的时候甚至有人问我们是不是把预览改成单页静态渲染了,其实背后就是复用了解析和虚拟渲染两套机制。

4.4 输入延迟的处理:合并更新和宏任务调度

前面提到老版本输入延迟 300 到 500 毫秒。重构后,这一块的原理是"输入不直接触发同步渲染"。keydown 事件进来后,先把文本变更应用到文档模型,然后立即请求一个宏任务(requestAnimationFrame)来处理当前帧的增量解析和局部渲染。在同一帧里如果用户连续输入了多个字符(中文输入法更是如此),这些变更会被合并成一次增量更新,只做一次解析和渲染。这个合并机制非常重要,因为中文输入法会在很短时间内触发十几个 composition 事件,如果每个事件都做完整解析,那还是卡。

实际调优时,我们保留了"光标闪烁优先"的原则:输入的第一优先级是让光标位置和当前已输入字符尽可能快地显示出来,其他不可见区域的解析全部让路。打个比方,输入延迟优化做的是"少干活",而不是"把活干得更快"。只有真正减少了每一帧的工作量,才能在高频输入下维持 60fps 的顺滑感。

5. 2MB 文档的实际压测数据与调参过程

5.1 重构前后的四项核心指标对比

两个月重构完成,进入验收阶段。我们拿同一批测试文档跑基准脚本,重点看四项数据:冷启动打开耗时、滚动帧率、输入延迟、内存峰值。下面这张表是其中一份 2.1MB 测试文档的结果,文档结构包含 4200 个段落、280 个代码块、110 个表格和超过 1500 个列表项。

指标老版本新版本优化幅度
冷启动打开耗时5.8 秒1.1 秒约 5.3 倍
连续滚动平均帧率18 fps58 fps约 3.2 倍
输入到出字延迟(P95)420 毫秒38 毫秒约 11 倍
峰值内存占用(打开后稳定)860 MB410 MB降低 52%

第一批数据出来后,"1 秒打开"还没有完全达标,1.1 秒离 1.0 秒只差一点但就差那么点。我们在 Profile 里又揪出一个隐藏开销:冷启动初始渲染时,虚拟列表一次性预渲染了上下 5 屏的缓冲区,这个操作在文档刚打开时就触发了 6 屏块的行内解析、代码高亮和样式计算,占了大概 150 毫秒。优化方式是把冷启动的缓冲区改成"先 1 屏,空闲后再补到 5 屏",首屏速度明显提升。

最终打开耗时稳定在 1.0 到 1.1 秒之间,不同文档稍有波动,目标基本达成。真正让我觉得这次重构没白做的是滚动表现——58 fps 基本就是贴着 60fps 上限在跑,而且连续滚动 5 分钟没有内存持续上涨,说明节点销毁和复用机制是健康的。

5.2 调参过程:哪些参数值得抠,哪些纯属心理安慰

这个月我调整最频繁的几个参数,记录一下供后来人参考。

第一是虚拟缓冲区的屏数。我们最终设置在 5 屏,但这个值不是越大越好。缓冲区太大会导致初始渲染变重,内存也跟着涨;太小又会在快速滚动时露白。取舍的关键是看你的目标用户主要用什么输入设备。如果办公场景以鼠标滚轮为主,3 屏就够;触控板用户多,就要到 5 到 7 屏。我们选择 5 屏是两者兼顾的结果。

第二是行内解析任务队列的单次处理量。我们设定每帧最多处理 20 个块的行内解析。如果一次处理太多,当前帧就会超时;处理太少,滚动到新区域时会感觉样式跳出。这个值跟机器性能关系不大,主要受块的平均复杂度影响。代码块密集的文档,20 个块可能耗尽预算;纯文本段落为主的文档,一次处理 50 个都没问题。最后我们做了一个动态预算:根据当前一帧的实际耗时自动调整下一帧的处理量,让帧率保持在 55fps 以上。

第三是脏区域扩展的步长。我们最开始设置为上下各扩展 20 行,实测在某些嵌套场景下还是要循环好几轮。后来改成了"先按 50 行扩展,如果还不匹配就跳转到最近的锚点",整体重试次数少了,结果反而更快。这个参数不建议调得太小,因为边界不匹配往往意味着结构性问题,靠小步扩展很难救回来。

也调过一些后来发现没用的参数,比如把行内解析任务改为用 Web Worker。听起来应该更快,但在我们的架构里,行内解析需要频繁访问块树和样式上下文,结构化克隆的开销反而把收益吃掉了。最后 Worker 只用于 fast scan 这种内存连续、上下文少的重计算。这里也给我们一个教训:不是所有"异步化"都有收益,要测量,不要拍脑袋。

5.3 用户视角的验收:长文档工作流还顺吗

指标归指标,用户真正关心的是"用起来顺不顺"。我们找了 5 个之前反馈最激烈的重度用户做内测,每人发了一份接近 2MB 的真实长篇文档,让他们完成三个任务:打开文档后快速定位到第 1500 行、在全文范围搜索一个关键词并替换、直接拉到文末追加一段 500 字的内容。

结果反馈比我们预期还好:三个人说"打开秒开",两个人说"打开之后稍微等一下下就能编辑",滚动顺畅,搜索替换也没有出现卡顿。唯一被吐槽的点是文末追加内容时,如果文档还没解析完,增量更新偶尔会和后台解析任务抢 CPU,导致追加后短暂掉一两帧。这个问题后来通过一个"变更优先于后台解析"的调度策略解决了:有编辑事件进来,后台循环立刻让位。

这些反馈让我意识到,性能优化的价值不是跑分好看,而是让用户不再被工具打断思路。文档编辑器作为一个工具,最好的状态就是"感觉不到它在工作"。这次的压测数据证明了方向是对的,但也暴露了很多只有在真实场景里才会出现的细节问题。

6. 这次重构踩过的坑,和后来人值得注意的地方

6.1 增量更新最深的坑:多行 block 被拦腰截断

增量更新这块,我们踩翻车最惨的一次是在列表嵌套场景。当时测试集里有一份文档,第 200 行是一个嵌套了 3 层的列表项,这个列表项内部还带一个引用块和一段代码。我们只修改了列表项中间某个词,按预期应该只重建这个列表项所在的块,但实际跑起来,重建后的列表项会和后面的内容完全脱节,整个文档从那个位置开始乱套。

排查了很久才发现问题:fast scan 阶段,我们误把"被缩进的下一行"当作了一个新块,而实际上它仍然是上一个列表项的子内容。脏区域扩展时又只带了区域上方两行作为上下文,不足以让扫描器确定"这里其实还在列表项内部"。最终修复办法就是上文提到的"带上文行"方案,而且上下文行数不是固定的,要根据当前块的类型动态调整。比如列表项需要带上整个列表头,引用块要带上前面的引用前缀。这个修复让我们的边界测试用例从 200 多个一下子涨到 400 多个。

这个坑给我们的教训是:markdown 的块级结构不是一个"行与行相互独立"的格式,它有很强的上下文依赖。凡是想做增量解析的编辑器,都必须先回答一个哲学问题:一个块到底是从哪里开始、到哪里结束?这个边界搞不清楚,后面全白搭。

6.2 事件节流引发的"光标跳跃"视觉 Bug

新版本刚换上虚拟滚动的时候,内部测试出现了一个诡异问题:输入时如果光标正好在视口边缘,按下空格键,整行文字会闪烁一下,然后光标跳到奇怪的位置,看起来像键盘漂移。一开始怀疑是文档模型算错了行号,查了一圈才发现是虚拟滚动加事件节流惹的祸。

原因是这样的:我们给滚动事件做了节流,每 100 毫秒最多处理一次滚动位置同步。当用户输入文字导致当前块高度变化(比如某一行文字换行,块变高了),文档总高度和块偏移都会变。这时候如果滚动位置没有及时同步,光标所在的行会被虚拟列表判断为"在视口外",从而被销毁,然后下一帧又检测到光标位置,把它重新渲染出来。一销毁一重建,视觉上就是闪烁和跳动。

修复并不复杂:在文档"高度变化"事件里,立刻重新计算当前块的偏移量,并把滚动位置锁定到光标所在行上面,不让它掉出视口。这个逻辑必须同步执行,不能放在节流事件里。类似的坑还有输入法 composition 期间触发滚动同步,也会造成光标跳动,最终我们把"光标跟随"事件优先级调到最高,所有其他调度都要让路。

6.3 兼容性和回滚策略:重构期间不敢删的老代码

两个月重构的过程中,我们一直保留着老版本完整代码,通过特性开关切换新旧内核。每次合并新代码到主分支,都先交给自动化测试跑一轮,再让团队内部在旧内核和新内核之间来回切换对比。这个"并行双轨"策略让我们没有任何一个晚上是"重构到一半无法工作"的状态。

不过保留老代码也有副作用:两套渲染逻辑并存,很容易被人不小心改乱。我们的规矩是,新代码一律放到新目录里,老代码目录冻结,除了修安全问题不允许任何改动。这个做法虽然增加了代码量,但保证了任何时刻都有一条可回退的路。最后切换上线的时间点,我们还专门压了一版旧的 tag,用于线上紧急回滚。

如果你也打算重构类似的基础组件,我强烈建议保留一条明确的回滚通道。性能优化的风险不在于"做不出来",而在于"做到一半项目状态不可控"。能随时回到旧版本,团队的心理压力会小很多,反而更容易按时交付。

6.4 重构完成后的长期收益:不只是打开快了

这次重构结束后,我们内部开玩笑说,这个编辑器从"能用"变成了"好用"。但我个人总结的长期收益其实有三层。

第一层是用户体验层面的。2MB 文档从 5.8 秒到 1 秒,看似只是数字变化,但它直接改变用户对产品的定位认知——原来只敢在小文档上用,现在敢把长篇技术文档和知识库都放进来,使用场景一下子从"便签"扩展到了"写作工作台"。

第二层是架构层面的。解析和渲染解耦后,后面加功能变得容易很多。比如后来加的自定义代码块渲染、大纲面板、文档概览图,都是直接复用块级 AST 和滚动定位系统,不需要再在渲染链路里动刀。

第三层是性能审计常态化。这次重构建立了一整套基准测试脚本和自动化告警机制,后续每次发版都会跑一遍性能回归。我们立了一个规矩:任何 MR 如果导致 2MB 文档打开耗时增加超过 100 毫秒,必须说明原因并提供优化方案,否则不允许合入。基于这次的经验,性能问题不会再等到爆发才处理,而是纳入每天的开发流程里。

最后再说一个针对 Markdown 编辑器场景的小技巧:性能优化一定要用"真实文档"压测,不要自己手写一个几万行的 lorem ipsum 就完事。真实文档里的列表嵌套、代码块、表格、图片引用、极长 URL、中文排版,每一种都会激活不同的代码路径。我们最终的测试集里甚至放了一篇从 PDF 转出来的小说,里面夹杂了大量空行和半角全角混排,这类"脏数据"才最能暴露问题。如果你正在为长期被吐槽卡顿的文档工具做重构,不妨先收集十份用户投诉里最典型的文档,再开始动手。性能问题的解法往往就藏在最细碎的真实文件里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询