Markdown编辑器性能优化:从全量渲染到虚拟滚动与Token化架构的重构实践
2026/9/16 5:20:28 网站建设 项目流程

两个月重构 Markdown 编辑器,2 MB 文档约 1 秒打开

事情是这样的。年初帮团队维护一个内部知识库系统,前端是用老掉牙的 jQuery 生态堆起来的,里面嵌了一个 Markdown 编辑器,说难听点就是个带预览的 textarea。刚开始大家也就记点会议纪要、贴点 API 文档,随便用用没人在意性能。结果后来有人把整个项目的历史变更记录、几十篇带大量代码块和 Base64 图片的 Markdown 全塞进去了,单文件经常干到 1~2 MB,整个编辑器直接卡到打不出字,保存一次要转圈三五秒,满屏全是用户的抱怨。

于是我接手做了两个月的重构。最终效果是:2 MB 的 Markdown 文档从打开到可编辑,耗时在 1 秒上下,打字延迟体感上完全消失,长文档滚动跟手,光标跳转不闪白。这篇文章就是这次重构的完整复盘——从架构选型到核心实现,再到底层算法的取舍和后续踩坑,全部记录下来。如果你也在维护一个 Markdown 编辑器、或者任何“大文本 + 实时渲染”型工具,这篇内容应该能帮你避开不少我趟过的雷。

先说结论:这次重构最核心的思路就一条——别把 Markdown 文档当文本处理,要把它当作一棵可控的 Token 树来处理。所有性能优化,到最后都是围绕“如何减少重复计算”和“如何减少 DOM 操作”这两件事展开的。

1. 重构前的定位与目标拆解

1.1 旧版编辑器到底慢在哪

拿 2 MB 的 Markdown 文件来说,纯文本大约是 40 万到 60 万个字符,换算成行数通常在 8 万到 15 万行之间。旧版编辑器用的是“全量渲染”策略:每次按键触发change事件,然后对整个 Markdown 源码进行正则解析,生成整篇 HTML,塞进预览区。打字的时候,每次 Keydown 都要走过“取全文 → 正则匹配 → 拼接 HTML → 整段 innerHTML”这条链路。

很多人可能以为正则解析 60 万字符应该很快,实际上快的是正则本身,慢的是整篇 HTML 的构建和 DOM 替换。一次输入触发一次全量 innerHTML 赋值,浏览器需要把 40 万节点的字符串重新解析成 DOM 树,然后再做一次完整的样式重算和布局。这个过程在 2 MB 级别下,单次要花费 300~800 毫秒。你要是连打十个字,那就是十次全量重排的排队和累积,用户体感自然就是“卡成 PPT”。

更隐蔽的坑是,旧版把编辑区和预览区做成了两个独立的滚动容器,每次操作两边的滚动位置都要做同步补偿,这里又有大量scrollTop的反复赋值和强迫浏览器同步布局的开销。

1.2 重构的坐标系:先定边界再动手

重构之前,我先把需求按照“必须达成”“尽量达成”“暂不追求”三个层次列了出来。这一步很关键,因为编辑器领域特别容易掉进功能深渊——今天想加个表格编辑器,明天想加个实时协同,一旦战线拉长,性能优化根本排不上优先级。

必须达成:

  • 支持 2 MB 级别(约 10 万行)Markdown 文档在 1 秒内完成打开渲染。
  • 编辑过程中打字不产生明显卡顿(单次输入响应不超过 50 ms)。
  • 编辑区和预览区保持实时联动。
  • 保留原有的大纲跳转、代码块复制、表格渲染等基础功能。

尽量达成:

  • 预览区支持较为流畅的滚动浏览。
  • 支持基础 Markdown 语法和 GFM 扩展(表格、删除线、任务列表)。
  • 依赖的最小化,方便后续移植。

暂不追求:

  • 实时协同编辑(那玩意是另一个级别的复杂度)。
  • 自研富文本排版引擎(直接用 contenteditable 的坑谁踩谁知道)。
  • 移动端适配的精细调优。

