前几天有个同学面试回来跟我吐槽,被问了一个看着很基础的问题:“大模型流式输出 Markdown 时,标签被截断了,你们前端怎么处理?直接重新让 marked 全部渲染,行不行?”他第一反应是“那我把完整字符串拼起来重新 parse 一次不就行了”,结果面试官追问了一句“如果输出正好卡在```js这里呢”,他当场愣住了。
这个场景其实现在特别常见。ChatGPT、各类 AI 对话、文档生成工具,输出都是一段一段往外蹦的,网络包再一分片,到前端手里的 Markdown 大概率是“半截”的:代码块没闭合、链接少了右括号、表格只有表头加两行。这种不完整文本如果直接丢给 marked,轻则样式错乱,重则整页布局被一个没闭合的<div>带崩。这篇文章我就聊聊这个问题的本质,以及我在实际项目里用的处理方案,顺便回答面试官那句“直接全部渲染行不行”——看完你自己就有答案了。
1. 问题从哪来:流式Markdown的“半截标签”困境
1.1 大模型为什么总是吐出半截Markdown
先搞清楚数据是怎么到前端的。大模型生成文本是按 token(可以粗略理解成“字”或“词”)逐步产生的,服务端拿到这些 token 后,一般不会攒成一篇完整文章再推给你,而是走 SSE、WebSocket 这类通道,一有结果就往外发。再加上网络传输的分片、服务端的缓冲策略,前端可能每次收到几行、几个字,甚至刚好卡在一个 Markdown 语法的正中间。
我随手写个例子。假设最终要输出的是下面这段 Markdown:
# 标题 这是正文 ```js const a = "hello";但因为流式传输,前端拿到的可能长这样: - 第 1 帧:`# 标题` - 第 2 帧:`\n\n这是正文\n` - 第 3 帧:`\n\n\`\`\`js\n` - 第 4 帧:`const a = "hello` - 第 5 帧:`";\n\`\`\`\n` 你看第 4 帧,内容是一段代码块的第一行,但代码块的开头围栏 ` ```js ` 已经出现了,闭合围栏还没来。如果这时候直接调用 `marked.parse`,它拿到的就是一个“还没有结束的代码块”。这就像作文写到一半,老师拿去批改,看到满篇病句就批了一堆红叉,但明明后半段还没写出来。 关键点是:marked 是完整文档解析器,不是流式解析器。它没有增量接口,也不具备“猜后面的内容会是什么”的能力。你喂给它什么,它就只能按当前字符串去解析。 ### 1.2 标签截断后会怎样:一个能复现的坏例子 与其空讲,不如直接看 markdown 被截断时 marked 的行为。先看最典型的代码围栏问题: ```js const { marked } = require('marked'); // 模拟流式中途:代码块刚开了一个头 console.log(marked.parse('```js\nconst a = "'));marked 的处理结果会是一段<pre><code>,但里面的内容是不完整的,而且它已经“认定”当前处于代码块状态。如果后续文本明明是想说别的内容,但因为围栏没闭合,这些内容会被继续当作代码吞进去,用户看到的就是一大片样式奇怪的等宽字体。
再来看 HTML 标签截断,这个更吓人:
const { marked } = require('marked'); // 模拟一个流式中途:HTML 标签没闭合 console.log(marked.parse('<div class="content">\n\n你好'));因为 Markdown 规范里,这类原始 HTML 块会被原样输出,marked 不会帮你补一个</div>。浏览器看到这个未闭合的<div>,会把后续所有 DOM 都塞进这个层级里,轻则样式错位,重则整个页面的点击区域、滚动容器全部乱掉。
下面我用表格整理一下常见截断场景和最终表现:
| 未闭合片段 | marked 直接输出的结果 | 用户会看到的问题 |
|---|---|---|
```js未闭合 | 后续内容被吞进<pre><code> | 整段全是等宽字体,高亮失效 |
<div class="a">没有闭合 | 原样输出未闭合标签 | 布局嵌套错乱,样式崩坏 |
[说明](https://example.com少右括号 | 链接没有被解析成<a> | 文本原样展示,链接点不了 |
**重点**还没输出完 | 加粗标记正常输出,样式丢失 | 文本不该加粗的部分闪烁 |
| 表格最后只到分隔行 | 表格整体解析失败或提前结束 | 表格区域反复变化,列数不对 |
这些都是我在调试流式输出时真实遇到过的现象。只要不做处理,直接把半截字符串给 marked,问题必然存在。
2. “直接全部重新渲染”为什么是下下策
2.1 marked的解析机制和流式场景天然冲突
有些人会想:每次收到新 chunk,我就把累积的完整文本重新给 marked parse 一次,不就能拿到最新结果了?这个思路听起来很“暴力直接”,但它有两个层面上的问题。
第一个层面:它根本没有解决标签截断。因为你每次拿到的“累积文本”依然是不完整的。只要当前输出刚好停在代码块内部,你重渲染多少次,输出的 HTML 都还是残缺的。把半截字符串重复倒进解析器,只会得到重复的半截结果,不会自动长出闭合标签。
第二个层面:marked 的解析机制是为“全量输入”设计的。它内部要先做词法分析(lexer),把整段 Markdown 拆成 token 树,再做 DOM 渲染。这个过程本身很快,但它是“无状态”的——你不能让 marked 记住上次 parse 到哪了,下一次接着往下走。所以每次更新都等于从零开始解析。
2.2 全量重渲染的三个真实代价
就算我们先用某种办法把半截标签补全了,再走“全部重渲染”路线,工程上依然不推荐。我在项目里踩过的坑主要有三个:
第一,输入框和滚动位置疯狂跳动。如果你的页面里除了 Markdown 渲染区,还有输入框、选择框、聊天记录列表,那全量重建 DOM 会导致用户正在操作的输入框失焦,或者滚动位置一下子被顶到顶部。尤其在流式输出过程中,如果你用innerHTML = marked.parse(text)去替换整个容器,每来一个 chunk 就重建一次,用户根本没法正常阅读。
第二,图片和音视频会重复加载。我遇到过一个场景,用户生成的 Markdown 里嵌了一张图片。流式输出过程中如果每帧都全量替换 DOM,浏览器会反复加载同一张图片,流量和体验都崩了。
第三,性能是 O(n²) 级别的。假设最终文本长度是 n,每次新增 1 个字符都全量解析一次,那所有解析工作加起来大概是 1 + 2 + ... + n 的量级,约等于 n²/2。写几百字的聊天回复没问题,但如果 AI 输出一篇几万字的长文,前端会越来越卡。我在低端测试机上试过,文本到 5 万字符左右时,每来一个新 token,页面都要卡几百毫秒。
2.3 什么时候可以勉强用一下
也不是绝对不能全量重渲染。如果满足以下几个条件,它可以作为 demo 或快速原型的兜底方案:
- 内容很短,比如单次回复不超过几百字。
- 交互要求低,不需要保留选择状态和滚动位置。
- 并发量小,只有自己本地调试用。
- 而且,必须已经通过缓冲区补全了不完整标签。
所以面试官那个问题,如果让我答:“直接全部重建”不是不能跑,但它解决不了标签截断,也没有解决重复解析和 DOM 重建带来的问题。它只是把问题从“解析阶段”推到了“渲染阶段”,而且代价更高。
3. 治本方案:缓冲分段 + 闭合检查
3.1 核心思路:让“完整块”先走,“半截块”等待
真正能解决截断问题的思路,是给 marked 加一个“上帝视角”缓冲层。每次收到 chunk,先不急着渲染,而是把数据放到缓冲区里,然后判断缓冲区里的 Markdown 是否已经“安全”。所谓安全,是指当前所有跨行语法都已经闭合,没有悬空的代码围栏、HTML 标签、链接括号或者行内标记。如果安全,就把这一整段交给 marked 解析,然后清空缓冲区;如果不安全,就继续等后续 chunk。
这个思路很像你在和别人聊天时,对方话说到一半,你知道他还没说完,不会急着打断他回答,而是等他停顿到一个完整语句结束,再接过话头。Markdown 的“完整语句”不是按句号划分,而是按块级语法边界划分。
3.2 安全切分点怎么找
要判断“安全”,至少要管住这几类跨行语法:
第一,代码围栏。Markdown 里的```和~~~都是成对出现的。如果整个字符串里代码围栏的数量是奇数,说明当前还处于代码块内部,绝对不能切分。
第二,HTML 标签。用<div>这类标签时,如果开标签已经出现,闭合标签还没出现,就得继续等。自闭合标签和<img>、<br>这类特殊标签不用管。注意 HTML 注释里可能有>,严谨的正则要处理,但作为 demo 可以先按简单逻辑来。
第三,链接和图片。[文本](url)这种写法,如果右括号还没出现,也不能认为这一行已经结束。虽然链接一般不会跨多行,但流式输出确实可能把一个完整链接在中间截断。
第四,行内代码和强调标记。一个反引号、两个星号也可能在行尾被截断。不过这类标记很多时候影响比较局部,如果你能接受最后一行闪烁,也可以只对“最后一行”做等待处理。
我在实际开发里用过一种“从后往前找空行”的策略:从文本末尾往前找最近的空行(\n\n),把空行前面的部分截出来,检查这段截出来的文本是否安全。如果安全,就把它提交给 marked;如果不安全,继续往前找上一个空行。这样既不会把半截内容交给解析器,又能保证大部分完整内容尽快展示。
3.3 一个可用的MarkdownStreamBuffer实现
下面我给一个可以直接抄的 TypeScript 实现。这个类做的事情很简单:
- 维护内部
buffer append(chunk)把新数据追加进去,然后尝试切出所有安全块flush()在流式结束时把剩余不安全内容强制交出来repair()负责补全最后没闭合的语法
先写围栏检测:
interface FenceState { inFence: boolean; marker: string; } function scanFence(source: string): FenceState { const lines = source.split('\n'); let inFence = false; let marker = ''; for (const line of lines) { const match = line.match(/^\s*(`{3,}|~{3,})/); if (!match) continue; const currentMarker = match[1]; if (!inFence) { inFence = true; marker = currentMarker[0]; } else if (currentMarker[0] === marker) { inFence = false; marker = ''; } } return { inFence, marker }; }再写一个简化版 HTML 标签配对检查:
const HTML_TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9-]*)(?:\s[^>]*?)?(?:\/?)>/g; function hasUnclosedHtmlTags(source: string): boolean { const stack: string[] = []; const re = new RegExp(HTML_TAG_RE.source, 'g'); let match: RegExpExecArray | null; while ((match = re.exec(source)) !== null) { const fullTag = match[0]; const tagName = match[1].toLowerCase(); if (fullTag.startsWith('</')) { const index = stack.lastIndexOf(tagName); if (index === -1) return true; stack.splice(index, 1); } else if ( !fullTag.endsWith('/>') && !['img', 'br', 'hr', 'input'].includes(tagName) ) { stack.push(tagName); } } return stack.length > 0; }然后写一个工具函数,判断某段 Markdown 是否可以安全渲染:
function isSafeToRender(markdown: string): boolean { if (markdown.trim() === '') return false; if (scanFence(markdown).inFence) return false; if (hasUnclosedHtmlTags(markdown)) return false; // 这里还应该检查链接括号、行内反引号等,篇幅原因先省略 return true; }最后是缓冲区主类:
export class MarkdownStreamBuffer { private buffer = ''; append(chunk: string): { done: string; remaining: string } { this.buffer += chunk; const doneParts: string[] = []; let splitIndex = this.findSafeSplitPoint(); while (splitIndex > 0) { doneParts.push(this.buffer.slice(0, splitIndex)); this.buffer = this.buffer.slice(splitIndex); splitIndex = this.findSafeSplitPoint(); } return { done: doneParts.join('\n'), remaining: this.buffer, }; } flush(): string { const rest = this.buffer; this.buffer = ''; return repairIncompleteMarkdown(rest); } private findSafeSplitPoint(): number { const lines = this.buffer.split('\n'); for (let i = lines.length - 1; i >= 1; i--) { // 从后往前找空行,空行往往是安全边界 if (lines[i - 1].trim() === '' || lines[i].trim() === '') { const candidate = lines.slice(0, i).join('\n'); if (isSafeToRender(candidate)) { return candidate.length; } } } return 0; } } function repairIncompleteMarkdown(markdown: string): string { let output = markdown; const fence = scanFence(output); if (fence.inFence) { output += '\n' + fence.marker.repeat(3) + '\n'; } // 如果有未闭合的 HTML 标签,可以根据栈补全闭合标签, // 实际项目中通常直接丢弃或由 sanitize 兜底。 return output; }注意isSafeToRender里我省略了链接和行内标记的检查,真实项目里建议把hasUnclosedLink这类逻辑也加上。思路很简单:从文本末尾的(往前找[,如果[后面没有对应的],就说明链接还未完整。
这个缓冲类有个好处:它不会无限制地等。只要出现了空行且之前内容安全,它就会把前面完整部分吐出去渲染,所以用户看到的内容延迟很小。只有在代码块、HTML 标签这种必须成对的场景下,才会多等一会儿。
3.4 接入React流式输出,并处理收尾修复
React 里用起来也很直接。我通常把它包成一个 hook,放在 WebSocket 或 SSE 的回调里:
import { useRef, useState } from 'react'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; function useMarkdownStream() { const [html, setHtml] = useState(''); const bufferRef = useRef(new MarkdownStreamBuffer()); const appendChunk = (chunk: string) => { const { done } = bufferRef.current.append(chunk); if (done) { // 每次只更新新增的安全块,避免全量重渲染 setHtml((prev) => prev + DOMPurify.sanitize(marked.parse(done))); } }; const finish = () => { const remaining = bufferRef.current.flush(); if (remaining) { setHtml((prev) => prev + DOMPurify.sanitize(marked.parse(remaining))); } }; return { html, appendChunk, finish }; }调用时,只要在 SSE 的onmessage里调用appendChunk(data),在end或close事件里调用finish()即可。这里我额外加了一层DOMPurify.sanitize,因为不能信任 AI 生成的内容一定会输出干净 HTML;marked 本身对原始 HTML 是不做清理的。
这里有一个关键点:很多教程只写setHtml(marked.parse(text)),造成每帧全量重建。上面的 hook 是从“新增内容”角度去追加 HTML,天然避开了全量重建问题,锁定的滚动位置也基本不会跳动。
4. 从“能用”到“好用”:增量diff渲染与性能优化
4.1 token级diff还是HTML级patch
缓冲分段方案已经能解决“标签截断”这个核心问题了。但如果你要处理的是长时间生成、需要回滚/修改内容的场景,可能还要再进一层。
一种相对容易落地的方案是 HTML 级 patch。具体做法是:每次拿到全量 Markdown 后,仍然用 marked 解析出完整 HTML,然后用morphdom之类的库,把新旧两个 HTML 结构做 diff,只更新变化的 DOM 节点。这样虽然解析是全量的,但 DOM 更新是局部的,可以保住滚动位置、输入焦点和图片加载状态。
import morphdom from 'morphdom'; function updateMarkdownContainer(container: HTMLElement, nextHtml: string) { const next = document.createElement('div'); next.innerHTML = nextHtml; morphdom(container, next, { childrenOnly: true }); }morphdom的厉害之处在于,它会把新旧 DOM 进行同级对比,只修改有变化的节点。比如 AI 改了前面某个段落的几个字,它不会把整个列表重建一遍,而只是更新那段文字。实测下来,对于几千字的 Markdown,全量解析加 patch 的耗时通常还在毫秒级。
另一种更彻底的做法是在 token 层做增量。你可以把流式 Markdown 按块拆开,每个块有自己的 ID,更新时只重新渲染发生变化的块。不过这块实现复杂度高,而且对大多数聊天/文档工具来说属于过度设计。我自己的项目里只做到了“缓冲分段 + 追加 HTML”,只有在需要修改历史内容时才切到 morphdom 方案。
4.2 代码块和表格的专项处理
代码块是流式渲染里最容易出问题的部分。如果你的页面要对代码做语法高亮,千万不要在代码块还没闭合时就跑去调 highlight 工具。一个更稳的做法是:markdown 解析后先不急着对整个 HTML 做高亮,而是等缓冲模块吐出一个完整代码块后,再单独处理该代码块。
具体实现可以用 marked 的自定义 renderer:
const renderer = { code({ text, lang }) { if (typeof hljs === 'undefined') { return `<pre><code>${text}</code></pre>`; } const highlighted = hljs.highlight(text, { language: lang || 'plaintext', }).value; return `<pre><code class="hljs language-${lang}">${highlighted}</code></pre>`; }, }; marked.use({ renderer });这样在流式输出过程中,代码块内容还没有完全到齐时,我们不会触发高亮逻辑,只有等到完整块被缓冲层吐出来,才会走一次高亮,性能和安全都更有保障。
表格也是类似。如果 AI 正在生成一个几十行的表格,你每帧都重新解析并渲染最新几行,用户会看到表格一会长一截、一会闪一下。我建议在缓冲层对“表格语法未结束”的情况再多保留一下:如果当前缓冲区最后一行以|结尾,且再往上找还能找到表格分隔行,就暂时不要把最后那部分交给 marked,等下一个空行出现再放行。
4.3 流式渲染的节流与调度
流式场景下,网络推送频率可能很高,尤其走 WebSocket 时一秒能有几十个事件。如果每个事件都触发一次 React setState,即使每次只追加一小段,也可能造成渲染堆积。我通常会在接收层加一个requestAnimationFrame或定时器节流:
let pendingChunk = ''; let scheduled = false; function onChunk(chunk: string) { pendingChunk += chunk; if (!scheduled) { scheduled = true; requestAnimationFrame(() => { appendChunk(pendingChunk); pendingChunk = ''; scheduled = false; }); } }这样能把一帧内的多次推送合并成一次更新,渲染压力大幅降低。需要注意:如果页面切到后台,requestAnimationFrame会暂停,可能导致输出停住。更稳妥的替代方案是setTimeout(fn, 16)或直接合并到 Promise 微任务里。我因为踩过这个坑,后来统一改成了setTimeout(fn, 16)配合页面可见性检查。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
把我在项目里遇到过的问题整理成表格,方便排查:
| 现象 | 常见原因 | 建议处理 |
|---|---|---|
| 内容输出结束后,页面还有一段等宽字体代码块 | 没有在流结束时调用 flush 或没有自动补全围栏 | finish()里对剩余 buffer 做 repair |
| 代码块内容闪烁/先渲染后消失 | 缓冲层没有识别代码围栏,把半截块提前交给了 marked | 修scanFence,围栏未闭合不输出 |
| 页面整体布局崩坏 | HTML 标签未闭合,且未检查 HTML 栈 | 在isSafeToRender里加入标签配对检查 |
| 滚动位置被顶到顶部 | 全量重渲染 DOM | 改成追加字符串,或使用 morphdom |
| 链接显示成纯文本 | 右括号还没到就被切分 | 增加“链接括号未闭合”检查 |
| 输入框点击不到/样式错乱 | 某个<div>标签未闭合嵌套了整个页面 | 立即用 DOMPurify 白名单,并修复缓冲区 |
| 长文本越到后面越卡 | 每次全量 parse 和全量重建 DOM | 防抖 + 追加渲染 + 代码块单独处理 |
5.2 快速定位截断点的调试手段
调试流式截断最有效的方法,是直接看 marked 解析出来的 token 树。你可以把当前 buffer 里的内容交给marked.lexer,然后打印 token 类型:
const tokens = marked.lexer(currentBuffer); console.table( tokens.map((t) => ({ type: t.type, raw: t.raw?.slice?.(0, 30), text: t.text?.slice?.(0, 30), })) );如果发现最后一个 token 的type是code,但它的raw结尾没有闭合的```,就能立刻确认是代码围栏问题。如果 token 是html,你就得检查里面的标签是否配对。这个方法比肉眼盯着 HTML 高效得多。
另一个技巧是写一个“慢镜头”测试脚本:把一段完整 Markdown 按字符逐个分割,然后每次都执行你当前的流式渲染逻辑,看它在哪一步开始输出错误 HTML。慢镜头能复现大多数截断问题,而且很容易自动化。
5.3 别忘了安全过滤
流式 Markdown 还有一个容易忽略的点:内容来源不可信。尤其 AI 应用可能被 prompt 注入,诱导模型输出恶意 HTML 标签。marked 本身不会清理这些,所以只要用了dangerouslySetInnerHTML,就必须在渲染前过一道DOMPurify.sanitize。
有人担心每帧都做 sanitize 会影响性能。我的经验是:不要在整篇文本上做,只对新增的完整块做。因为缓冲层已经把一个安全块切出来了,这个块通常不会太长,sanitize 的开销完全可以接受。这也是为什么我建议在appendChunk内部净化,而不是在外面等最终整篇文本。
6. 面试里我会怎么答(个人经验)
6.1 先定义问题,再给方案
回到开头那个面试题,我会分三层回答。
第一层,直接全量重渲染行不行?答:不行。它既不能避免标签截断,还会带来 DOM 重建的性能问题。如果非要用,也只能作为输出很短、不计较体验的 demo 兜底,而且前提是先解决缓冲区问题。
第二层,正确的做法是什么?答:做缓冲分段。每次收到 chunk,先放进 buffer,检测代码围栏、HTML 标签、链接括号等是否完整,只把完整块交给 marked 解析。流式结束后,对剩余半截块做自动修复。
第三层,怎样做得更好?答:加防抖节流、使用 morphdom 做增量 DOM patch、代码块和表格专项处理,最后再加一层安全过滤。
面试官想听到的,其实不是某个 API 的具体用法,而是你有没有建立“解析器需要完整输入”的认知,以及能不能针对流式场景设计出合理边界。
6.2 工程落地中的两个小建议
最后分享两个我在实际项目中觉得特别值钱的经验。
一个是“能后端配合就别前端硬扛”。如果 AI 输出接口由你自己控制,可以在服务端做一层 Markdown 完整块检测,按完整段落推送,前端压力会小很多。当然,真实场景里服务端不好判断用户是不是在等代码块,所以前端该做的兜底还是得做。
另一个是“不要追求零延迟”。流式输出体验的核心是稳定,而不是每个字都第一时间渲染。适当多等一个空行,让前端多攒 50 个字符再渲染,用户几乎感知不到延迟,但页面稳定性会好很多。我曾经为了让效果“ 更实时 ”,把缓冲设得太薄,结果每秒钟渲染十几次,反而造成闪烁和卡顿。后来改成“至少等一个块结束再渲染”,体感反而顺滑了。
这套东西从原理到落地并不复杂,但很能看出一个人对“输入完整性与解析器边界”的理解。如果你也在做 AI 流式输出相关的前端,建议直接拿上面的代码跑一跑,再用慢镜头测试打一遍,你会对 Markdown 解析的细微之处有更深的体感。