尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

流式Markdown渲染避免标签截断:从全量重渲染到分块占位方案

发布时间:2026/9/15 19:37:08

资讯中心
01
ARTICLE

流式Markdown渲染避免标签截断:从全量重渲染到分块占位方案

流式Markdown渲染避免标签截断:从全量重渲染到分块占位方案
去年我做了一个接入大模型流式输出的协作工具前端最磨人的不是接口对接而是 Markdown 渲染。模型每吐出来一个字我就得把累积的文本重新塞给marked.parse()一遍然后再整块innerHTML。一开始 demo 跑得挺顺等对话内容写到第三屏页面就开始肉眼可见地跳表格先是一行普通文字补全后“啪”地变成表格链接先是一串蓝色文本补全后突然变成可点击状态代码块更别提内容倒是显示出来了highlight.js 却在每次增量更新时把整页代码重新高亮一遍。这就是标题里那个问题流式解析 Markdown 时如何避免标签截断“直接重新让 marked 全部渲染”到底行不行如果你正打算给 AI 对话、实时协作 Markdown 编辑器或者任何“一边收数据一边渲染”的场景写渲染层这篇文章会把方案的坑和可落地的实现都讲透。1. 先搞清楚流式 Markdown 到底卡在哪1.1 一个典型的流式渲染场景先统一一下场景。所谓流式渲染指的不是用户手动点“预览”按钮那种一次性渲染而是内容以增量块chunk形式持续到达页面每收到一块就要把已有的展示结果往前推进。最典型的就是 LLM 对话界面——你在页面上看到的打字机效果本质上就是后端把生成结果按 token 或按句子分段推给前端前端每收到一段就刷新一次内容。这个场景有两个特点决定了它不能照搬普通渲染逻辑一是更新频率高每秒可能有好几次甚至十几次更新二是内容本身是不完整的后一个 chunk 随时可能让前一个 chunk 的“半成品语法”变成完整语法。比如模型输出了## 会议记下一 block 才补充录但## 会议记在语法上已经是一个完整的标题 token录进来后标题内容又要变。这种“同一段内容以不同完整度反复被解析”的问题就是流式 Markdown 渲染最麻烦的地方。1.2 “标签截断”具体指什么很多同学第一次遇到截断时会以为只有“标签没闭合”这一种情况比如 HTML 里的div被截断成div。但在 Markdown 流式场景里截断是一个更宽泛的概念只要是“依赖后续内容才能确定语法身份”的片段都会形成截断问题。最容易踩的几类代码块围栏输入流在js之后恰好断掉此时整块代码还没有结束标识。表格只收到了| 姓名 | 年龄 |还缺第二行| --- | --- |和下面的数据行。这个时候 marked 不会把它当成表格而会当成普通段落。列表可能已经收到了- 项目 A但后续会不会再接- 项目 B不知道li 的边界其实还没闭合。行内语法像[这是一个链接、![图片描述这种半截链接和图片[后半段没出现解析器只能把它们当普通文本。引用块 第一行后面还有一行以开头的内容但流在这里断了导致引用块被截断成只有第一行。这些情况如果处理不好用户看到的就是“一会儿是纯文本一会儿突然变成表格/链接/代码块”的闪烁严重时还会出现滚动位置跳动、输入焦点丢失。1.3 全量重渲染的三宗罪提到给 Markdown 做流式渲染新手最直觉的方案就是维护一个fullText变量每个 chunk 到达后执行fullText chunk; container.innerHTML marked.parse(fullText);。这确实能跑通 demo但一旦认真用起来问题就来了。第一宗罪是性能。marked 本身是纯 JS 解析器解析速度不慢但流式场景是“高频地重复解析全部内容”。随着文本不断累积每次更新的解析成本都会上升到十几 KB、几十 KB 时再加上innerHTML全量重建 DOM页面会明显卡顿。表现在聊天场景里就是打字机效果一卡一卡的用户每收一个 token 都会明显感觉页面“喘气”。第二宗罪是状态丢失。innerHTML会销毁旧 DOM 重建新 DOM。如果页面里嵌入了已经加载好的图片、iframe或者已经完成高亮处理的代码块它们全都会被迫重新加载、重新渲染。用户在输入框里输入内容时焦点和光标位置也可能被重置。第三宗罪是闪烁。全量重渲染意味着每次更新都要把整块内容替换一遍视觉上会产生一个微小的“从头刷到尾”的跳动。内容多的时候这种跳动非常明显用户会觉得页面很不稳定。2. 直接重新让 marked 全部渲染行不行2.1 可以但你只是得到了“最终一致”先说结论直接全部重渲染“行”但它不是解决问题的方案只是把问题推后了。它的核心理念是“最终一致”——不管中间态多难看只要最终文本完整最后一次渲染结果一定是对的。所以在纯静态内容一次性渲染的场景里全量重渲染完全没有问题。但流式渲染的诉求恰恰是“中间态也要稳定”不能因为内容不完整就让结果在用户眼皮底下反复横跳。更重要的是全量重渲染并不能真正避免截断问题。你重新让 marked 渲染的文本依旧是不完整的表格还是那行没分隔行的表头链接还是那个只有左括号的半截。截断是输入内容本身的信息不完整导致的跟“渲染多少次”没有关系。你重新渲染一百遍| 姓名 | 年龄 |也不会自己变成表格。2.2 你为“偷懒”付出的三个隐性成本全量重渲染最大的吸引力在于实现成本低但这三个隐性成本经常被忽略。第一个是重排重绘开销。每次innerHTML都会触发浏览器的布局计算和绘制连续高频触发时即使内容只有几 KB也可能把 CPU 打满。这在低端移动设备上尤其明显。第二个是外部资源重复加载。Markdown 里嵌入的图片、iframe、脚本只要 DOM 被重建它们就会重新请求。如果图片没有配置缓存每次更新都刷一遍图用户体验直接崩。第三个是代码高亮和数学公式这类“二次处理”的重复执行。你大概率不会只用 marked还会配 highlight.js 做代码高亮配 KaTeX 或 MathJax 渲染公式。这些库的渲染成本通常比 marked 本身高一个量级。全量重渲染等于每次更新都要把它们也重新跑一遍页面不卡才怪。2.3 什么时候可以勉强用也不是完全不能用。如果你的场景满足以下条件全量重渲染确实是性价比最高的选择内容总量很小基本在 1~2 KB 以内永远不会出现长文档。更新频率很低比如每秒不超过一次。不涉及外部资源也没有代码高亮、数学公式这类二次处理。用户对闪烁不敏感比如内部工具、调试面板。但如果你在做 AI 对话这类产品我建议直接放弃全量重渲染的思路。因为它保证了“最终正确”却放弃了“过程稳定”而在 AI 场景里过程稳定恰恰是用户体验的核心。3. 正确的设计思路完成块与进行中块分家3.1 核心思想把“语法状态”纳入流程解决截断问题的正确思路是把“一段文本的语法状态”纳入渲染流程。一段文本在任意时刻要么是“已完成块”要么是“进行中块”。已完成块指语法已经完整的块。比如后面跟着空行的一段普通段落、一对完整围栏包裹的代码块、带表头分隔行和至少一行数据行的表格。这些块的内容不会再变了可以安心渲染一次并缓存结果。进行中块指还在增长、语法尚未闭合的块。比如还没遇到闭合围栏的代码块、还没有分隔行的表头、还没结束的列表。这些块不能按普通规则直接渲染否则就会反复出现“半成品语法被当成普通文本”的截断闪烁。做法就是每一轮更新都把文本切分成done和tail两段。done里全部是已完成块交给 marked 正常渲染渲染结果缓存起来tail是整个文档尾部那一个进行中的块专门写一套逻辑去处理它的“半成品状态”。3.2 如何判断一段文本的“块是否完成”判断“块是否完成”主要靠两个信号空行和围栏。Markdown 的块级语法都以空行作为分隔边界。一个段落、一个列表、一个引用块只要它后面出现了空行就说明这个块在语法上已经和后续内容隔离了可以视为完成。但代码块是个例外代码块内部允许出现空行外壳用三个反引号或波浪号围栏包裹。所以切分时必须先检测围栏是否闭合如果围栏没闭合那么从最后一段未闭合的围栏开始一直到文本末尾全都算作tail不管里面有多少空行。表格的判断更特殊。marked 在启用 GFM 时只有同时满足“表头行 分隔行 至少一个数据行”才生成 table token。所以如果你看到文本尾部有一行| 姓名 | 年龄 |先不要急着渲染成表格因为分隔行可能还没到。你可以先把它当作“待定表格块”用一个轻量的骨架展示出来保证视觉稳定。3.3 占位渲染三件套骨架、闭合、防抖确定了tail之后“占位渲染”就是让用户在这个过渡期看起来基本稳定的手段。我常用的技巧有三个。第一是“骨架先行”。表格这种强格式语法在数据还不完整时先渲染表头骨架至少把 tabular 的视觉先立住等数据行到了再补。第二是“人工闭合”。代码块围栏没闭合时marked 其实可以把它识别成代码块渲染出来。你只要在占位渲染时主动给内容的末尾补一个闭合围栏让视觉上有一个稳定的“代码块底边”而不是让 HTML 里一直悬着一个没结束的pre。第三是“增量防抖”。不要在每次收到 chunk 后立刻同步渲染而是在requestAnimationFrame里合并当前帧的所有增量减少无谓的重复布局。4. 基于 marked 的流式渲染实战4.1 准备工作lexer 与 parser 的分工marked 这个库很多人只知道一个marked.parse()其实它把“解析”和“渲染”拆成了两个阶段。marked.lexer(src)负责把 Markdown 原文解析成 token 数组marked.parser(tokens)负责把 token 数组渲染成 HTML。这两个阶段分开的意义在于你可以拿 token 数组做各种精细控制比如判断最后一个 token 是什么类型、决定哪些 token 要渲染成完整 HTML、哪些 token 要单独处理。我们用 marked 做流式渲染时尽量用 lexer/parser 组合而不是一步到位的 parse这样才有操作空间。下面代码用 TypeScript 风格写你用 JavaScript 的话把类型标注去掉就行。4.2 先写出安全切块函数切块是整个方案的核心。第一步要判断代码围栏是否未闭合如果未闭合整个未闭合代码块就是tail否则再找最后一个空行把空行后面的部分当作tail。function isFenceUnclosed(md: string): boolean { // 简化实现统计 开头的行数奇数则说明还没有闭合 const count (md.match(/^/gm) || []).length; return count % 2 1; } function findLastFenceStart(md: string): number { const lines md.split(\n); let fenceStart -1; let count 0; for (let i 0; i lines.length; i) { if (/^/.test(lines[i])) { count; if (count % 2 1) fenceStart i; } } return lines.slice(0, fenceStart).join(\n).length (fenceStart ? 1 : 0); } function splitTail(md: string): { done: string; tail: string; kind: fence | block } { if (isFenceUnclosed(md)) { const fenceStart findLastFenceStart(md); return { done: md.slice(0, fenceStart), tail: md.slice(fenceStart), kind: fence }; } // 没有未闭合代码块时按空行切最后一块 let cut md.lastIndexOf(\n\n); while (cut 0) { const tail md.slice(cut 2); // 如果尾部以列表、引用、表格开头说明它和前面是连续语法 // 要往前回溯到真正独立的块边界 if (/^\s*([-*]|\d\.)\s/m.test(tail) || /^\s*\s/m.test(tail) || /^\s*\|/.test(tail)) { cut md.lastIndexOf(\n\n, cut - 1); continue; } break; } if (cut 0) { return { done: , tail: md, kind: block }; } return { done: md.slice(0, cut), tail: md.slice(cut 2), kind: block }; }这段代码里面有三个关键点我实际写的时候踩过坑while回溯是为了处理连续块。比如文本是- 项目 A - 项目 B - 项目 C如果只是在最后一个\n\n处切分tail是- 项目 C看起来没问题。但如果是- 项目 A - 项目 B - 项目 C中间没有空行整块是一个 list token。此时lastIndexOf(\n\n)可能是 -1直接返回done: 整段都变成tail。这其实可以接受因为列表本来就不容易在中间切按整段处理更稳。表格开头的判断只用了/^\s*\|/可能把段落里的竖线也误判成表格但这里只是决定“是否向前回溯”多回溯一段顶多多渲染一点缓存不会出错。所以宁可激进也不能切错块。findLastFenceStart返回的是字节偏移不是行号所以计算时用join(\n).length转换。这个偏移是给slice用的别搞混。4.3 尾部块怎么渲染才“稳”切出tail后渲染策略要对不同的半成品语法区别对待。function escapeHtml(s: string): string { return s .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;) .replace(//g, #39;); } function hasTableSeparator(md: string): boolean { return /^\s*\|?\s*:?-{3,}/m.test(md); } function renderTail(tail: string, kind: fence | block): string { if (kind fence) { // 未闭合代码块按代码块骨架渲染 const langMatch /^\s*([\w-]*)/.exec(tail); const lang langMatch langMatch[1] ? langMatch[1] : ; const codeText tail.replace(/^[\w-]*\n?/, ); return pre classstreaming-fencecode classlanguage-${lang}${escapeHtml(codeText)}/code/pre; } const firstLine tail.split(\n)[0] || ; // 表格第一行像表头但分隔行还没到先渲染表头骨架 if (/^\s*\|.\|\s*$/.test(firstLine) !hasTableSeparator(tail)) { const cells firstLine.split(|).filter((c) c.trim() ! ); const th cells.map((c) th${escapeHtml(c.trim())}/th).join(); return table classstreaming-tabletheadtr${th}/tr/theadtbody/tbody/table; } // 列表和引用块本身支持增量直接交给 marked 处理 if (/^\s*([-*]|\d\.)\s/m.test(tail)) { return marked.parse(tail); } if (/^\s*\s?/m.test(tail)) { return marked.parse(tail); } // 普通段落手动转义并保留换行避免 marked 把换行折叠成空格 return p classstreaming-paragraph${escapeHtml(tail).replace(/\n/g, br)}/p; }这里要解释一下为什么普通段落要手动转义而不是直接marked.parse(tail)当tail是这是一段普通的文字里面有一个 [半截链接时marked 会因为 link 语法不完整而把它输出成带a的半成品。直接把tail用marked.parse渲染虽然多数情况没问题但是一旦碰到行内语法截断就会闪一下。所以段落级别我选择先用纯文本换行展示等它变成done后由 marked 正常渲染链路处理。代码块转义是必须的否则用户输入的语言字符串可能包含 HTML 标签直接拼进code会被浏览器解析造成 XSS。这个坑在聊天机器人场景尤其严重因为模型生成的代码里经常带/code这种字符串。4.4 一个可用的流式渲染器类把切块和尾部渲染拼起来就是一个能直接落地的流式渲染器。import { marked } from marked; class StreamingMarkdownRenderer { private buffer ; private doneHtml ; private doneEnd 0; constructor() { marked.setOptions({ gfm: true, breaks: false }); } push(chunk: string): string { this.buffer chunk; return this.render(); } private render(): string { const { done, tail, kind } splitTail(this.buffer); // 只有 done 前进了才重新渲染避免每次刷新都全量解析 if (done.length ! this.doneEnd) { this.doneHtml done.trim() ? marked.parse(done) : ; this.doneEnd done.length; } const tailHtml tail ? renderTail(tail, kind) : ; return ${this.doneHtml}\n${tailHtml}; } }调用方式很简单const renderer new StreamingMarkdownRenderer(); // 假设 onChunk 是流式回调 function onChunk(text) { container.innerHTML renderer.push(text); }注意done.length ! this.doneEnd这个判断利用了流式场景“只追加不改中间内容”的特性。流式追加时done的边界只会向后推进不会倒退所以用长度就能判断是否变化。如果你还要支持用户手动编辑历史内容就不能这么干得用更精确的标记比对了。4.5 进阶优化只重解析发生变化的尾部上面的类已经缓存了doneHtml但每次调用splitTail仍然会对整篇文本做一次扫描。当文档很长时文本切分本身也会有开销。如果想进一步优化可以维护一个“已处理长度”每次只把新收到的 chunk 追加到tail上然后重新执行切分。这样文本切分的均价会摊薄到每次只处理增量部分。标记为进阶是因为它会引入一个隐含问题doneHtml和 token 列表的同步。如果切分结果回退了你要能正确丢弃已经渲染的doneHtml。在纯流式追加场景下我做过多轮验证回退基本不会出现但如果你拿它去做协同编辑的底层复杂度会上升不少。另一个实用优化是配合requestAnimationFrame合并同一帧的多次 push。把push改成private pendingChunk ; private rafId 0; append(chunk: string) { this.pendingChunk chunk; if (this.rafId) return; this.rafId requestAnimationFrame(() { const full this.pendingChunk; this.pendingChunk ; this.rafId 0; this.push(full); }); }这样即使后端一秒钟推 20 次渲染也只会跟随屏幕刷新率执行。我在项目里实测网络快的时候用户感知不到延迟但 CPU 占用明显下来了。5. 几种方案的取舍与选型建议5.1 防抖全量渲染最懒但最不稳先补充一个经常被推荐的“变种全量渲染”加上防抖内容稳定后再整篇渲染。比如设定 100ms 防抖窗口窗口内没有新 chunk 才执行marked.parse。优点是我再强调一次实现成本极低几行代码就能结束战斗。问题是它只是降低了更新频率并没有解决“半成品语法被解析成普通文本”的闪烁问题也没有解决 DOM 全量重建的性能问题。打字机效果会有一种“憋一下再吐一堆”的节奏感内容一长依旧卡顿。它适合做兜底方案不适合做主方案。5.2 分块完成 占位渲染首推这是我在第 4 章给出的方案也是现在个人项目里的默认选择。它的最大优势是“哪里有变化就渲染哪里”done部分的结果被缓存只有tail在持续更新。配合表格骨架和代码块人工闭合中间态的视觉稳定性非常高。缺点是需要维护一套块边界判断逻辑尤其要处理列表、引用这类“连续块”的边界问题。代码量比防抖全量渲染大不少但复杂度集中在splitTail这一个函数里调试成本可控。5.3 vdom diff体验天花板最高还有一类思路是把每次渲染结果交给 vdom 库做 diff比如引入 morphdom 或使用 preact 的 diff 算法。每次全量解析出 HTML 字符串然后 patch 到真实 DOM 上只更新变化的部分。这样做的好处是代码逻辑简单不需要自己判断块边界聚焦在“最终 HTML 的差异”上。但如果数据量很大全量解析的成本依然在只是 DOM 重建的开销被避免了。另外当表格从普通段落变成 table 时vdom diff 也挡不住视觉跳跃因为结构差异太大了。所以 vdom diff 适合对 DOM 状态保留要求极高的场景比如页面里有很多携带状态的组件但与“避免截断闪烁”这件事本身没有直接帮助。5.4 方案对比表方案截断时表现性能实现成本适用场景全量重渲染链接/表格先文本后跳变代码高亮重复执行文档增长后急剧变差最低内容短、频率低、不敏感防抖全量渲染同上但节奏更差有明显的延迟停顿更新次数减少长文档仍卡低接受打断感的小工具分块完成 占位渲染表格先出骨架、代码块保持样式、段落增量更稳更新集中在尾部缓存已渲染区域中流式聊天、AI 编辑器首推vdom diff 全量解析跳动最小但类型突变仍无法避免解析成本仍在diff 成比例增加高前端框架深度绑定、DOM 状态敏感6. 常见问题与排查实录6.1 表格补全后为什么还是会闪一个很容易被忽略的细节是marked 的表格必须同时满足“表头行、分隔行、至少一个数据行”三个条件缺一个都不会生成 table token。所以即使是| 姓名 | 年龄 |\n| --- | --- |这种只有表头和分隔行的文本marked 也会当成普通段落因为数据行还没到。用骨架先行方案后中间态至少是一个table补全后从一张空表头变成填了数据的完整表格视觉连续性比“从段落变表格”好太多。但如果你是拿marked.parse(tail)直接渲染带分隔行的表头结果它生成的是段落就会出现一到数据行到达就突然变成表格的闪现。这个问题的根源不是渲染器而是 marked 对表格 token 的严格判定。6.2 代码高亮重复执行、页面卡死全量重渲染配合 highlight.js 时最典型的故障现象是页面越滚越卡最后直接无响应。原因是每次innerHTML重建 DOM 后highlight.js 都要对页面里所有的precode重新执行一次高亮。代码块越多开销越大。解决方法是加一个“已高亮”标记function highlightBlock(el) { if (el.dataset.highlighted yes) return; hljs.highlightElement(el); el.dataset.highlighted yes; }然后在更新 DOM 后只对新增的pre code调用highlightBlock。分块完成方案天然支持这个优化因为done部分不会再次插入页面自然不会被重复高亮。我在项目里还加了一个小细节代码块没闭合前不触发高亮只在围栏闭合、变成done之后才高亮。这样用户看到的不是“边输入边闪烁的上色代码”而是“代码完整后亮一下”。6.3 焦点和滚动位置被重置这个坑主要出在innerHTML全量替换上。页面一旦重建滚动容器会根据新内容重新计算滚动高度如果内容高度变化剧烈用户正在看的位置就会被顶上去。要解决这个问题最彻底的办法是避免全量innerHTML让更新落在“尾部 DOM 节点”上。在分块方案里done部分 HTML 不变只有tail部分在变化所以你可以只更新容器的最后一个子节点尽量不触碰前面的节点。代码层面可以先把doneHtml单独放在一个div里tailHtml放在一个tailContainer里每次更新只改tailContainer的内容。这样滚动位置基本不会跳。如果编辑器是 textarea 类输入法选字、光标位置也容易丢。恢复方式是在设置 value 后手动调用setSelectionRange把光标放到上次的位置。这个技巧在做“Markdown 源码编辑 实时预览”双栏时会用到。6.4 被截断的链接和图片怎么处理链接和图片是行内语法块级切分救不了。[这是一个链接这种半截文本在块级切分里就是一个普通段落的一部分marked 无法判断它未来是链接还是普通文本。我的策略是在tail层就“压着”不解析行内语法把段落内容转义后作为纯文本展示直到这个块变成done再交给 marked 完整解析。这样做的代价是“链接高亮会延迟一块出现”但换来的是“链接从普通文本变成可点击状态时不再闪”。实际体验下来延迟高亮比闪烁更可接受。图片更麻烦一点因为浏览器一旦看到img标签就会立刻发起网络请求。流式渲染期间如果频繁插入新的img可能造成大量无效请求。我的做法是在表格骨架之外再加一个图片占位策略行内图片语法完整后再替换为真实图片或者统一给img加loadinglazy。后者治标不治本但能显著减少无谓加载。6.5 渲染时机要不要等一个 chunk 就渲染不要。即使在分块完成方案下splitTail和marked.parser也不便宜而且单个 chunk 可能是一个非常短的分词单元比如一个空格、一个字母。每来一个就同步渲染渲染跑得再快也是浪费。推荐用requestAnimationFrame合并当帧的多次增量。这一步我已经在第 4 章给出了代码。你还可以在模型停顿明显时比如已经 500ms 没有新 chunk做一次强制刷新确保最终状态一定正确。这个“最终校准”备用方案是给“中途因为网络原因丢包”兜底的。最后再分享一个小技巧如果你现在准备把一个聊天机器人的渲染从“全量重渲染”改成流式方案我建议不要急着引入 vdom、不要急着做 token 级增量解析先把第 4 章的三个核心函数——splitTail、renderTail、StreamingMarkdownRenderer抄下来跑通。这个框架已经能解决 80% 的截断问题而且每部分都可以单独测试调试起来很清晰。调试时有个非常实用的套路把每次 push 触发后的splitTail结果打出来看。别光看最终页面你把done和tail分别打印就能知道是不是切多了、切少了。有一次我发现表格一直闪打印后发现是while回溯把表格整个留在了tail里导致done缓存一直没前进。这类问题看最终页面很难定位看切分日志一目了然。流式 Markdown 渲染的核心思路其实就一句话让“已完成”的部分保持稳定让“进行中”的部分有一个体面的中间态。把这句话落实到代码里你的渲染层就能在流畅度和准确性之间找到平衡。希望这篇实践笔记能帮你少踩几个坑。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。