流式 Markdown 渲染代码高亮与表格的增量解析工程一、SSE 流式输出的重排闪烁增量渲染的工程痛点大模型应用在前端最直观的体验差异往往体现在流式输出上。SSEServer-Sent Events把回答按 Token 分片推送到浏览器前端逐字渲染给用户「思考中」的即时反馈。但当回答包含代码块、表格、嵌套列表这类块级结构时朴素的逐字追加会引发严重的重排闪烁。问题的根因在于 Markdown 的块级元素需要跨分片才能确定边界。一个代码块以三反引号开始但结束的三反引号可能在几百毫秒后才到达一个表格的列结构直到下一行 pipe 符到达才能确认。如果在每个分片到达时都做一次完整的 Markdown 重新解析与重新渲染浏览器需要销毁并重建整个 DOM 子树造成视觉闪烁与光标跳动代码高亮反复重算表格列宽反复抖动。更隐蔽的问题是性能衰减。完整重解析的复杂度随文档长度线性增长长回答在后期的每次分片都会触发 O(n) 的解析与渲染。在低端设备上这会导致流式输出后期明显卡顿首 token 延迟之后的吞吐反而下降。本文构建一套基于状态机的增量解析方案让块级元素按状态推进只对真正变更的块做重新渲染。二、块级元素的增量解析状态机核心思想是把 Markdown 流切分为「块block」序列每个块有独立的生命周期创建、累积、完成。解析器维护一个状态机根据当前分片内容推进块的状态。增量解析状态机分片到达时的状态推进 ----------- 分片匹配块起始 ----------- 块结束条件命中 ----------- | IDLE | ------------------- | BUILDING | ------------------- | CLOSED | | (等待块) | | (累积内容)| | (锁定) | ----------- ----------- ----------- | | ^ | 非块起始字符 | 分片未触发结束 | | v | | ----------- | -------------------------- | APPEND | -------------------------- | (累积文本)| 分片触发结束 -----------状态说明如下状态含义渲染策略IDLE等待新块起始不渲染缓冲文本BUILDING块起始已识别内容累积中占位渲染显示进行中状态APPEND普通段落文本累积增量追加不重建CLOSED块结束条件命中锁定后续分片不再修改关键设计点在于「块完成判定」。不同块的完成条件不同代码块的完成条件是匹配到结束三反引号表格的完成条件是连续两行无 pipe 符普通段落的完成条件是遇到空行或下一个块起始。状态机必须为每种块类型维护独立的完成判定函数。另一个关键点是「已闭合块不可变」。一旦块进入 CLOSED 状态后续分片绝不修改它。这条约束保证了已渲染的块不需要重新解析增量渲染的范围被严格限制在 BUILDING 与 APPEND 状态的块。配合 DOM 节点的稳定引用已闭合块对应的元素可以完全跳过 diff把更新开销压到最低。三、流式 Markdown 渲染器实现下面给出一个生产可用的流式渲染器核心实现包含分片状态机、块管理与增量 DOM 更新。// streaming-markdown.ts // 流式 Markdown 增量渲染器 // 设计目标块级增量更新、避免全量重排、代码高亮按需重算 type BlockType paragraph | code | table | heading; interface Block { id: number; type: BlockType; state: building | closed; raw: string; // 已累积的原始文本 html: string; // 已渲染的 HTML用于增量比对 el: HTMLElement | null; // 对应 DOM 节点用于直接更新 } // 块起始检测判断当前缓冲是否构成新块起始 function detectBlockStart(buf: string): { type: BlockType; consumed: number } | null { // 代码块三反引号开头 const codeMatch buf.match(/^(\w*)\n/); if (codeMatch) { return { type: code, consumed: codeMatch[0].length }; } // 标题1~6 个 # 开头 const headingMatch buf.match(/^(#{1,6})\s/); if (headingMatch) { return { type: heading, consumed: 0 }; } // 表格至少两行包含 pipe 符且第二行是分隔行 const lines buf.split(\n); if (lines.length 2 lines[0].includes(|) /^\|?[\s-:|]\|?$/.test(lines[1])) { return { type: table, consumed: 0 }; } // 默认段落 if (buf.trim().length 0) { return { type: paragraph, consumed: 0 }; } return null; } // 块完成检测判断当前块是否应该关闭 function isBlockComplete(block: Block, incoming: string): boolean { switch (block.type) { case code: // 代码块完成条件遇到结束三反引号 return /\n\s*$/.test(block.raw incoming); case table: { // 表格完成条件连续两行无 pipe 符 const lines (block.raw incoming).split(\n); if (lines.length 3) return false; const last lines[lines.length - 1]; const prev lines[lines.length - 2]; return !last.includes(|) !prev.includes(|); } case heading: // 标题完成条件遇到换行 return (block.raw incoming).includes(\n); case paragraph: // 段落完成条件空行或新块起始 return /\n\s*\n/.test(block.raw incoming) || detectBlockStart(incoming) ! null; } } // 简易 Markdown 内联渲染转义 加粗 行内代码 // 为什么不全量上 marked/remark增量场景下重型解析器难以细粒度控制 function renderInline(text: string): string { const escaped text .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;); return escaped .replace(/\*\*(.?)\*\*/g, strong$1/strong) .replace(/([^])/g, code$1/code); } class StreamingMarkdownRenderer { private blocks: Block[] []; private current: Block | null null; private buffer ; private nextId 0; // 高亮缓存避免 building 阶段每次追加都重算高亮 private highlightCache new Mapstring, string(); constructor(private container: HTMLElement) { // 容器内容由渲染器独占管理外部不应直接修改 container.innerHTML ; } // 分片到达入口状态机驱动块生命周期 feed(chunk: string): void { this.buffer chunk; while (this.buffer.length 0) { if (!this.current) { const start detectBlockStart(this.buffer); if (!start) { // 未构成块起始保留在缓冲等待更多分片 break; } this.current { id: this.nextId, type: start.type, state: building, raw: this.buffer.slice(0, start.consumed), html: , el: null, }; this.buffer this.buffer.slice(start.consumed); this.blocks.push(this.current); this.createBlockElement(this.current); } // 把缓冲追加到当前块 this.current.raw this.buffer; this.buffer ; if (isBlockComplete(this.current, )) { this.current.state closed; this.renderBlock(this.current); this.current null; } else { // building 状态下也渲染给用户即时反馈 this.renderBlock(this.current); break; } } } private createBlockElement(block: Block): void { const el document.createElement(div); el.className md-block md-${block.type} md-building; el.dataset.blockId String(block.id); this.container.appendChild(el); block.el el; } private renderBlock(block: Block): void { if (!block.el) return; const html this.renderBlockHtml(block); if (html block.html) return; // 内容未变化跳过 DOM 更新 block.html html; block.el.innerHTML html; // building 转为 closed 时更新类名触发样式过渡 if (block.state closed block.el.classList.contains(md-building)) { block.el.classList.remove(md-building); block.el.classList.add(md-closed); } } private renderBlockHtml(block: Block): string { switch (block.type) { case code: return this.renderCodeBlock(block.raw); case table: return this.renderTable(block.raw); case heading: { const level block.raw.match(/^(#{1,6})/)?.[1].length ?? 1; const text block.raw.replace(/^#{1,6}\s/, ).trim(); return h${level}${renderInline(text)}/h${level}; } case paragraph: default: return p${renderInline(block.raw.trim())}/p; } } private renderCodeBlock(raw: string): string { // 解析语言标识与内容 const match raw.match(/^(\w*)\n([\s\S]*?)(\n)?$/); if (!match) { // 未闭合的代码块显示进行中状态 const partial raw.replace(/^\w*\n/, ); return precode classstreaming${this.escape(partial)}/code/pre; } const lang match[1]; const code match[2]; // 高亮缓存同一代码块在 building 阶段多次追加缓存避免重算 const cacheKey ${lang}:${code}; if (this.highlightCache.has(cacheKey)) { return this.highlightCache.get(cacheKey)!; } const highlighted this.applyHighlight(lang, code); // 缓存上限保护避免长会话下缓存无界增长 if (this.highlightCache.size 256) { const firstKey this.highlightCache.keys().next().value; if (firstKey) this.highlightCache.delete(firstKey); } this.highlightCache.set(cacheKey, highlighted); return pre>// 接入 EventSource 流式输出 async function streamChat(url: string, renderer: StreamingMarkdownRenderer) { const res await fetch(url, { method: POST }); if (!res.ok) throw new Error(chat request failed: ${res.status}); const reader res.body!.getReader(); const decoder new TextDecoder(); try { while (true) { const { done, value } await reader.read(); if (done) break; const text decoder.decode(value, { stream: true }); // 按 SSE 协议解析 data: 行 for (const line of text.split(\n)) { if (line.startsWith(data:)) { const payload line.slice(5).trim(); if (payload [DONE]) continue; try { const json JSON.parse(payload); const delta json.choices?.[0]?.delta?.content ?? ; if (delta) renderer.feed(delta); } catch { // 非 JSON 行如心跳注释跳过不打断流 } } } } renderer.end(); } catch (err) { // 网络中断时也要关闭当前块避免半截块永远 building renderer.end(); throw err; } }四、增量解析的内存与一致性代价增量解析不是银弹。第一类代价是内存占用。为了支持增量比对与高亮缓存渲染器需要在内存中维护所有块的原始文本与渲染结果。一个长回答可能包含数十个块每个块的原始文本与 HTML 都驻留内存。在移动端这可能成为隐性内存压力。缓解方式是对已 CLOSED 的块释放原始文本只保留 HTML 与 DOM 节点但代价是失去重新渲染能力无法应对主题切换。下表给出两种保留策略的取舍。策略内存占用主题切换错误恢复全量保留 raw高支持支持closed 后释放 raw低需重解析不可逆第二类代价是一致性风险。Markdown 规范允许某些结构跨行回溯修正例如 Setext 标题下一行 会让上一行变成标题。增量状态机一旦把上一行判定为段落并关闭后续到达的下划线无法回退修正。这类边界情况需要在状态机中预留「前瞻缓冲」对可能回溯的结构延迟一个分片再判定但这会引入额外延迟。实践中通常接受这类边界不完美换取主流场景的流畅体验。第三类代价是错误恢复。流式输出中途如果出现解析异常如畸形表格增量状态机可能卡在某个 building 状态无法推进。工程上必须设置超时与兜底当某个块 building 时间超过阈值时强制转为 closed 并按段落降级渲染。这个兜底会牺牲少量格式正确性但保证流不会卡死。阈值设定需要根据目标体感调整过长导致用户长时间看到进行中占位过短则频繁误判。禁用场景方面增量解析不适合以下情况需要严格遵循 CommonMark/GFM 完整规范的场景增量状态机无法覆盖所有边界文档需要导出为静态 HTML 离线阅读的场景增量机制的开销没有收益一次性解析更简单以及回答预期都很短如纯文本问答的场景增量状态机的复杂度不必要直接追加文本即可。五、总结落地建议分两步推进。第一步在普通段落与代码块两种块类型上验证增量状态机确认重排闪烁消除与吞吐稳定。第二步逐步接入表格、列表、引用等更多块类型每接入一种都补充对应的完成判定函数与边界测试。技术要点上块级增量解析的核心是「已闭合块不可变」与「building 块即时渲染」两条约束高亮必须带缓存否则 building 阶段的反复重算会拖垮流式吞吐流中断时必须显式关闭 building 块避免半截块残留导致后续渲染错乱对 Setext 标题等回溯性结构接受边界不完美以换取主流场景的流畅性。流式 Markdown 渲染的收益是消除重排闪烁与稳定长文档吞吐代价是内存占用与边界一致性的妥协选型时需要根据回答长度分布与目标设备内存综合判断。资料说明本文中的协议、版本、性能、成本和行业趋势应以可核验的一手资料为准。未标注统计口径的比例、时间表和预测仅作工程讨论不应视为行业事实。可参考 0730 资料来源索引并在发布前将具体来源贴到对应断言之后。