流式 Markdown 渲染:代码高亮与表格的增量解析工程
一、SSE 流式输出的重排闪烁:增量渲染的工程痛点
大模型应用在前端最直观的体验差异,往往体现在流式输出上。SSE(Server-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, '&') .replace(/</g, '<') .replace(/>/g, '>'); 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 Map<string, 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 `<pre><code class="streaming">${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 资料来源索引,并在发布前将具体来源贴到对应断言之后。