1.3 两条技术路线的灵魂对决

我在立项初期其实纠结了很久:是走 CodeMirror 6 / Monaco 这类成熟的代码编辑器底座,还是自研一个轻量的“文本域 + 渲染层”方案?

CodeMirror 6 的架构我非常欣赏,它的文档模型Text本身就是个持久化数据结构,对超大文档做了分段处理;视图层也有Viewport的概念,只渲染可视区域内的行。理论上用它做底座是最稳妥的。但我调研之后发现两个很现实的问题:第一,CodeMirror 6 为了通用性,带了很多我们用不到的模块,包体积和初始化成本都偏高;第二,也是更要命的,团队里没人熟 CodeMirror 6 的插件体系,而我们要做的 Markdown 预览联动、大纲跳转、代码块渲染都不太容易在它的模型上直接实现,学习成本摊进去,两个月不一定够。

所以最后我选了“自研 textarea 虚拟滚动 + 分区 Token 化解析”这条路。textarea 负责处理键盘输入和光标管理,我们只负责两件事:缩放 textarea 的内容行高来匹配虚拟滚动的位置,以及把可视区域里的文本段提取出来做局部解析。这套方案的工程量可控,所有核心逻辑都握在自己手里,排查问题比依赖黑盒要舒服得多。

提示:如果你不需要预览联动,只要“超大 Markdown 文件的编辑体验”,直接用 CodeMirror 6 或者 Monaco 会更省事。自研虚拟滚动属于“可控性强、工程量大”的路线,适合有明确需求边界和足够排期的情况。

2. 核心架构设计与关键决策

2.1 数据流:从“全量管道”改成“增量环形队列”

旧版的架构本质上是一条管道:textareavalue → parser → html → preview DOM,每次输入走完全程。新架构我把数据流改成了三层:

  • 源图层:textarea 的 value 始终是真相源(source of truth),我们不去动它,只读取。
  • 模型层:由源文本切分出的“块数组”(block list),每个块是一个逻辑段落(标题、列表项、代码块、引用块等)。
  • 呈现层:只根据可视范围,从块数组里取需要的块,做局部解析和渲染。

这个改动最重要的变化是:不再因为一次按键触发全量解析。每次输入事件到达时,我们只对“光标所在的块”和“可能受影响的相邻块”做重新解析,然后对比新旧 Token 结果,决定要不要更新 DOM。其余块的 Token 和渲染结果原样保留。

这里有个比喻特别好用:旧的方案是每次交作业都推倒重写一整本作业本;新的方案是打开作业本,只擦掉你做错的那一行,用修正带补上新内容,其他页面翻都不翻。

2.2 魔改 textarea 行高:撑起一棵“虚拟的”参天大树

textarea 天然只显示少量文本,滚动时浏览器帮你控制可视区的内容。但在 10 万行的文件里,textarea 的所有文本依然在内存里,而且它的渲染高度最多只能到 33 万多像素(Chrome 里大概是 33,554,428px)。这个高度上限直接限制了我们不能用“真实撑开”的方式实现虚拟滚动。

我的做法是:textarea 里不装载全部文本,只保留可视区域上下的“窗口文本”。然后通过调整 textarea 的height样式和padding-top/padding-bottom填充,让滚动条模拟出完整文档的滚动范围。具体公式是:

滚动条总高度 = 全部块累计高度总和 可视区 textarea 高度 = 可视区容纳的块累计高度 padding-top = (当前滚动位置对应的块之前的累计高度) padding-bottom = (总高度 - padding-top - 可视区高度)

这样浏览器就会认为 textarea 里有一个很高的文档,滚动条比例看起来和真实文档一致。当用户滚动时,我们监听scroll事件,重新计算当前可视区对应的块区间,再更新窗口文本。

这个方案最大的难点在于:如何准确测量每个块的高度。Markdown 渲染后的高度受字体、行高、图片尺寸、代码块换行等因素影响,纯靠估算一定会导致滚动条跳动或光标错位。我最终的方案是用一个隐藏的测量行(measure line)来动态测量每个块的渲染高度,然后按块缓存起来。每当字号、主题或图片加载状态变化时,自动失效缓存。

