资讯详情

流式 Markdown 渲染的坑:为什么全量重渲染不靠谱,以及正确的增量方案

📅 2026/9/18 11:01:16 | 华诺云谱 👁 阅读
流式 Markdown 渲染的坑:为什么全量重渲染不靠谱,以及正确的增量方案
最近在做 AI 对话产品时我遇到了一个很典型的坑大模型在流式输出时Markdown 内容还没写完就已经到达前端渲染结果要么缺了半边语法要么直接错乱。团队里有同学提了个方案——“直接重新让 marked 全部渲染不就行了吗”这句话如果拿到面试里问候选人十有八九能钓出一堆问题。Markdown 流式解析、标签截断、增量渲染、全量重渲染之间的取舍远比表面看起来复杂。这篇文章我就把这个场景拆开讲讲为什么“全量重渲染”在 demo 里看着没问题一上真实流式场景就崩标签截断是怎么发生的真正能落地的方案是什么以及我踩过的坑。适合正在做 AI 应用、聊天工具、在线 Markdown 编辑器的前端同学参考也适合准备面试时把这类问题想透。1. 一个日常翻车现场流式 Markdown 渲染为什么会炸1.1 流式输出的典型形态先还原一下场景。用户在对话框里问了句“帮我写一个节流函数”大模型开始逐字返回好的下面是一个简单的节流函数实现 js function throttle...注意这段内容到“function throttle...”这里可能就断了因为下一个 token 还没生成出来。前端拿到这段不完整的文本如果直接调用marked.parse()再塞进innerHTML会出现很尴尬的画面js这一行被当作普通文本打印或者整个页面从代码块开始后面全被吞掉。这还不是最夸张的。如果模型输出到一半卡在**使用方式**这种加粗文本中间你可能看到的是**使用方式**原样输出或者前面的内容正常但最后一行一直显示一串星号。用户看到这种半截语法第一反应就是“产品出 bug 了”。1.2 流式解析的核心矛盾解析器假设输入完整Markdown 解析器从根上就是为“完整文档”设计的。CommonMark 规范规定了一整套词法规则解析器拿到整段文本后先做块级分词再做行内分词最后生成 HTML。这个过程隐含了一个前提输入是完整的、闭合的。流式场景恰恰打破了这个前提。token 是逐字生成的文本是逐渐拼出来的“当前时刻的文本”天然是不完整的。你不可能等整段话全部生成完再渲染那样用户会盯着一块白屏等几秒钟体验极差。所以前端必须在“文本不完整”的情况下做出“看起来还算完整”的渲染效果。这里就出现了第一层矛盾解析器要完整输入流式场景只能给半截输入。怎么处理就是整篇文章要回答的问题。1.3 全量重渲染为什么“诱人”把整段文本丢给 marked 重新渲染实现成本确实最低preview.innerHTML marked.parse(fullText)一有新的 chunk 追加就重新 parse 一次、重新设置一次innerHTML。在小 demo、短文本、本地文件预览这些场景下这个写法完全够用肉眼几乎感知不到延迟。所以很多人在面试时第一时间就会想到这个方案。但在真实的流式对话场景中这种写法问题非常多。不是 marked 本身不行而是“全量 parse 全量替换 DOM”这个操作模式在持续高频追加的数据流面前撑不住。下面展开说。2. “直接让 marked 全部重新渲染”到底行不行2.1 致命伤一性能随文本长度直线劣化marked 的parse是一个同步过程文本越长词法分析、语法分析、HTML 生成的开销就越高。流式场景里每来一个 token 就全量 parse 一次复杂度叠加上去就是平方级增长。我实测过一个大概 50KB 的对话记录单次marked.parse()大约需要 20ms 到 40ms取决于内容里代码块、表格、链接的数量。看起来不高对吧但流式输出时每秒钟可能来十几个 chunk每次都要 parse 50KB再加上 DOM 整体替换主线程就被占满了。用户在低端手机上感受到的就是打字卡顿、页面掉帧、风扇狂转。更麻烦的是随着对话越来越长这个成本只增不减。对话到 5000 字时还能忍到 20000 字时基本就是灾难。2.2 致命伤二滚动位置、光标、选区全线丢失innerHTML整体替换会产生一个全新的 DOM 树浏览器不会保留之前的滚动位置、焦点状态和选区。在聊天界面里这等于用户刚看到一半内容页面就“跳”到顶部或底部用户的阅读位置瞬间丢失如果页面里还有输入框、搜索框这类可聚焦元素焦点也会被重置。我做过一个实验在预览区里选中一段文字然后触发一次流式 chunk 更新选中状态立刻消失。如果刚好有用户正在复制某个代码片段更新一来复制操作直接失败。在编辑器类产品里这个问题几乎是不可接受的。2.3 致命伤三图片与嵌入内容反复重载全量替换 DOM 之后之前已经加载好的img、iframe、video等节点会被浏览器销毁重建。虽然浏览器对相同 URL 的图片会有缓存但对于动态生成的 Blob URL、需要携带认证信息的接口、或者带loadinglazy的图片来说重建节点等于重新发起加载请求。实际表现就是图片闪一下、视频重新缓冲、iframe 页面重新加载。在大模型回答里如果穿插了图表或截图每次新 token 到达用户都能看到图片闪白一下。这个体验问题虽然不如性能问题致命但非常让人烦躁。2.4 致命伤四事件绑定和组件状态没法维护如果你在渲染结果里绑定了事件或者嵌入了自定义组件全量innerHTML重建会把它们全部打回原形。比如你在 Markdown 渲染结果中给所有a绑定了点击跳转事件第一次 parse 后绑定好了第二次全量重渲染后新生成的a节点的事件是空的需要重新绑定。如果你还用了 React、Vue 这类框架直接操作innerHTML会绕过框架的 diff 机制组件状态变得完全不可控。这一条在做“可交互 Markdown”的产品里尤其明显。比如渲染结果里包含一个折叠面板、一个代码高亮切换按钮、或者一个“复制代码”按钮全量重渲染后这些交互组件全部失效。所以“直接重新让 marked 全部渲染”从代码行数上看最简单但从用户体验和维护成本上看是典型的“便宜没好货”。那有没有更好的办法先要搞清楚标签截断到底是怎么发生的。3. 标签截断的根因marked 解析“半截语法”时做了什么3.1 三种典型的半截语法表现标签截断这个词听起来有点抽象翻译成大白话就是Markdown 语法写到一半就断了。常见的形态有这些类型示例未截断之前的完整语法未闭合时的渲染效果加粗/斜体**这是加粗内容**这是加粗内容**星号原样输出字体没有变化行内代码const a 1const a 1反引号原样显示或者整段格式错乱链接[点击这里](https://[点击这里](https://example.com)链接不能被正确识别图片![图](https://![图](https://example.com/a.png)图片裂开代码块js\nconsole.log(1)js\nconsole.log(1)\n后续内容全部被当作代码块吞掉HTML 标签div classboxdiv classbox内容/divHTML 标签可能被直接输出我在实际项目中遇到过最严重的案例是模型输出到一段代码块的围栏三个反引号之后、代码正文还没开始就暂停了。前端把这半截文本交给 markedmarked 把未闭合的js当成了一个块级代码块的起点于是从这一行开始后面所有内容——包括用户已经看到的问题、回答、其他排版——全部被渲染成代码块。结果是整个对话区域变成一片灰底所有内容都变成了等宽字体。3.2 marked 为什么不能自动补全很多人会问marked 那么成熟为什么不能自动把**你好识别成strong你好/strong因为这个需求本质上是“猜测用户的意图”而规范不允许猜测。CommonMark 规范对 emphasis加粗/斜体、code span、link 的语法都有严格的闭合要求。**你好在规范里并不是一个合法 emphasis 的起始因为缺少对应的**结束标记。marked 作为规范实现宁可把**当作普通字符输出也不能擅自加上结束符。同理[点击](https://里(后没有对应的)链接不会被识别括号里的内容会被当作文本。这不是 marked 的缺陷而是所有遵循 CommonMark 规范的解析器markdown-it、remark、mistune 等的统一行为。所以我们在做流式渲染时不能指望 parser 帮我们处理“半截语法”要自己在外层做一层“半成品包装”。3.3 真正容易爆雷的是块级结构行内语法未闭合顶多显示得丑一点块级结构未闭合出的是大问题。这里点名代码块围栏和引用块代码块以三个或以上反引号开头必须以同样数量的反引号结尾。开头有了结尾迟迟不来解析器会认为代码块一直延续到文档末尾。流式输出过程中用户会看到后续所有普通段落全部被渲染成代码块。引用块以开头的行会进入 blockquote。虽然单独一行引用块不闭合也不会吞掉太多内容但如果模型中途换行方式不对会出现引用嵌套层级混乱。列表- item后面如果跟了缩进内容未闭合的列表项可能把后面的非列表文本吸收成列表内容。块级结构的“吞内容”特性是流式 Markdown 渲染最容易翻车的点。这也是为什么很多现成方案在流式场景下效果不佳因为它们只处理了行内标签的截断没有考虑到块级解析的延续性。4. 流式解析的正确姿势安全边界 临时补全 兜底重渲染4.1 增量渲染的核心思路能局部就别全量不搞全量重渲染那就得做增量渲染。增量渲染的核心思路是维护两个东西——已经被渲染成 HTML 的“历史部分”以及还没到渲染时机的“缓冲部分”。新到的 chunk 先进入缓冲只有当缓冲区的文本达到一个“安全边界”时才把这段增量解析成 HTML追加到历史部分后面。这个思路的好处很明显。已渲染的 DOM 节点不重建滚动位置、图片状态、事件绑定都还在每次只需要 parse 一小段增量文本性能开销小得多。而且从用户视角看内容是“长出来”的不是“整页刷新”的。4.2 安全边界切割在哪里下刀最稳“安全边界”的意思是在这个位置切一刀不会破坏任何语法结构。最典型的边界是空行因为多数块级元素在空行处会结束。但空行并不总是安全的代码块内部、列表项内部也可能出现空行。我实践中用过一套相对稳妥的判断逻辑先看代码块围栏是否闭合再逐行从后往前找边界function findSafeBoundary(text) { // 如果代码块围栏未闭合说明整个尾部都处于代码块中不能切 const fenceCount (text.match(/^/gm) || []).length; if (fenceCount % 2 1) { return -1 } const lines text.split(\n) for (let i lines.length - 1; i 0; i--) { const line lines[i] // 空行块级元素在这里结束可以切 if (line.trim() ) { return i 1 } // 块级标题文章某个标题的自然结束位置 if (/^#{1,6}\s/.test(line)) { return i } // 单独成行的引用/列表标记这类行后面往往还有内容不能在这里切 // 所以这里只记录不返回 } return -1 }这段代码能覆盖大部分常见场景但别指望它能够处理所有 Markdown。遇到表格、嵌套列表、代码块内容里包含空行等复杂结构时边界判断可能失效。所以我还加了另一个保险块级结构未闭合时直接不切继续留在缓冲区等待。4.3 尾部临时补全让半截语法暂时合法安全边界解决的是“块级结构吞内容”的问题但很多行内语法截断发生在文本流的尾部。比如模型正在输出**这是一个说明**你切到的安全边界可能在“说明”两个字中间剩下的尾缀**还在缓冲区。为了让已经渲染的部分看起来正常可以采用临时补全策略。思路是检测缓冲区尾部的半截语法在渲染前临时追加对应的闭合符号让 parser 认为文本是完整的渲染完成后再想办法隐藏这些补全符号或者用 CSS 标记为“输出中”状态。function closeUnfinishedSyntax(text) { const patterns [ // 未闭合的粗体 { regex: /\*\*[^*]*$/, close: ** }, // 未闭合的行内代码 { regex: /[^]*$/, close: }, // 未闭合的链接文字 { regex: /\[[^\]]*$/, close: ] }, // 未闭合的链接地址 { regex: /\([^)]*$/, close: ) }, // 未闭合的图片描述 { regex: /!\[[^\]]*$/, close: ] } ] let closedText text let hasUnfinished false for (const pattern of patterns) { if (pattern.regex.test(closedText)) { closedText pattern.close hasUnfinished true break } } return { closedText, hasUnfinished } }然后渲染时const { closedText, hasUnfinished } closeUnfinishedSyntax(buffer) const html marked.parse(closedText)如果hasUnfinished为 true说明当前渲染结果里带了一个“假闭合”的尾部可以在 DOM 层把它标记成“正在生成中”preview.insertAdjacentHTML(beforeend, html.replace(/p([\s\S]*?)\/p$/, p classstreaming$1span classcursor/span/p))这里的核心技巧是补全符号只用于让 parser 工作不要让它们污染真实缓冲区。缓冲区里存的仍然是原始文本只是渲染时做一次临时包装。4.4 流结束的兜底策略无论增量渲染做得多么精细流式过程中一定会有某些内容因为边界判断不够聪明而渲染得不够准确。所以我在实践中约定了一条铁律流结束后用完整文本做一次最终全量渲染。这次重渲染是最后一次发生在文本已经完整、不会再追加新内容的时候。此时解析器拿到的输入是完整合法的渲染结果必然正确。页面从“实时预览模式”切换到“最终文档模式”用户看到的是干净的结果之前的增量渲染瑕疵会被彻底覆盖。stream.on(end, () { preview.innerHTML marked.parse(fullText) })这个兜底和“每次全量重渲染”有本质区别它只发生一次不是每次 token 都触发而且它覆盖的是最终结果不会打断用户的实时阅读。4.5 一个可以抄的完整示例下面是一个简化但完整的流式渲染实现流程能把上面的思路串起来const preview document.getElementById(preview) const state { fullText: , // 完整文本 buffer: , // 尚未渲染的缓冲区 renderedLength: 0 // 已渲染的字符长度 } function appendChunk(chunk) { state.fullText chunk state.buffer chunk const safePos findSafeBoundary(state.buffer) if (safePos 0) { // 当前没有安全边界继续等待 return } const safePart state.buffer.slice(0, safePos) const rest state.buffer.slice(safePos) const { closedText, hasUnfinished } closeUnfinishedSyntax(safePart) const html marked.parse(closedText) preview.insertAdjacentHTML(beforeend, hasUnfinished ? markStreaming(html) : html) state.renderedLength safePart.length state.buffer rest } function finishStream() { if (state.buffer.length 0) { const { closedText } closeUnfinishedSyntax(state.buffer) preview.insertAdjacentHTML(beforeend, marked.parse(closedText)) state.buffer } // 兜底最终全量渲染 preview.innerHTML marked.parse(state.fullText) }这个示例不是生产级代码但核心逻辑都在安全边界、临时补全、兜底重渲染。实际项目里还需要加上防抖、虚拟滚动、Web Worker 等优化不过思路是完整的。5. 更工程化的替代方案token 级流式消费与解析器选型5.1 常见方案对比方案实现成本流式友好度适用场景纯 marked 全量重渲染极低差本地小文本预览安全边界 增量渲染中较高大部分流式 Markdown 渲染marked.lexer 拿到 token 后增量合并较高高需要细粒度控制的复杂场景markdown-it 解析 增量 token 更新中高高需要自定义 rule 的产品直接在富文本编辑器里增量消费高最高协同编辑、复杂交互文档我也用过一些现成的流式 Markdown 库它们本质上就是“安全边界 临时补全”的工程化封装。但封装再好自己也得理解底层逻辑否则遇到复杂 Markdown 时根本不知道从哪里排查。5.2 基于 marked lexer 的改造思路marked 其实暴露了marked.lexer(src)和marked.parser(tokens)这两个方法。 lexer 负责把 Markdown 文本变成 token 数组parser 负责把 token 数组变成 HTML。你可以绕过marked.parse()自己来控制解析粒度。一种做法是每次有新 chunk 时调用marked.lexer(newText)拿到全部 token然后对比上一次的 token 列表只把新增的 token 转成 HTML 追加。这个做法的优势是能拿到结构化的 token方便做语义判断比如“这个 token 是不是一个未闭合的 code block”劣势是 marked 的 lexer 在流式场景下依然会面临半截语法问题token 列表可能不稳定对比逻辑写起来比较烧脑。如果你真的想走 token 级路线我的建议是不要把 lexer 的结果直接当最终状态而是要自己去管理“未闭合 token 的暂存区”。比如一个以开头的 code token 没有对应的结尾 token就把它留在暂存区等后续 token 到达后再决定是否闭合。5.3 基于 markdown-it 的增量 token 合并markdown-it 在这方面稍微友好一点因为它的 token 流是扁平的并且允许通过 plugin 扩展。一个常见的实践是维护一份全局 token 数组。新文本到达时只对新增文本部分调用 markdown-it 的parse。把新的 token 和缓冲中的尾部 token 合并。通过 renderer 只渲染新增 token 对应的 HTML。但这里有个陷阱markdown-it 的 block ruler 在解析文本时可能因为输入截断而生成不同的 token。比如单独解析- item它可能生成一个bullet_list_open但不会生成bullet_list_close。如果你只把这段 token 交给 renderer输出的 HTML 可能是不闭合的标签。所以 token 合并时必须维护一个“未闭合标签栈”等后续 token 到达时补上闭合。这类方案的工程量不小适合对渲染精度要求极高的产品。如果只是为了聊天场景的流式展示4.5 里那套安全边界方案已经足够。5.4 优化扩展Web Worker 虚拟滚动当文本膨胀到一定程度解析本身也可能拖累主线程。把 marked 的 parse 放到 Web Worker 里执行主线程只负责接收 HTML 字符串可以显著降低卡顿。此时需要注意marked 在 Worker 里同样可以使用但 DOM 操作仍在主线程做通过postMessage传递增量 HTML 即可。虚拟滚动是另一层优化。聊天记录很长时不需要把全部 Markdown 渲染到 DOM 里只需要渲染可视区域附近的内容。不过这要和消息列表结合起来做适合像“历史对话记录”这种本身就很长的场景不太适合“当前正在生成中的消息”。6. 常见问题与排查技巧实录6.1 代码块围栏未闭合后续内容全被吞现象对话中出现一条消息前半段是正常文本后半段突然全部变成等宽字体灰底看起来像是一整段代码块吞掉了所有内容。排查思路先定位第一个出现的位置检查它后面的文本里有没有匹配的闭合围栏。最常见的原因是模型输出到代码块中间时流式暂停前端把未闭合内容直接交给 parser 渲染了。解决办法是走安全边界逻辑在检测到围栏未闭合时把整个代码块内容留在缓冲区不参与渲染。注意fenceCount % 2 1只是一个粗粒度的判断。如果你在文档里嵌入了行内代码code里面的单个反引号不会影响围栏配对但三个以上反引号的行就要小心了。所以更稳妥的做法是逐行扫描识别“以三个或以上反引号开头、且该行除反引号外没有其他内容”的行。6.2 临时补全后出现多余闭合符号现象用临时补全法渲染后页面上出现多余的**、]、)等字符看起来十分奇怪。原因补全符号是在渲染前 append 到文本里的marked 把它当作正常内容解析于是多出来的部分也被显示出来了。解决办法有两个方向。一是在 renderer 回调里拦截比如加粗文本的 renderer 在结尾处不输出多余的符号二是不用“先补后渲染”的方案改为在 DOM 层处理——渲染原始半截文本然后在可视层通过 CSS 加一个“输出中”光标让用户知道内容还在生成而不是让 parser 去猜未闭合语法。我在项目里最终采用了第二种思路未闭合的行内语法不补全而是把尾部用一个.streaming类标记展示一个动画光标。这样既不会带出多余符号也不会让 parser 猜错。6.3 增量渲染后多个段落互相污染现象两段文本之间没有正确换行或者一段列表项把下一段普通文本吸收成了列表内容。原因安全边界切错了位置。最常见的是空行虽然能作为块级边界但空行本身被切到了“上一段”的末尾导致下一段开头失去了空行分隔两个段落被拼成同一段。排查思路打印缓冲区、已渲染文本、边界位置看看空行到底归了哪边。我踩过这个坑之后把空行统一归入“待渲染部分”不随已完成部分渲染出去这样下一个文本块自动从空行开始段落之间就正常了。6.4 排查思路速查表问题现象优先检查整个消息变成代码块代码块围栏是否闭合尾部出现多余闭合字符临时补全逻辑是否影响真实缓冲区图片闪白、音量重置是否使用了innerHTML整体重建滚动位置跳动是否使用了 DOM 全量替换增量内容渲染错乱安全边界是否切到了块级结构中富文本组件状态丢失是否绕过框架直接操作 DOM最后再说两句大实话回到面试题本身。如果面试官问“直接重新让 marked 全部渲染行不行”不要急着说不行要说清楚为什么不行然后给方案。我会这么答直接全量渲染在流式场景下会有性能和体验问题核心原因是 Markdown 解析器天然假设输入完整而流式输入天然不完整所以要用安全边界切割加临时补全让增量部分能独立渲染最后等流结束后做一次全量兜底保证最终文档正确。这个回答把“是什么、为什么、怎么做”都涵盖了面试官一般会认可。实际开发中我最大的体会是流式渲染没有银弹工具只是在给你兜底真正决定体验的还是你对输入流的控制。多花心思在边界判断和缓冲策略上比频繁换解析器库有用得多。如果你也在做流式 Markdown 渲染欢迎交流你踩过的坑。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。