uniapp使用rich-text流式 Markdown 换行问题与解决方案

流式 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 在出现 \nincludes("\n") 为 true)。
  • closeIncompleteMarkdown 只做格式修补(补双换行、未闭合 ** 等),不会去掉 \n,因此"数据没有换行"可排除。

3.2 渲染层面(核心)

  • Markdown 组件(如 zero-markdown-view) 一般用 marked 等库把字符串转为 AST/HTML,再转为 rich-text 的 nodes
  • 流式场景 :content 是递增的 (每次只多一小段字符)。组件内部可能是:
    • 每次 markdown prop 变化都重新完整解析 ,但 rich-text / 小程序对 nodes 的更新策略 导致在"频繁增量更新"时,未按最新全文重新布局;或
    • 存在缓存/复用,导致在"从无换行到有换行"的临界点,没有用最新字符串重算 nodes。
  • 完整内容渲染:一次性传入完整字符串,只解析一次,nodes 结构正确,故展示正常。
  • 思考步骤 stepContent :虽然也是流式接口,但每条 step 的 content 在业务上往往是整段更新(如一整段"本轮提问..."),而不是同一段文本的逐字追加,因此每次更新都更接近"完整内容一次解析",换行正常。

结论:问题不在"数据有没有 \n",而在"流式逐字追加 + 当前 Markdown/rich-text 的更新机制"下,nodes 未随最新全文正确更新,导致换行/列表结构丢失。


四、为何 displayContent 更改有时"不触发重新渲染"

  • displayContentcomputed ,依赖 props.content,content 变则 displayContent 变,理论上会触发依赖它的模板更新
  • 若使用 rich-text 的 nodes 由组件内部根据 markdown 计算:
    • 可能 nodes 是内部状态 ,只在组件 挂载或 key 变化 时用 markdown 重新生成;
    • marked 解析结果 在某种增量/缓存策略下未随 markdown 每次更新而完全重算。
  • 表现就是:displayContent 已变(含 \n),但界面不更新或仍显示旧结构 。此时仅改 props/computed 不够,需要强制组件按"新内容"重新挂载

五、解决方案

方案一::key 强制在"流式 + 出现换行"时重新挂载(已采用)

思路 :在仅流式拼接内容中已出现换行 时,改变主气泡 zero-markdown-viewkey,触发一次重新挂载,用当前完整 content 重新解析,从而得到正确的列表/段落结构。

实现要点

  • 主气泡的 zero-markdown-view 绑定 :key="mainBubbleKey"
  • mainBubbleKeycomputed
    • 非流式 (如 !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 绑定为 contentcontent.length)。
  • 缺点 :流式下 content 变化非常频繁,会导致频繁重挂载或重解析,性能差、可能闪烁,一般不采用。

六、推荐实践小结

  1. 保留 closeIncompleteMarkdown:作为 Markdown 展示前的统一预处理,保证句号后、标题后、列表前的换行与未闭合符号被修补。
  2. 流式主气泡使用动态 key
    mainBubbleKeyisTyping 且 displayContent 含 \n 时与 无换行时 区分,使 仅在流式且出现换行时 对主气泡的 zero-markdown-view 做一次重新挂载;历史消息渲染时 isTyping 为 false,key 稳定,不触发多余 remount。
  3. 避免用 content 全文或 length 作为 key:否则每次流式追加都会 remount,造成性能浪费。
  4. 调试"是否带换行"
    • 控制台想看原始字符串中的 \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 closeIncompleteMarkdownnormalizeLineBreaks 等预处理
uni_modules/zero-markdown-view Markdown → nodes 的解析与渲染,流式下对 nodes 的更新策略决定是否需外部 key 触发 remount

八、通用经验(可复用到其他项目)

  • 流式 + Markdown/rich-text :若"完整内容一次渲染正常,流式追加不正常",优先考虑数据是否带 \n (用 JSON.stringifyincludes('\n') 确认);若数据无误,则多为组件在增量更新时未按最新全文重算结构 ,可通过条件化 :key 在关键时机(如首次出现换行)强制 remount 解决。
  • computed 更新了但视图不更新 :若依赖的是第三方组件的内部 nodes/状态,仅改 props 可能不够,需要 key 触发重新挂载 或联系组件作者支持"强制用当前 markdown 重算 nodes"。
  • 性能 :remount 成本高于单次解析,因此 key 应只在必要时变化(例如流式下"无换行 → 有换行"一次),避免用整段 content 或 length 作为 key。

相关推荐
We་ct2 小时前
LeetCode 49. 字母异位词分组:经典哈希解法解析+易错点规避
前端·算法·leetcode·typescript·哈希算法
CHU7290352 小时前
废品回收小程序前端功能设计逻辑与实践
前端·小程序
lzhdim2 小时前
微星首款全白设计的M-ATX小板! MPG B850M EDGE TIMAX WIF刀锋 钛评测:性能媲美顶级X870E主板
前端·edge
恋猫de小郭2 小时前
小米 HyperOS 4 大变样?核心应用以 Rust / Flutter 重写,不兼容老系统
android·前端·人工智能·flutter·ios
摘星编程2 小时前
OpenHarmony环境下React Native:Loading全屏加载遮罩
javascript·react native·react.js
李火火的安全圈2 小时前
基于Yakit、Wavely实现CVE-2025-55182(React Server Components(RSC)) 反序列化漏洞挖掘和POC编写
前端·react.js
Orange_sparkle2 小时前
dify的web页面如何传入user用户信息进行对话,而不是uuid
前端·人工智能
Amumu121382 小时前
Vue Router 和 常用组件库
前端·javascript·vue.js
木子啊2 小时前
Uni-app导航栏适配终极避坑指南
uni-app·自定义导航栏·导航栏