ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

流式Markdown渲染:如何解决代码块闪烁与半截语法问题

流式Markdown渲染:如何解决代码块闪烁与半截语法问题 “候选人用 marked.js 每段 parse 一下。面试官那代码块输出一半时页面闪成什么样了” 这个面试场景最近在不少 AI 应用团队里反复出现。说实话每次有候选人提到流式 Markdown 渲染我第一反应也是先问代码块因为这个问题几乎是流式渲染的照妖镜能答上来的人是真的理解流式输出答不上来的人多半只是调过几个 API还没遇到过真实用户对着闪烁页面截图吐槽的场景。这道题的陷阱在于候选人只回答了“怎么做”没回答“对不对”。marked.js 是一个典型的全量文本解析器它的输入应该是一份完整的 Markdown 源码而 LLM 流式返回的内容只是一份完整源码的前缀。用半截代码块、半截表格去喂 marked解析器只能给出一个半成品 DOM。你再把这些 DOM 往页面上一铺结果就是反引号裸奔、表格闪现、滚动位置乱跳、高亮忽明忽暗。这篇文章会把这个问题拆开讲清楚。你会看到三个层级的解决方案先用防抖缓解闪烁再用块级状态机解决代码块的半截语法问题最后组合出一个可以用于生产环境的轻量级流式渲染器。读完你能带走三样东西一份可复制的实现代码一套面试追问时能兜住底的设计思路以及生产环境里那些别人踩过但你不用再踩的坑。1. 面试官真正在考察什么先还原一下现场。候选人回答“用 marked.js 每段 parse 一下”这句话本身不是完全错误——思路是有的而且确实能跑。但面试官紧接着问“代码块输出一半时页面闪成什么样了”这个问题背后考察的是三层能力。第一层是否理解解析器的输入要求。marked.js、markdown-it 这类库的工作方式是把一整段 Markdown 源码解析成 HTML。它们内部有行级扫描、块级匹配、token 生成、HTML 拼接等步骤前提是源码的结构完整。假设 LLM 正在输出一段js 开头的代码块你在这个时刻调用 marked.parse输入只有孤零零的三个反引号加“js”两个字。这三个反引号不是一个合法围栏解析器只能把它们当成普通段落文本处理于是页面上出现一行裸露的反引号。第二层是否理解增量数据流的特征。AI 对话接口通常通过 SSEServer-Sent Events或 WebSocket 推送内容每个 chunk 可能只有几个 token也可能是一整句话。这些 chunk 连在一起才是完整答案但前端拿到的每一帧都是“未完成”的。处理增量数据流的核心能力是识别“哪些片段可以安全渲染、哪些片段必须等待完整”而不是拿到什么就渲染什么。第三层是否对用户体验有敏感度。很多候选人没想过一个细节如果你把 marked.parse 的结果通过 innerHTML 直接塞进容器那么每收到一个 chunk整棵 DOM 树都会被销毁重建一次。用户正在阅读上一句话页面突然刷新滚动位置回到顶部光标跳到末尾浏览器的高亮状态全部丢失。这种页面不是“能用”而是“能看但非常难受”。所以面试官真正想听的回答不是“我怎么调用 marked”而是“我怎么处理半截语法和增量渲染之间的关系”。这两个问题搞懂了代码块、表格、列表这些具体问题都只是状态机里的一行判断而已。2. 为什么直接 marked.parse 会闪烁要理解闪烁的本质先得知道 marked.js 面对不完整输入时到底做了什么。Markdown 的块级语法有很强的“开启/闭合”特征代码块用三个反引号围栏包裹表格需要表头、分隔行、数据行三段结构引用块用行首的“”标记连续多行。这些语法在设计时是给人写的人写文档时会一口气写完整一段但 LLM 流式输出不会遵守这个节奏。以代码块为例LLM 实际输出顺序通常是先输出三个反引号和语言名再逐行输出代码内容最后输出三个反引号闭合。如果前端每收到一个 chunk 就调用一次 marked.parse会出现下面这个进程第一次渲染时输入是js未闭合marked 把它当普通文本页面出现一行反引号。第二次渲染时输入是js 加几行代码但是仍然没有闭合围栏marked 依然把整个区域当普通段落处理代码缩进、换行全部失效。第三次渲染时闭合围栏才出现marked 终于识别出这是一个代码块整个区域的结构猛然从“普通文本”变成“代码块”。这个过程里页面上的内容从裸露反引号到错乱文本再到完整代码块DOM 节点每一次都完全不同于是用户看到的就是闪烁。代码块只是最严重的情况。表格的闪烁更隐蔽Markdown 表格必须同时包含表头行、分隔行、数据行LLM 输出表格时经常先给表头再给分隔线然后才逐行给数据。如果你在只输出表头和分隔线时就调用 parsemarked 会认为这不是一个合法表格直接不渲染页面闪现一个空白区域。等下一行数据到达表格突然出现视觉上就像页面表格区域在“闪进闪出”。除此之外innerHTML 全量替换还会连累代码高亮。假如你在 parse 之后调用了 Prism 或 highlight.js第一次渲染给代码加了高亮类名下一次 chunk 到达后整个 innerHTML 被替换高亮类名全部消失代码重新变成普通颜色。高亮状态被反复清空用户会以为代码高亮功能有 bug。从这里可以提炼出一个关键判断流式渲染的问题不是“解析速度不够快”而是“解析器的工作方式与流式输入不匹配”。marked.js 期望输入是完整文档而流式输出天然只能给你前缀和增量。你需要做的是在标记解析器之前增加一个“状态感知层”专门处理半截语法。3. 方案一防抖加全量渲染先解决闪烁最保守的做法不改变解析逻辑只是改变调用频率。把所有增量文本先累积到一个缓冲区每收到一个 chunk 不清空而是设置一个定时器等 150 到 200 毫秒内没有新 chunk 到达时才调用一次 marked.parse把整个缓冲区渲染到页面上。这个方案能解决两个问题一是把频繁的小渲染合并成低频的大渲染二是减少了 DOM 销毁重建的次数。原本一秒触发二十次渲染现在可能只触发五次页面观感会好很多。但必须说清楚防抖只是拖延了问题没有根治问题。代码块半截语法的情况依然存在只是出现频率降低了。如果 LLM 连续输出几十秒每几百毫秒都会有一个新的未闭合代码块进入缓冲区每次渲染时 marked 都会先错一遍再等闭合后纠正一遍用户依然能感知到闪烁。更糟的是随着文本积累每次全量 parse 的时间会线性增长长文本场景下页面会越来越卡。防抖方案最适合两类场景一类是短回答渲染比如内部工具里只渲染几十行 Markdown 的提示信息另一类是低频流式输出适合快速验证原型。它最大的价值是成本极低几分钟就能接好。下面是一个最小实现。// 文件路径debounce-render.js const container document.getElementById(markdown-container); const buffer { text: }; let timer null; function onChunk(chunk) { buffer.text chunk; clearTimeout(timer); timer setTimeout(() { container.innerHTML marked.parse(buffer.text); // 如果有代码高亮插件建议放在这里执行 // Prism.highlightAllUnder(container); }, 160); } // 模拟 LLM 流式输出每个 chunk 间隔 200ms const fakeStream [ js, \nconst a 1;, \nconsole.log(a);, \n ]; fakeStream.forEach((chunk, index) { setTimeout(() onChunk(chunk), (index 1) * 200); });注意这里用 setTimeout 模拟流式输出实际项目里这个回调来自 SSE 的 onmessage 事件。每次收到文本先把文本追加到 buffer再重置定时器最后才渲染整个 buffer。试着跑一下这段代码你会看到第二次渲染时缓冲区里的代码块没有闭合。这时候 marked.parse 的输出大概率是错乱的第三次渲染虽然最终成型但中间那一下已经把页面闪到了。知道这个方案的边界你才能在面试里说出“防抖只能减少闪烁不能解决半截语法”这个结论。4. 方案二按块级状态切分解决代码块闪烁要根治代码块闪烁必须做预处理在调用 marked.parse 之前自己先维护一个 Markdown 块级状态机。这个状态机的任务只有一个判断当前文本流是否处于未闭合代码块中。为什么优先处理代码块因为代码块是流式渲染里破坏力最大的情况。列表、引用、标题这些块级语法即使没闭合渲染结果顶多是少一个符号、多一个缩进不会让整段文本崩溃。但代码块不一样三个反引号出现与否直接决定后面几十行代码是作为代码块展示还是作为普通段落展示。一个判断错误整片区域就乱了。具体做法是扫描缓冲区中的所有行逐行判断是否开启或关闭了围栏最后如果发现围栏仍然处于开启状态就在源码末尾补一个闭合围栏。这样喂给 marked.js 的源码在结构上永远是完整的。下面是一个代码块围栏保护函数。// 文件路径code-fence-guard.js function ensureClosedCodeFence(src) { const lines src.split(\n); let inCode false; let fenceChar ; let fenceMinLen 3; for (const line of lines) { const trimLine line.trim(); // 匹配以 或 ~~~ 开头的围栏行 const fenceMatch trimLine.match(/^({3,}|~{3,})/); if (fenceMatch) { const char fenceMatch[1][0]; const len fenceMatch[1].length; if (!inCode) { // 遇到一个围栏进入代码块状态 inCode true; fenceChar char; fenceMinLen len; } else if (char fenceChar len fenceMinLen) { // 遇到同类型且长度足够的围栏退出代码块状态 inCode false; } } } if (inCode) { return src \n\n; } return src; }这个函数不会修改源码只是在检测到代码块未闭合时补上一个闭合围栏。把它放到渲染逻辑里调用顺序就变成了container.innerHTML marked.parse( ensureClosedCodeFence(buffer.text) );这样做的好处是中间态永远是结构完整的代码块。当 LLM 只输出了 js 和两行代码时页面渲染的是一个带围栏的、暂时闭合的代码块下一行代码到达后整个代码块重新打开再闭合虽然 DOM 还是会重建但结构始终是正确的代码块不会出现反引号裸奔的问题。这个方案是面试里的加分项。候选人能说出“先识别块级状态再做有条件渲染”说明他不是简单地调库而是真正理解了解析器对输入完整性的要求。代价是未闭合代码块在流式过程中会呈现出一个“被截断但结构完整”的样式视觉上会有轻微跳动但远好过整页乱掉。5. 方案三轻量级流式渲染器的完整设计前两个方案解决的是代码块问题但一个生产级渲染器还需要考虑表格、引用、列表这些块级语法的中间态。我会把完整的流式渲染器拆成四层缓冲区、状态机、安全渲染层、结束校准层。缓冲区用于累积所有源文本这是流式渲染的基础。状态机负责识别代码块、表格、引用、列表等块级语法的完成状态。安全渲染层负责把未完成的块级语法转成不会让页面崩坏的形式要么补结构要么转义成纯文本。结束校准层负责在流结束后用一次全量 parse 替换中间态并做最终高亮和清理。先明确一个核心认知流式渲染不是要比 marked.js 更聪明而是要比它更保守。宁可让未闭合的内容显示成普通文本也不能让半个代码块撕裂页面。只要中间态的结构是完整的页面就不会闪烁。表格是最难处理的一类块。Markdown 表格需要同时包含表头、分隔行和数据行少一行都会导致表格不渲染。LLM 流式输出时经常先给表头再给分隔线然后才逐行给数据。假如你在表头和分隔线出现时就渲染marked 会认为这不是合法表格页面闪现一个空白区域。等到数据行逐条补齐表格才突然出现视觉上就是表格区域在不停闪。此时比较稳妥的策略是检测到当前处于表格上下文但表格结构不完整时暂时把整个表格区域按纯文本输出直到分隔行和数据行连续出现才交给 marked 解析。这样表格会整体出现一次而不是闪现多次。下表可以作为块级状态机设计的参考。块类型开启标记可能的未完成状态中间态处理策略代码块 或 ~~~只写开头未写闭合围栏补闭合围栏保留代码块结构引用块行首 连续引用未结束未完成部分按逐行转义避免整体消失有序/无序列表行首 - * 1.列表项可能继续追加保留已出现列表项未出现部分不再构建表格表头与分隔行分隔行缺失或数据行不全表格完整前按纯文本输出标题行首 #标题自然结束于换行风险低只需做行边界判断这张表不用追求完美。面试或项目设计时能说出“代码块补围栏、表格等完整、引用逐行处理”这三个策略已经足够证明你的系统设计能力。6. 完整代码示例把上面思路拼起来把防抖、状态机、安全渲染层组合成一个类是最适合放入业务代码的形态。下面是一个 StreamMarkdownRenderer 的完整实现。// 文件路径stream-markdown-renderer.js class StreamMarkdownRenderer { constructor(container, options {}) { this.container container; this.delay options.delay ?? 160; this.parser options.parser ?? ((src) marked.parse(src)); this.buffer ; this.timer null; this.isFinished false; this.onPostRender options.onPostRender || null; } append(chunk) { if (this.isFinished) { return; } this.buffer chunk; clearTimeout(this.timer); this.timer setTimeout(() this.render(), this.delay); } finish() { this.isFinished true; clearTimeout(this.timer); this.renderWith(this.buffer); } render() { if (this.isFinished) { return; } this.renderWith(this.prepareSource(this.buffer)); } renderWith(source) { this.container.innerHTML this.parser(source); if (this.onPostRender) { this.onPostRender(this.container); } } prepareSource(src) { return this.ensureClosedCodeFence(src); } ensureClosedCodeFence(src) { const lines src.split(\n); let inCode false; let fenceChar ; let fenceMinLen 3; for (const line of lines) { const trimLine line.trim(); const match trimLine.match(/^({3,}|~{3,})/); if (!match) { continue; } const char match[1][0]; const len match[1].length; if (!inCode) { inCode true; fenceChar char; fenceMinLen len; } else if (char fenceChar len fenceMinLen) { inCode false; } } return inCode ? src \n\n : src; } }为什么用类而不是函数因为流式渲染需要跨多个回调维护 buffer、timer、isFinished 这些状态封装成类以后每个流式区域只需要创建一个实例调用 append 和 finish 两个方法就行业务侧非常干净。再看业务侧的调用方式。// 文件路径main.js const renderer new StreamMarkdownRenderer( document.getElementById(md-root), { delay: 120, onPostRender: (container) { // 这里可以执行代码高亮注意频率不能太高 // Prism.highlightAllUnder(container); } } ); // SSE 回调来自 AI 对话接口 const sse new EventSource(/api/chat-stream); sse.onmessage (event) { renderer.append(event.data); }; sse.onclose () { sse.close(); renderer.finish(); };这个例子里的 onPostRender 是每次中间态渲染后的回调。代码高亮这种比较重的操作不建议每次渲染都做更合理的做法是在 finish 之后统一执行一次。你也可以在 onPostRender 里做滚动位置恢复比如记录当前容器的高度渲染后再滚动到之前的比例位置。这段代码解决的是代码块闪烁和防抖两个核心问题。表格、引用这些块级语法可以在 prepareSource 方法里继续扩展思路是相通的先判断当前处于哪个块再决定是补结构还是转义输出。7. 流式渲染常见问题与排查思路真实项目里遇到的问题往往比面试题更细。下面整理了一份排查清单按影响程度从高到低排列。问题现象可能原因排查方式解决方案页面不断跳动滚动位置丢失每次渲染全量替换 innerHTML控制台检查 DOM 节点是否每次全部销毁重建降低渲染频率渲染后恢复滚动位置用状态机保护中间态代码块输出一半时反引号裸奔半截围栏被 marked 当普通文本打印缓冲区的源文本确认缺少闭合围栏使用 ensureClosedCodeFence 补围栏复制出来的文本多出 补围栏行为被用户复制到剪贴板对比 innerHTML 和浏览器选区的文本复制时从源文本读取或者给中间态用 data 属性标记代码高亮失效或闪烁全量替换清掉了 Prism 的渲染结果打断点观察 highlight 调用顺序高亮放在渲染最后重型高亮推迟到 finish 后长文本渲染越来越慢全量 parse 时间随文本长度线性增长在 render 里打 performance.now() 日志分块缓存已解析片段降低渲染频率必要时虚拟滚动表格区域闪现后消失表格未完成时 marked 不渲染表格打印源文本检查是否缺少分隔行表格完整前按纯文本输出页面直接白屏marked 解析异常或渲染超时查看控制台报错和缓冲区内容设置错误边界解析失败时用 pre 标签展示原始文本代码块问题是最常见的占比也最高。如果你排查时发现反引号裸奔第一步永远是把缓冲区里的源文本完整打印出来对比当前是否处于代码块状态。这一步几乎能定位八成问题。滚动位置丢失是第二个高频问题。全量 innerHTML 替换天然会重建 DOM除非你在渲染前记录容器滚动高度与视口高度的比例渲染后再恢复。这里不需要复杂计算一个 scrollTop 加 scrollHeight 的比例就能解决。还有一个容易忽略的问题是 XSS 安全。很多人默认 LLM 输出是可信的但 LLM 生成的内容本质上还是不可信文本。如果 LLM 被提示词注入诱导输出了 script 标签或 onerror 属性而你的代码直接把它塞进 innerHTML页面就有被攻击的风险。所以生产环境里marked.parse 的结果必须经过 DOMPurify 清洗再插入 DOM。8. 生产环境最佳实践面试能答出状态机基本已经过关。但真正把它推到生产环境还有几个细节值得注意。第一来源安全必须做。marked.js 官方早已废弃了内置的 sanitize 选项现在不能在解析器里依赖自带的过滤功能。推荐的做法是统一走 DOMPurify像这样const dirtyHtml marked.parse(src); const cleanHtml DOMPurify.sanitize(dirtyHtml); container.innerHTML cleanHtml;这个操作不会对性能造成明显影响但能让你的页面在遇到恶意输入时不至于直接沦陷。第二高亮策略要分层。流式过程中代码块还没写完高亮计算没有意义反而会带来额外性能开销。更好的做法是中间态只渲染暗色背景标明“这是一个代码块区域”等 finish 后统一调用 highlight.js 或 Prism 做全量高亮。如果回答很长高位代码块数量多可以把高亮任务拆成多个 requestIdleCallback 分组执行避免阻塞主线程。第三滚动和焦点要保护。聊天类产品的输入框通常不在渲染容器内部所以焦点不会因为渲染而丢失。但如果在 contenteditable 区域里做流式渲染每次 innerHTML 替换都会让光标跳到末尾。这种情况下建议把编辑区与渲染区分开或者干脆放弃编辑区内的实时渲染改成等用户停止输入后再渲染预览。第四性能监控要落地。给渲染器加一个简单的耗时统计每次 render 计算耗时。如果连续多次超过 50ms就应该考虑降级策略把 delay 从 160ms 提高到 300ms或者改为每 500ms 最多渲染一次流结束时强制全量渲染一次。宁可让用户看到内容延迟一点也不能让页面变成幻灯片。第五降级路径要明确。解析器不是永远可靠的marked 遇到异常输入也可能抛错。业务侧应该给流式渲染容器加一个错误边界如果解析失败用 pre 标签包裹原始文本展示保证用户至少能看到 AI 说什么而不是面对一片空白。第六多文档场景要隔离状态。如果一个页面同时有多个 AI 回答在流式渲染每个区域必须持有独立的 StreamMarkdownRenderer 实例不能共享 buffer 和 timer。否则两个回答的文本会混在一起出现匪夷所思的乱码。9. 总结与后续学习方向回到开头那道面试题。用 marked.js 每段 parse 一下不是错误答案但只是一个起点。真正的流式 Markdown 渲染器是在解析器前面加了一层状态感知层它知道哪些语法块还没写完然后在“等待完整”和“立即展示”之间做取舍。代码块补围栏、表格等完整、未完成块转义这几种策略本质上是同一种思路让中间态永远保持结构完整而不是去追求每帧都完美。如果你的项目正好在接入流式 AI 输出建议先用最简方案跑通链路缓冲区加防抖加代码块围栏保护加结束全量渲染。这四件事组合起来已经能覆盖大多数聊天类和生成类产品的渲染需求。等出现明显的性能瓶颈再考虑基于 markdown-it tokenizer 的增量渲染或者用 diff 算法对旧 DOM 做 patch 更新但那是另一个复杂度量级的话题了。流式渲染的核心思想可以浓缩成一句话对不确定的语法保持保守对确定的语法保持忠实。理解了这句话再去看 marked.js 的每段 parse、任何新的渲染方案你都能快速判断它到底靠不靠谱。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进