2.3 为什么说 Token 化是“唯一真神”

Markdown 解析这个环节,我见过很多开发者直接用正则一行行做匹配,然后立刻拼 HTML。这在短文档下没问题,但在 10 万行场景下会暴露两个致命问题:

  • 正则的“回溯灾难”:某些嵌套结构(比如列表里的引用里的代码块)会让正则引擎陷入反复回溯,单次解析时间从几毫秒变成几百毫秒。
  • 没有中间表示,DOM 更新只能全量替换。

重构后的方案是引入一个轻量级的 Token 化层:先把 Markdown 源文本按行切块,再用“游标扫描”的方式逐个字符生成 Token。每个 Token 记录类型(headingbullet_listcode_blockquoteplain_text等)、起始偏移、结束偏移、嵌套层级和原始文本范围。

有了 Token 树之后,渲染就变成了一个“Token → DOM 节点”的单纯映射过程。这一层隔离的意义非常重大:解析器和渲染器彻底解耦。以后想更换渲染主题、增加新语法支持,只需要在渲染器里做映射,不需要动解析器。同样,如果想支持“部分更新”,只需要重新解析被修改的那几个块,重新生成局部 Token,再做局部 DOM 更新。

这里顺手记录一个 Token 化的关键细节:不要把整个文件一次性 Token 化成一个大数组,而是按块懒加载。我们维护一个块索引表,每块对应一段源文本,只有被渲染到可视区的块才执行真正的 Token 化。这样即使文件 10 万行,初次打开的 Token 化计算量也只有可视区那几十行,自然快得飞起。

注意:懒加载 Token 化意味着“大纲”这类功能不能只依赖已解析的块。我的做法是:打开文档后,在后台空闲时间(requestIdleCallback)分段扫描全文档的标题 Token,只有大纲数据会被全量构建,正文渲染保持局部化。这样既保证大纲秒出,又不阻塞主线程。

3. 实操过程与核心环节实现

3.1 环境准备与工程结构调整

项目技术栈我选了 Vite + TypeScript,纯前端,不依赖任何框架。原因很直接:Markdown 编辑器的渲染频率非常高,如果引入 React 或 Vue,每一次局部刷新都要走一遍虚拟 DOM diff,这层开销在 10 万行的场景下会被放大得很难看。如果你非要用框架不可,记住一个原则:编辑区核心链路(滚动、输入、光标)尽量避免框架的响应式代理,用原生 DOM 操作直连。

初始化工程:

npm create vite@latest md-editor-refactor -- --template vanilla-ts cd md-editor-refactor npm install

目录结构按功能模块拆好:

src/ core/ blockManager.ts # 块索引与懒加载管理 tokenizer.ts # Markdown Token 化解析器 renderer.ts # Token 到 DOM 的渲染器 viewport.ts # 虚拟滚动可视区管理 ui/ editor.ts # textarea 封装与输入事件处理 preview.ts # 预览区渲染入口 statusBar.ts # 状态栏(字数、解析耗时等) utils/ measure.ts # 隐藏测量行 debounce.ts # 防抖工具

3.2 Token 化解析器的实现要点

我不会贴整份解析器代码,因为那个实在太长了。这里只讲核心设计模式。解析器入口接收一段源文本和起始块号,输出一个 Token 数组。整个过程是逐行扫描,状态机驱动

核心状态有:

  • NORMAL:普通段落
  • IN_CODE_BLOCK:在围栏代码块内
  • IN_QUOTE:在引用块内
  • IN_LIST:在列表内(需要记录嵌套层级)
  • IN_TABLE:在 GFM 表格内(做宽度要对齐)

状态机的好处是,不需要回溯就能正确处理多行结构。

关键的伪代码逻辑:

