ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

流式Markdown解析器:代码块闪烁的根源与工程化解决方案

流式Markdown解析器:代码块闪烁的根源与工程化解决方案 最近在一个技术群里看到一段面试复盘问题本身并不复杂“流式 Markdown 解析器怎么实现”候选人很快给出了自己的方案“用 marked.js 每段 parse 一下就行。”面试官没有直接说对错而是追了一句“那代码块输出一半时页面闪成什么样了”这一问确实扎中了要害。如果只是在聊天框里渲染一句普通文本分段解析问题不大。可一旦内容里出现围栏代码块、列表、引用、表格这种跨行结构按段切开的 Markdown 文本就不再是合法的 Markdown。你拿一个不完整的片段去做解析得到的结果自然不稳定页面也跟着闪。更麻烦的是闪烁的本质不只是解析结果不对还有 DOM 重排、滚动位置回跳、代码高亮反复初始化这些因素叠在一起。今天想把这整套问题拆开聊一聊。1. 先搞清楚候选人的方案为什么会在代码块上翻车1.1 Markdown 的块级结构天然跨越“一行”Markdown 看起来是线性的、按行书写的但它的块级结构并不是天然按行划分的。一个围栏代码块从第一行python开始到某个后续行结束中间可能包含任意多行。如果你只取“每段”文本一段是什么意思是按换行切还是按 SSE 的某个 chunk 切如果分块边界恰好落在开围栏之后、闭围栏之前当前这一段就不是一个功能完好的 Markdown 文档。不只是代码块。表格也是一个典型例子。表格通常分成三部分表头行、分隔行、数据行这三部分必须连在一起才能构成完整表格。如果表格只输出到一半比如只输出表头行和分隔行数据行还没到解析器会怎么处理它会认为这里有一个表格结构但表格体为空。紧接着下一段数据进来又要重新解析整个表格页面就会连续跳动一次。普通文本段落也一样。Markdown 里常见的*斜体*或**加粗**如果被切了一半比如只收到左侧的**还没收到右侧的**解析器只能把这一半当作普通文本输出。等下一段内容到达、完整结构形成之后它又要从普通文本变成加粗文字肉眼看到的就是“文字突然跳了一下”。候选人的方案看起来简单但它忽略了一个前提marked.js 是一个面向完整文本的文档级解析器。它适合一次性输入完整 Markdown然后输出完整 HTML。它不负责维护“当前文本还处于什么状态”的上下文信息。你拿它做流式解析本质上就是让一个没有记忆力的解析器反复处理残片然后用心跳一样的 DOM 更新惩罚自己。1.2 “每段 parse 一下”会让半截代码块被错误解析我们可以直接写一段示例感受一下。假设流式输出顺序如下python def hello(): print(hello)如果按一次一行的方式传给 marked.js第一段只有pythonmarked.js 看到的是一个以围栏开头的文本但后面没有内容也没有闭合。它在处理时可能把它当作一个未闭合代码块也可能因为流式过程中还不存在闭围栏发出一段不规整的 HTML。无论哪种情况页面都会先渲染出一个奇怪的状态。等第二段def hello():到达后你又要拿python\ndef hello():重新 parse。这时标记库可能有足够信息打开代码块但它仍然没有闭围栏输出结果依然不是最终形态。等到第三段完整内容全部到达解析器才终于得到一个正确闭合的代码块然后重新生成完整的precode...。这三次渲染每一次 DOM 结构都不同。第一次可能是ppython/p第二次可能是precodedef hello():第三次才是完整代码块。用户看到的自然是代码块区域反复闪烁。如果你还在这个区域挂了语法高亮插件高亮会在每次 DOM 变化时重新初始化闪烁会更明显。这里有一个容易被忽略的点闪烁不一定来自“内容闪烁”也可能是 DOM 结构高度变化引起的“滚动跳动”。代码块未闭合时解析器生成的 DOM 高度可能只有一行闭合之后突然变成一个完整的代码块高度增加几十行。如果当前页面正好在滚动浏览器会为了保证视觉稳定性把用户定位在原来的内容位置上于是整个页面像被“拉”了一下。这个现象不是解析器特有的但流式输出会把它放大。1.3 闪烁不是一种现象而是三类问题叠加如果只把闪烁理解成“解析结果不稳定”排查思路会很窄。实际上流式 Markdown 页面闪烁至少可以拆成三类解析层闪烁同一段内容因为输入不完整被解析成不同结构。渲染层闪烁内容结构没变但每次更新都重建整个容器导致图像闪烁。布局层闪烁DOM 高度、宽度、滚动位置变化导致视觉跳动。很多人排查时只盯解析层换成更好的解析器、加了很多缓存页面还是闪。原因就是渲染层和布局层也在同时出问题。候选人的方案最原始版本是function appendChunk(chunk) { buffer.push(chunk); const text buffer.join(); container.innerHTML marked.parse(text); }这个写法把解析层、渲染层、布局层的坑全踩了一遍。每次新增一个 chunk都拿完整文本重新 parse然后把整个容器的 innerHTML 替换掉。就算 marked.js 没解析错页面也会因为innerHTML整体重建而闪浏览器没有机会复用已经存在的节点。所以面试官的追问其实是把方案背后的三个隐患一次性暴露了。它真正想问的不只是“代码块怎么处理”而是你有没有意识到流式渲染的复杂边界。这个问题要答好必须从“如何把一段文本变成 HTML”切换到“如何把一段不断增长的文本稳定地映射到不断变化的视图上”。2. 流式 Markdown 的核心不是“解析”而是“不完整”2.1 分块后第一要务判断当前是不是未完整块想做流式 Markdown头一件事不是下载一个解析库而是建立“不完整块”的识别机制。你要始终知道当前已经输出的内容里有哪些结构是已经开始、但还没结束的。最简单的方法是维护一个状态机。以围栏代码块为例let buffer ; let fenceOpen false; function appendChunk(chunk) { buffer chunk; const lines buffer.split(\n); // 最后一行不完整先保留不参与解析 buffer lines.pop(); for (const line of lines) { if (/^\s*(|~~~)/.test(line.trim())) { fenceOpen !fenceOpen; } // 正式解析这一行按当前状态决定是否输出 } }这段代码只是示例结构不是完整实现但它表达了关键点解析器必须带状态并且状态要跨 chunk 保留。如果当前fenceOpen为 true说明内容正处于代码块内部还没收到闭围栏。此时不能把这一整段内容直接交给 Markdown 解析器当作普通 Markdown 文本去处理否则会出现前面说的结构漂移。同样列表、引用、表格也会有不完整状态。列表可以通过“当前行是否以块级标记开头、缩进是否连续”来粗略判断引用可以用前缀判断表格的识别更麻烦因为表格没有明确的开头标记只能通过表头分隔行| --- |以及后续数据行是否持续来判断。所以如果你的流式内容主要来自大模型输出常见的情况其实是代码块、列表和加粗标记可以先处理这三种。2.2 要么推迟渲染要么暂时安全展示知道了“当前块不完整”就是已经确定了吗还没有。你还要决定不完整的块要不要显示显示成什么不显示会不会显得很迟钝两种常见策略策略一推迟渲染直到结构完整。围栏代码块只有等到闭围栏到达之后才渲染。这样代码块在任何时刻都是完整、合法的不会闪烁。但缺点也很明显大模型输出代码时通常是一个字一个字往外蹦用户会看到已经输出的代码迟迟不出现体验很差。所以这个策略更适合输出速度很快、代码块也较短的场景。策略二不渲染成真正的代码块而是先安全展示未完成内容。未闭合的代码块不是不展示而是先用普通文本、等宽字体、浅色背景展示原始内容。这么做用户至少能看到“已经输出的一部分代码”不会觉得页面卡住。等到闭围栏到达后再把这段内容切换成真正的高亮代码块。第二策略看起来好但它仍然有一个切换过程。为了减少切换闪烁可以把未完成代码块的占位容器和最终代码块容器做成同一个 DOM 节点不要销毁重建。你这样告诉浏览器这不是新元素只是内容变化。这样做虽然不能完全避免布局变化但可以减少节点重建带来的图像闪烁。对于加粗、行内代码这种更小粒度的不完整状态最省事的方法其实是“等这一行结束”。一行在流式输出中没有写完之前不要对行内标记做语义解析只把原始文本填充进去。等换行符到达标记当前行完整了再去解析行内样式。这样会让加粗、内联代码有一个很小的延迟但换来的是页面不再疯狂跳变。不要一上来就给完整内容做全量 parse也不要每个 token 都 parse。先把“哪些内容当前可安全解析”和“哪些内容暂时不能解析”分清楚。2.3 延迟阈值怎么定更新频率、流式速度、用户感知“推迟渲染”不是无限期推迟。实际操作中我们需要一个延迟阈值或者一种批处理节奏。比如每收到 100ms 的数据合并成一批再渲染每收到完整一行再触发一次渲染每收到 50 个 token再触发一次渲染。不同业务要求不同。如果是 LLM 聊天窗口用户对逐字输出有期待延迟太久会显得不灵敏一般算法端返回本身就有一定间隔我们没必要每个字都渲染可以 100300ms 做一次批量更新。如果是日志流、技术文档生成用户更关心稳定性和可读性可以等一个段落或一个代码块完整后再刷新。这里的核心不是“延迟越短越好”而是刷新频率要稳定。如果有时候 10ms 刷新一次有时候 500ms 刷新一次页面就会忽快忽慢用户能感觉到输出不连贯。稳定的中低频比杂乱的极高频体验更好。从工程经验看我会建议先做一个“按行 按时间”的组合触发有新行到达就排队但至少等待 150ms 才执行一次真实渲染。这样可以避免高频 chunk 连续打过来时页面频繁更新。后续再根据实际内容类型优化比如代码块内部输出频率较高时可以改成只更新文本节点不触发完整解析。3. 从最小可用方案到工程化方案3.1 先说一个能跑通的最小实现先别追求完美先让流程不断。可以这样设计一个最小可用的流式渲染机制维护一个原始内容缓冲区。每次追加 chunk 时把内容按\n拆开最后一段保留到 buffer作为下一轮的前缀。对完整的行维护一个状态机识别代码块围栏是否开启。如果代码块未闭合把代码块内部的原始内容暂存起来不交给 Markdown 解析器。如果代码块已闭合把暂存的内容拼成完整代码块文本再交给解析器生成 HTML。每次渲染时只替换“从上次渲染位置到当前末尾”的增量区域不替换整个容器。这里的第 6 条很关键。你可以用一个容器节点每次更新时把新增的 HTML 追加到容器末尾而不是整体重建。如果一个代码块因为闭合而发生变化那也只更新这个代码块对应的节点而不是把前面已经稳定的内容全部重绘。这种方案实现成本中等但能解决大部分闪烁问题。它适合内容块边界比较清晰的流式场景比如大模型输出、实时 Markdown 预览。3.2 代码块未闭合时的两种处理策略在最小实现里代码块是重点。我先给一个简化示例展示未闭合代码块的处理思路let buffer ; let fenceState false; let pendingCode ; function appendChunk(chunk) { buffer chunk; const lines buffer.split(\n); buffer lines.pop(); for (const line of lines) { const fenceMatch line.match(/^\s*(|~~~)/); if (fenceMatch) { if (!fenceState) { fenceState true; pendingCode line \n; } else { fenceState false; pendingCode line \n; flushCodeBlock(pendingCode); pendingCode ; } continue; } if (fenceState) { pendingCode line \n; } else { renderLine(line); } } } function flushCodeBlock(code) { const html marked.parse(code.trimEnd()); codeContainer.innerHTML html; }这个示例很粗糙但它说明了三种变化普通行直接渲染。代码块开始后内容先进入pendingCode。代码块闭合后才把完整代码块提交给marked.parse。如果你是实时预览场景可以额外给未闭合的代码块做一个纯文本预览容器让用户先看到内容等结构完整后再替换成真正的代码块。这个预览容器和最终容器建议使用同一外层节点只更新内部内容不要把节点从父级上移除再插入。3.3 防抖、批量更新和滚动锚定解析层解决了渲染层还需要处理。一个最简单的优化是把高频更新变成低频批量更新。不要每次收到一个 chunk 都执行render而是用一个setTimeout做合并let pendingContent ; let timer null; function scheduleRender() { if (timer) return; timer setTimeout(() { timer null; flushRender(pendingContent); pendingContent ; }, 150); } function appendStream(chunk) { pendingContent chunk; scheduleRender(); }这里flushRender依然要做状态判断和增量更新但至少不会出现“每秒 50 次解析”的情况。另外滚动锚定很值得做。很多流式页面是倒序显示或者用户希望在输出过程中保持在页面底部。如果你每次更新内容都直接scrollTop scrollHeight用户一旦向上回看历史内容就会被强拉到底部。正确做法是先记录用户当前的滚动方向。如果用户已经离开底部区域就不自动滚动如果用户仍停留在底部才跟随新内容滚动。对于增量更新还可以使用requestAnimationFrame把 DOM 写入集中到浏览器渲染之前避免一次事件循环里反复读写布局。这个对闪烁的改善比换解析器更明显。3.4 更彻底的做法虚拟滚动 全文缓存 / 增量解析库如果文本很长比如一个上万行的 Markdown 文档在流式输出你不能把所有 HTML 都放进 DOM。这时要换方案永远保留原始文本的完整缓存解析器也一直基于完整文本生成 AST但视图层只渲染可视区域。这个方案看起来复杂但它反而解决了闪烁问题因为底层 AST 不会因为视图裁剪而丢失状态。具体实现可以这样维护一个不可变数组每个元素是一行文本。每次追加 chunk 时按行拆分并追加到数组末尾。用一个轻量级状态机或增量解析器维护“哪些行处于代码块内”。虚拟列表只渲染可视区域内的行每行根据所属块类型决定渲染成文本、代码、列表项还是普通段落。这种结构对长文档非常友好但对实现要求很高。你需要处理行高变化、代码块跨虚拟行、高亮范围跨块、滚动锚定等问题。好在现在有一些编辑器框架本身就具备增量解析能力比如 CodeMirror 6 的 Language Support、Monaco 的 Model它们可以处理增量内容更新而不需要每次全量解析。只是它们的定位更偏向代码编辑器不完全是 Markdown 渲染器。如果你要做的是“带流式输出的 Markdown 编辑器”可以优先考虑这些底层能力而不是自己从零写。排查时先固定复现样例不要边看线上边改参数。没有稳定复现路径的问题最后都会变成玄学排查。4. 实操排查页面闪烁到底应该按什么顺序查4.1 先复现再用最小样例定位层级遇到页面闪烁第一反应不要是“换一个解析器”。先用最小样例固定复现路径。比如构造一个包含未闭合代码块、列表、表格的 Markdown 文件分别用三种方式渲染一次性完整解析每 100ms 追加一行解析每 100ms 追加整段然后整体替换 innerHTML。对比三种方式的输出。如果第一种正常第二种出问题那就是不完整状态判断有误。如果第二种正常第三种闪烁那问题就出在 DOM 重建和渲染策略上。如果第二种和第三种都闪烁而且闪烁位置在代码块高亮区域那就还要看高亮插件的初始化逻辑。这一步做完你基本能定位到“解析层、渲染层、布局层”中的哪一层。不要跳过。4.2 输入层先看分块边界和原始文本分块边界非常影响结果。如果是后端 SSE 返回的流式内容后端可能按字符、按词、按行、按语义块切分你拿到的 chunk 边界不一定与 Markdown 块边界对齐。甚至同一个返回内容不同次请求切分位置不同前端很难通过状态机预测所有情况。排查时先记录日志每次追加 chunk 时把 chunk 内容、当前 buffer、当前状态机状态输出到一个调试面板。重点看是否出现过孤立的围栏开始标记是否出现过“表格已经结束但状态机还认为在表格内”的情况是否出现过换行符被拆到两个 chunk 里的情况比如第一个 chunk 是def f():第二个 chunk 是\n print(...)。输入层如果就不干净后面解析层怎么改都会很痛苦。合理的做法是后端尽量按“行”或“语义块”发送前端再做兜底拼接。4.3 解析层解析器是不是每次都从零开始很多“流式解析慢”的根源是每次都拿“截至目前的所有原始文本”重新交给 marked.js 全量解析。内容短的时候没问题输出到 3000 行时每次全量解析的耗时就会明显增加。排查解析层时先问你三句话解析函数接收的是完整文本还是只接收增量文本每次解析后是复用上一次的 AST 做增量更新还是推翻了重建解析结果是否缓存同一行内容在不同批次里有没有被重复解析如果三个答案都是否定的那问题已经基本定位了。你不需要变成一个“调参侠”而是要先改数据结构至少做到“只有新增内容才进入解析流程已稳定内容不重复解析”。如果你觉得状态机维护太麻烦可以退一步把内容按“块”做缓存比如按空行切块。每个块只在完成时解析一次块与块之间互不影响。虽然不如真正的增量解析但比每次全量 parse 好很多。4.4 渲染层DOM 重排和内部高亮解析结果稳定后页面还在闪那就必须看渲染层。最简单的性能测试方法打开浏览器 DevTools 的 Performance录制一次流式输出过程观察每次更新是不是都在做大量布局计算。如果每次innerHTML赋值都触发整段 layout那么问题就在容器更新策略。常见优化使用insertAdjacentHTML或DocumentFragment追加新内容避免整体替换。给代码高亮插件设置防抖等代码块完整后再高亮一次不要每次变化都重建高亮。对图片、重资源内容做延迟加载或骨架占位。尽量避免在流式更新过程中读取offsetHeight、scrollHeight等强制布局属性。如果滚动位置很关键可以在更新前记录scrollTop更新后恢复。但更根本的解法是让已渲染区域不变化只更新新增区域。你前面的内容稳住了用户滚动时就不会觉得页面在闪。4.5 记录和验证把每次输出留给日志最后不要靠眼睛判断“是不是不闪了”。保存一份调试日志记录每次调用的当前 chunk 原文原始 buffer 长度状态机状态围栏是否开启、是否在表格内渲染区域是增量追加还是全量替换页面滚动位置变化高亮插件是否触发用日志构成一条可回放的时间线每次改动后重新跑一遍样例对比时间线中的关键节点。这个习惯看起来有点重但对于流式渲染这种交互密集、状态多变的问题非常有效。一旦日志回放能稳定复现某个闪烁点你就有办法试验不同修复方案。5. 什么时候别自己写流式解析或者应该换一种解耦方式5.1 适合自己接管解析的场景自己写流式解析适合内容结构相对可控、不需要覆盖完整 Markdown 标准的场景。典型场景包括大模型聊天窗口内容主要是普通段落、代码块、列表、加粗。内部工具里的实时日志预览只需要把日志转成简单的 Markdown 样式。已经和后端约定好按语义块输出的系统比如一行一传。在这些场景里你可以用状态机处理最常见的几个块不需要完整实现 CommonMark 规范。这样实现成本低维护也容易。5.2 不适合自己接管的场景如果你要做一个面向所有用户、输入内容不可控的通用 Markdown 编辑器或者用户可能在里面写嵌套表格、脚注、自定义容器、Mermaid 流程图那还是优先考虑成熟方案而不是在流式解析器里自己造轮子。原因很简单Markdown 语法边界很宽。你以为只需要处理和#结果用户给你贴了一段嵌套引用加任务列表还夹杂着 HTML block。你的状态机会越写越长边界情况越来越难处理最后变成一个比解析器本身更复杂的项目。而且这些复杂结构的“不完整状态”很难判断。比如嵌套的 blockquote一个可以连续出现什么时候算结束遇到空行才算那如果空行也属于 blockquote 内部呢这类逻辑如果全自己做测试成本会非常高。5.3 SSE 聊天预览的特殊取舍如果你只是做 SSE 聊天预览其实不必追求“渲染结果和最终完整文档完全一致”。用户在看大模型输出时关注的是内容本身而不是页面是否能在每个瞬间都呈现最终排版。你可以接受一个小延迟等一个段落或一个代码块完成后再更新。这也是很多聊天产品没有自己做流式 Markdown 解析而是让前端用“每隔一段时间截取全文重新渲染 滚动锁底”的原因。这个方案不优雅但在大多数聊天场景里够用。它的问题是当输出内容很长、频率很高时频繁重绘会带来性能问题。所以我更建议在聊天场景中用“按行 时间批次渲染”而不是每个 token 都更新。如果只是临时预览完全可以接受 200300ms 的延迟没必要在“每个字都实时渲染”上死磕。5.4 长期维护需要补的工程能力如果你决定自己维护一套流式 Markdown 渲染层长期看至少要补上这几块能力状态机单元测试覆盖代码块、列表、表格、行内样式、HTML block。性能基准脚本固定一个 5000 行文本测试追加、重渲染、高亮耗时。日志链路能把线上某个会话的流式输出回放出来。异常兜底当状态机判断出错时优先切回“整段纯文本”而不是让页面崩溃。可降级策略如果用户浏览器性能不好自动降低刷新频率关闭高亮。这些听起来不性感但决定了这个功能是能上线后稳定用一年还是上线两周就被人改回“全量 parse”。6. 给你一套判断框架流式 Markdown 需求该怎么拆6.1 先回答五个问题面对一个“流式 Markdown 渲染”需求可以先回答下面五个问题再做方案选型内容源是流式分块返回还是一整段文档用户需要实时看到每个新增 token还是可以接受几百毫秒的延迟内容里最常见的高频结构是什么代码块表格列表还是普通段落页面是否需要在流式过程中保持可编辑、可滚动、可复制内容会不会非常长比如在未来某个时间点出现几万字。回答完这五个问题方案基本就出来了。如果第 1 题答案“流式分块”第 2 题“低延迟”第 3 题“代码块为主”那你自己写一个状态机 增量渲染是合理的。如果第 5 题“大概率很长”那最好直奔虚拟滚动或成熟的文档编辑框架。如果第 2 题答案是“可以接受延迟”那用防抖 全量重建也没太大问题只是要控制频率和性能。6.2 再根据答案选方案可以粗略分成四类需求特征推荐方案实现成本短文本、低延迟、普通段落带状态的行级解析 增量追加低长文本、代码块多、低延迟状态机 虚拟滚动 全文缓存高长文本、可接受延迟防抖批量更新 全量解析低高亮、编辑、复杂格式使用成熟编辑器底层能力不自研高但更稳这里没有绝对最优方案关键是你愿意为“体验”和“维护成本”付出多少。我自己在项目里的经验是如果需求方没有明确要求“每个字都要实时渲染”我先做低延迟阈值 稳定刷新频率如果后来真的出现代码块闪烁再专门补状态机而不是一开始就把复杂度拉满。6.3 回到面试现场现在再看面试官的问题“那代码块输出一半时页面闪成什么样了” 它其实是在提醒候选人流式渲染不能只看“解析”还要看“结构不完整时的视觉稳定性”。如果候选人能接着说出“未闭合代码块需要等闭围栏或安全展示页面更新要增量追加滚动位置要锚定”这场面试基本就有戏了。这个问题也很有现实价值。毕竟现在很多产品都在接入大模型流式输出Markdown 预览早就不是文档编辑器的专属需求了。你不需要一定要从零实现一个流式解析器但至少得理解流式渲染真正考验的不是解析能力而是对“不完整状态”的容忍能力。把这一点想清楚无论面试还是实践都不会被“每段 parse 一下”这种表面方案困住。
返回列表