流式 Markdown 换行问题与解决方案(通用经验总结)
- 总结: 大白话,流式输出的时候'/n'后面拼接的时候已经渲染为p节点了,就算后面给了换行符也不会渲染,深度原理不清楚。只能通过key强制出现换行符的时候手动强行渲染更新来解决。
一、问题描述
在聊天场景中,流式拼接 AI 回复时,主气泡内的 Markdown 内容(通过 zero-markdown-view / rich-text nodes 渲染)不按换行符分段,整段文字挤在一行或一个段落里;而以下两种情况表现正常:
- 浏览器控制台 :
console.log输出的原始字符串里能看到\n,且控制台展示为多行。 - 聊天记录完整渲染:从历史记录加载的完整 content 一次性渲染时,列表、段落换行正常。
即:同一份带 \n 的字符串,流式逐字追加时渲染异常,一次性给完整内容时渲染正常。
二、现象对比
| 场景 | 数据是否带 \n |
展示效果 |
|---|---|---|
| 流式拼接(主气泡 content) | 是(已确认) | 不换行,整段在一个 <p> 里 |
| 聊天记录完整渲染 | 是 | 正常换行,列表为 ol/li 等 |
| 思考步骤 stepContent | 是 | 正常换行(每次整段替换,非逐字追加) |
典型"异常"的 nodes 结构 :整段在一个 p 里,列表未拆成 ol/li:
json
[{"name":"p","attrs":{...},"children":[{"type":"text","text":"1.问题一 2.问题二 3.问题三"}],"type":"node"}]
期望结构:按换行拆成列表或段落:
json
[{"name":"ol","attrs":{},"children":[{"name":"li",...},{"name":"li",...}]}]
三、根因分析
3.1 数据层面
- 流式接口返回的 content 确实包含换行符
\n(控制台可见,displayContent在出现\n后includes("\n")为 true)。 closeIncompleteMarkdown只做格式修补(补双换行、未闭合**等),不会去掉\n,因此"数据没有换行"可排除。
3.2 渲染层面(核心)
- Markdown 组件(如 zero-markdown-view) 一般用 marked 等库把字符串转为 AST/HTML,再转为 rich-text 的 nodes。
- 流式场景 :content 是递增的 (每次只多一小段字符)。组件内部可能是:
- 每次
markdownprop 变化都重新完整解析 ,但 rich-text / 小程序对 nodes 的更新策略 导致在"频繁增量更新"时,未按最新全文重新布局;或 - 存在缓存/复用,导致在"从无换行到有换行"的临界点,没有用最新字符串重算 nodes。
- 每次
- 完整内容渲染:一次性传入完整字符串,只解析一次,nodes 结构正确,故展示正常。
- 思考步骤 stepContent :虽然也是流式接口,但每条 step 的 content 在业务上往往是整段更新(如一整段"本轮提问..."),而不是同一段文本的逐字追加,因此每次更新都更接近"完整内容一次解析",换行正常。
结论:问题不在"数据有没有 \n",而在"流式逐字追加 + 当前 Markdown/rich-text 的更新机制"下,nodes 未随最新全文正确更新,导致换行/列表结构丢失。
四、为何 displayContent 更改有时"不触发重新渲染"
displayContent是 computed ,依赖props.content,content 变则 displayContent 变,理论上会触发依赖它的模板更新。- 若使用 rich-text 的 nodes 由组件内部根据
markdown计算:- 可能 nodes 是内部状态 ,只在组件 挂载或 key 变化 时用
markdown重新生成; - 或 marked 解析结果 在某种增量/缓存策略下未随
markdown每次更新而完全重算。
- 可能 nodes 是内部状态 ,只在组件 挂载或 key 变化 时用
- 表现就是:displayContent 已变(含
\n),但界面不更新或仍显示旧结构 。此时仅改 props/computed 不够,需要强制组件按"新内容"重新挂载。
五、解决方案
方案一::key 强制在"流式 + 出现换行"时重新挂载(已采用)
思路 :在仅流式拼接 且内容中已出现换行 时,改变主气泡 zero-markdown-view 的 key,触发一次重新挂载,用当前完整 content 重新解析,从而得到正确的列表/段落结构。
实现要点:
- 主气泡的
zero-markdown-view绑定:key="mainBubbleKey"。 mainBubbleKey为 computed :- 若 非流式 (如
!props.isTyping):key 固定为如"content-final",不因内容变化而变,聊天记录渲染不触发 remount。 - 若 流式 (
props.isTyping === true):- 当
displayContent已包含\n时,key 为"content-streaming-with-nl"; - 当尚未出现
\n时,key 为"content-streaming-no-nl"。
- 当
- 这样仅在流式且第一次出现换行时 key 从
content-streaming-no-nl变为content-streaming-with-nl,触发一次重新挂载;后续流式追加只要仍有\n,key 不变,避免频繁 remount。
- 若 非流式 (如
优点:
- 改动小,只增加一个 computed key,且仅影响流式场景。
- 不改变现有
closeIncompleteMarkdown逻辑,无需在每次 content 变化时都做重解析,性能可控。
注意:若希望"每次新出现一个换行就重挂载一次"可改为 key 中包含换行次数或内容哈希,但通常"第一次出现换行时挂载一次"即可缓解问题。
方案二:继续使用 closeIncompleteMarkdown(预处理)
- 作用 :统一换行符、在句号/标题后补双换行、补未闭合的
**等,使 marked 能正确识别段落和列表。 - 局限 :只能改善"解析输入",无法解决"组件在流式更新时不用最新全文重算 nodes"的问题,因此单独使用 在流式场景下仍可能不换行,需配合 方案一(key) 使用。
方案三:每次 content 变化都强制重新解析(不推荐)
- 在业务层或封装层,每次
content变化都生成新的 nodes 或强制 remount(例如 key 绑定为content或content.length)。 - 缺点 :流式下 content 变化非常频繁,会导致频繁重挂载或重解析,性能差、可能闪烁,一般不采用。
六、推荐实践小结
- 保留
closeIncompleteMarkdown:作为 Markdown 展示前的统一预处理,保证句号后、标题后、列表前的换行与未闭合符号被修补。 - 流式主气泡使用动态 key :
mainBubbleKey在 isTyping 且 displayContent 含\n时与 无换行时 区分,使 仅在流式且出现换行时 对主气泡的zero-markdown-view做一次重新挂载;历史消息渲染时 isTyping 为 false,key 稳定,不触发多余 remount。 - 避免用 content 全文或 length 作为 key:否则每次流式追加都会 remount,造成性能浪费。
- 调试"是否带换行" :
- 控制台想看原始字符串中的
\n,可用:
console.log(JSON.stringify(content))
这样\n会显示为字面量"\\n",便于确认。 - 或用:
console.log('hasNL:', content.includes('\n'), 'content:', content)
确认数据源是否含换行。
- 控制台想看原始字符串中的
七、涉及文件与代码位置(本项目中)
| 文件 | 说明 |
|---|---|
src/pages/chat/components/DoctorBubbleWithThinking.vue |
主气泡 zero-markdown-view 使用 :key="mainBubbleKey",displayContent = closeIncompleteMarkdown(props.content) |
src/utils/markdown.js |
closeIncompleteMarkdown、normalizeLineBreaks 等预处理 |
uni_modules/zero-markdown-view |
Markdown → nodes 的解析与渲染,流式下对 nodes 的更新策略决定是否需外部 key 触发 remount |
八、通用经验(可复用到其他项目)
- 流式 + Markdown/rich-text :若"完整内容一次渲染正常,流式追加不正常",优先考虑数据是否带
\n(用JSON.stringify或includes('\n')确认);若数据无误,则多为组件在增量更新时未按最新全文重算结构 ,可通过条件化:key在关键时机(如首次出现换行)强制 remount 解决。 - computed 更新了但视图不更新 :若依赖的是第三方组件的内部 nodes/状态,仅改 props 可能不够,需要 key 触发重新挂载 或联系组件作者支持"强制用当前 markdown 重算 nodes"。
- 性能 :remount 成本高于单次解析,因此 key 应只在必要时变化(例如流式下"无换行 → 有换行"一次),避免用整段 content 或 length 作为 key。