type Token = { type: string; startLine: number; endLine: number; text: string; depth: number; meta?: Record<string, string | number>; }; function tokenizeBlock(lines: string[], startLine: number): Token[] { const tokens: Token[] = []; let state = 'NORMAL'; let currentToken: Token | null = null; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const trimmed = line.trim(); // 围栏代码块检测 if (/^```/.test(trimmed)) { if (state === 'IN_CODE_BLOCK') { state = 'NORMAL'; currentToken.endLine = startLine + i; tokens.push(currentToken); currentToken = null; } else { state = 'IN_CODE_BLOCK'; currentToken = { type: 'code_block', startLine: startLine + i, text: '', depth: 0, }; } continue; } if (state === 'IN_CODE_BLOCK') { currentToken!.text += line + '\n'; continue; } // 标题 const headingMatch = /^(#{1,6})\s+(.*)$/.exec(trimmed); if (headingMatch) { if (currentToken) { tokens.push(currentToken); currentToken = null; } tokens.push({ type: `heading${headingMatch[1].length}`, startLine: startLine + i, endLine: startLine + i, text: headingMatch[2], depth: headingMatch[1].length, }); continue; } // 列表项 const listMatch = /^(\s*)([-*+]|\d+\.)\s+(.*)$/.exec(line); if (listMatch) { if (currentToken && currentToken.type !== 'list_item') { tokens.push(currentToken); currentToken = null; } if (!currentToken) { currentToken = { type: 'list_item', startLine: startLine + i, text: '', depth: Math.floor(listMatch[1].length / 2), }; } currentToken.text += line + '\n'; continue; } // 其他情况:段落文本 if (currentToken && currentToken.type !== 'paragraph') { tokens.push(currentToken); currentToken = null; } if (!currentToken) { currentToken = { type: 'paragraph', startLine: startLine + i, text: '', depth: 0, }; } currentToken.text += line + '\n'; } if (currentToken) { tokens.push(currentToken); } return tokens; }

你一定注意到了,这个状态机不会对段落内部的“加粗”“行内代码”“链接”做精细拆分。这是有意的——行内语法我们放在渲染阶段用正则做小范围匹配。因为段落内部的行内 Token 化开销相对可控,而且行内结构很少跨段,不会引发回溯灾难。把块级解析和行内解析分离开,能让块级解析特别快、特别稳。

3.3 虚拟滚动排版与高度缓存

虚拟滚动的核心是一个块高度管理表(blockHeightCache)。每个块在首次渲染完成后,用offsetHeight读取真实高度,缓存在 Map 里。当滚动事件触发时,计算当前滚动位置scrollTop,通过前缀和数组二分查找定位起始块索引。

具体实现:

class ViewportManager { private blockHeights: number[] = []; private prefixSum: number[] = []; private rebuildPrefixSum() { this.prefixSum = [0]; for (let i = 0; i < this.blockHeights.length; i++) { this.prefixSum[i + 1] = this.prefixSum[i] + this.blockHeights[i]; } } // 二分查找:给定 scrollTop,返回对应块索引 findBlockIndex(scrollTop: number): number { let low = 0; let high = this.prefixSum.length - 1; while (low < high) { const mid = Math.floor((low + high) / 2); if (this.prefixSum[mid] < scrollTop) { low = mid + 1; } else { high = mid; } } return Math.max(0, low - 1); } }

这里要注意的是,块的高度不是永远不变的。比如用户正在打字,一个空段落变成带文字的标题,高度可能从 20px 变成 40px。如果缓存不失效,滚动位置就会错乱。我的经验是:每当块的内容发生变化(输入事件的撤销/重做、代码块折叠展开等),立即将该块的缓存高度标记为 dirty,并在下一次帧渲染时重新测量。

另外一个容易踩的坑是图片加载。Markdown 里如果有一张还没加载完成的图片,它的初始高度可能只有几十像素,等图片加载完成会撑开几百像素,导致后面的所有块整体下移。我的处理方式是:给预览区的图片设置一个默认的占位高度(比如 240px),等load事件触发后用真实尺寸更新块高缓存,并做一次滚动位置的补偿修正。

3.4 样式与主题适配

重构后我把渲染层和样式层彻底分离。预览区里的每个 Token 类型对应一个 CSS 类,比如:

.md-block { font-family: var(--md-font, "SF Mono", Consolas, "Courier New", monospace); line-height: var(--md-line-height, 1.7); } .md-heading1 { font-size: 2em; font-weight: 700; margin: 1.2em 0 0.6em; border-bottom: 1px solid var(--md-border, #e5e5e5); padding-bottom: 0.3em; } .md-code-block { background: var(--md-code-bg, #f6f8fa); border-radius: 6px; padding: 12px; overflow-x: auto; margin: 0.8em 0; } .md-quote { border-left: 4px solid var(--md-quote-border, #d0d7de); padding-left: 1em; color: var(--md-quote-color, #57606a); }

主题适配只需要覆盖一组 CSS 变量,非常干净。对用户来说,这也意味着他们可以像写 CSS 一样自定义自己的 Markdown 样式,不需要修改任何 JS 逻辑。

3.5 用骨架屏让首屏“看起来”更快

2 MB 文档从点击打开到真正渲染完成,即使经过优化,也要大约 1 秒的时间。这 1 秒如果白屏,用户会以为程序挂了。所以我加了一个轻量的骨架屏机制:文档打开后,先根据文件的总字节数和平均行高粗略估算总高度,在预览区渲染出一片灰白相间的条纹占位背景,并显示“正在解析文档… 38%”这类进度提示。

具体实现是,在解析过程中不断上报已解析的块数比例,用requestAnimationFrame驱动进度条。这样给用户的心理预期就完全不一样了——不是“卡住了”,而是“正在加载”。

3.6 性能数据实测与对比

重构完成之后,我拿一份 2 MB、约 11 万行的 Markdown 文档做了完整的性能测试。测试环境是 Chrome 116、MacBook Pro M1。数据如下:

指标旧版重构后
首次打开到可编辑8.7 秒0.94 秒
输入一个字符后的响应延迟约 340 毫秒8 毫秒
连续输入 10 个字符的总耗时4.1 秒60 毫秒
文档内跳转(大纲点击)1.2 秒120 毫秒
滚动过程中的平均帧率12 FPS58 FPS
内存占用峰值890 MB205 MB

内存占用的大幅下降是意外之喜,原因是旧版每次全量渲染都会产生一次巨大的 HTML 字符串和 DOM 树,垃圾回收又没来得及回收;新版因为是局部渲染和复用 DOM,存活对象数量少了一个数量级,自然不吃内存了。

4. 常见问题与排查技巧实录

4.1 光标错位与跳动

虚拟滚动编辑器的天敌就是光标错位。最常见的原因是padding-top和 textarea 内部的换行结构没有匹配上,导致浏览器默认光标定位用的坐标和显示的字符位置对不上。排查思路可以用三步走定位:

第一步,关闭虚拟滚动,用全量 textarea 渲染看看光标是否正常,排除输入法或浏览器扩展的干扰。第二步,检查 padding-top 与实际窗口文本的行号偏移是否一致——可以临时在文本开头添加一个特殊标记,看看光标是不是落到标记之后。第三步,确认窗口文本的行数是否与预估高度一致,尤其是空行(空行高度容易估算错误)。

我最终的修复方案比较暴力但有效:编辑区不用 textarea 自带的软换行(wrap="off"),设成wrap="off",这样所有文本都是物理一行对应逻辑一行,行高由 CSS 统一控制为line-height的整数倍,高度计算就精确到“行数 × 行高”,不再依赖像素测量。这是虚拟滚动类编辑器的最优解,代价是横向出现滚动条,但对 Markdown 编辑这个场景完全可接受。

4.2 输入法组合态下的高亮错乱

中文输入法在 textarea 里输入时,会有一个“组合态”(composition)阶段。这个阶段里,textarea 的 value 变化是连续的,但其实内容还没有确认。如果在组合态期间触发解析和渲染,会出现高亮一半中文、一半英文的混乱状态,甚至可能把组合中的拼音当成 Markdown 语法解析掉。

我的处理方式是监听compositionstartcompositionend事件:

let isComposing = false; editorEl.addEventListener('compositionstart', () => { isComposing = true; }); editorEl.addEventListener('compositionend', () => { isComposing = false; scheduleRender(); }); editorEl.addEventListener('input', (e) => { if (isComposing) return; scheduleRender(); });

同时,预览区的更新全部改到requestAnimationFrame里合并,这样即使一次输入事件触发了多次 DOM 变更,渲染也只执行一次。

4.3 长文档下代码块和表格断裂

当虚拟滚动把文档切成一个个块渲染时,代码块如果从一个块跨越到另一个块,样式会断裂——上面的部分是浅灰背景,中间突然变成正文,下面又变成浅灰,非常难看。这和分页打印时表格跨页断裂是同一个问题。

解决方案是:块管理器维护一个“合并块”的概念。如果连续的多行都属于同一个代码块(Token 类型相同),就合并成一个渲染单元,而不是按物理行拆分。这样整个代码块要么完整出现在可视区内,要么整个被移出,不会出现半截渲染的情况。在实际渲染时,如果代码块特别高,比可视区还高,就需要内部再做一次子分页,但这种情况极少,遇到时可以降级为全量渲染该代码块,性能影响也可控。

4.4 大纲跳转和滚动定位的精度修复

大纲跳转的本质是“已知目标块索引,滚动到目标块顶部”。这个功能看起来简单,但和虚拟滚动组合后就变得很微妙:如果直接算目标块的前缀和,然后设置 scrollTop,理论上是对的,但由于块高度缓存可能存在脏数据,落地时位置会偏差几十像素,甚至差出一个屏幕。

我的修复方法是“两步跳转”:第一次先按估算的前缀和设置 scrollTop,然后等渲染完成后,读取真实的目标块 offsetTop,再次修正 scrollTop。也就是说,跳转分“粗定位”和“精修正”两帧完成,中间只隔一次 requestAnimationFrame,用户无感知,但精度可以做到像素级。

4.5 性能监控手段

重构过程中,我给编辑器加了一个小的监控面板,用PerformanceObserver采集长任务(long task)和布局抖动(layout shift)的数据,在调试模式下显示在状态栏里。这个小面板帮了大忙,因为很多性能问题不能靠肉眼感知——比如一次看似无害的scrollTop赋值,可能反而触发整页的同步 layout。

如果你是边开发边调试,强烈建议在状态栏持续输出这几个指标:

  • 单次渲染耗时(renderTime
  • 当前可视块数量
  • 块高度缓存命中率
  • 主线程长任务数量
  • 内存变化(通过performance.memory

有了这些数字,你能非常清晰地知道每次改动是变快了还是变慢了,而不是靠“感觉”。

5. 重构过程中沉淀的几个关键经验

5.1 性能优化优先做减法,而不是做加法

这次重构最大的认知升级是:旧版卡顿的根因是架构问题,不是某个函数写得不够快。即使我把正则表达式优化到极致,把 innerHTML 换成 createContextualFragment,全量渲染带来的卡顿依然会存在。真正解决问题的是减少“要做的事”,而不是让每件事变得更快。所以开动之前,先想清楚哪些是可以不做的,往往比寻找更快的实现更重要。

5.2 状态唯一来源(single source of truth)

编辑器是一个非常容易出现“状态不同步”的场景:textarea 里的文本、预览区的 DOM、大纲的数据、光标的偏移、滚动的位置,这些东西如果各自为政,稍微改一处,另外几处就乱套。

这次重构我立了一条死规矩:textarea 的 value 是唯一权威数据源,一切渲染和状态计算都由它派生。预览区的 DOM 是 source 的“投影”,大纲是“索引”,滚动位置是“游标当前观察点”。任何地方想改状态,都必须通过“修改 value → 触发更新”的方式完成。这条规矩在最开始会增加一点开发成本(不能图方便直接操作 DOM),但越到后期越能感受到它的价值——编辑器几乎不会出现“内存态和界面态不一致”的诡异 bug。

5.3 给自动化测试留后路

重构这种核心模块,不做测试就是在走钢丝。我给三个核心模块(blockManager、tokenizer、viewport)都写了独立的单元测试。尤其是 tokenizer,我准备了一批特殊用例:空行开头、Windows 换行符、混合缩进、嵌套引用、表格中的竖线、代码块内的 Markdown 语法、HTML 标签等等,确保状态机在各种情况下都不会“跑飞”。

在真实输入事件和滚动事件上做自动化测试比较麻烦,但业务逻辑不依赖 DOM 事件,所以单元测试的价值依然立得住。后续如果还有人动这块代码,回归测试直接跑一遍,比靠“点两下看看”要可靠得多。

5.4 时间预算与迭代节奏

两个月的时间,我在排期上做了个三七开:前两周做技术调研和架构验证,中间五周做核心功能开发,最后一周专门做性能调优和 bug 修复。实际执行下来,性能调优永远不够一周——所以如果你也要做类似的重构,建议至少给性能打磨留出两周。特别是虚拟滚动这类对交互细节要求极高的模块,真正的坑往往是在“好像已经能用了”之后才暴露出来的。

另外有个建议:不要第一天就冲进代码里。先用一天时间把你手头最大的那个 Markdown 文件、最慢的操作、最难受的交互全部列出来,量化好“现在慢到什么程度”。后面的每一次优化,都对照这个基线去衡量。这样既不会做无用功,也能在团队 review 时拿出清晰的数据说服别人。

6. 后续扩展方向和踩坑预告

重构完成之后,我又花了一点时间把预览区升级成了“可交互预览”。做法很简单:预览区不再是一块纯只读区域,而是叠加了一层透明的 canvas 事件层,用于捕获光标位置和点击坐标,这样用户点击预览区的标题时,编辑区的光标会自动跳到对应的 Markdown 源文本位置。这个功能实现成本不高,但对“看文档写文档”的使用习惯是有明显改善的。具体实现是先遍历 Token 树,为每个标题 Token 记录它在预览区 DOM 中的 offsetTop,然后在点击事件里二分查找最近的标题 Token,再调用编辑器的光标定位方法。

还有一个我觉得值得做的方向是“代码块折叠”。Markdown 文档里如果包含大量很长的代码块,预览时用户体验非常糟糕。可以考虑给代码块 Token 增加一个 collapsed 状态,折叠时只显示第一行和展开按钮,高度只有一行。这个功能在技术上没有任何难度,只是需要在块高度缓存上多做一层失效逻辑——折叠操作之后,所有后续块的前缀和都要全部重算。如果文档本身就是长文档,这个重算可以做得很克制,因为一次折叠只影响折叠块自身的高度,后续块高度都没变,完全可以做成增量更新。

再往后如果团队有富文本导出或 PDF 导出的需求,Token 树这套架构也能直接复用:你只需要写一个新的 renderer 输出 HTML 片段,再交给打印引擎即可,完全不需要动解析器。这也是当初把 Token 化作为独立一层带来的最大红利。

根据我个人的体会,编辑器这类工具是最值得做性能投入的——它不是给用户看一眼就走的页面,而是很多人每天要盯上好几个小时的生产力工具。一次输入延迟半秒,一天下来累积的无谓等待和心理摩擦是非常惊人的。所以如果你手头也有一个越用越卡的 Markdown 编辑器,或者任何类似的“大文档 + 实时预览”类工具,别犹豫,尽早做一次架构层面的重构。先把“全量更新”改成“局部更新”,再把“全部解析”改成“懒解析”,性能问题的雪崩大概率就能直接止住。

这次重构里的两个小细节,最后再分享一下吧:一个是在 textarea 上设置spellcheck="false"autocorrect="off",能显著减少输入过程中的潜在重排;另一个是预览区尽量使用contain: content的 CSS 属性,把渲染边界锁死,从根上避免编辑器内部改动引发外层页面布局抖动。这俩都是几行代码的事,但对体感提升非常明显。如果你正打算动手,建议开场就先加上。

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

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

立即咨询