AI对话应用Markdown渲染全栈实践:从安全解析到流式优化
1. 项目概述:为什么AI回复需要Markdown渲染?
最近在折腾一个AI对话应用的后端,当我把大模型生成的回复丢到前端时,遇到了一个挺典型的问题:回复里的代码块、列表、加粗文本全都变成了一坨纯文本,毫无结构可言。这体验实在太差了。用户,尤其是开发者,看到一段没有高亮和缩进的代码,或者一篇没有标题层级的说明,阅读效率会直线下降。这就是“实现AI回复支持Markdown渲染”这个需求的直接来源——它不是一个炫技功能,而是一个关乎用户体验和产品专业度的基础建设。
简单说,我们的目标就是让AI生成的、符合Markdown语法的文本,在前端界面上能够像在GitHub README或Typora里一样,被漂亮地渲染成富文本格式。这背后涉及几个核心环节:首先,AI模型(无论是GPT、Claude还是开源模型)需要被引导或具备能力生成结构化的Markdown文本;其次,后端需要安全地处理和传递这段文本;最后,前端需要一个可靠且高效的渲染器来将Markdown字符串转换为HTML并应用样式。整个过程,就像是一条从“原材料生产”到“精加工”再到“成品展示”的流水线,任何一个环节出问题,最终效果都会大打折扣。
这个项目适合所有正在集成AI对话能力的产品开发者、全栈工程师,或者对提升应用交互细节有追求的团队。无论你是用React、Vue、还是原生技术栈,核心思路都是相通的。接下来,我会拆解整个实现链条,从设计思路、工具选型到具体的代码实现和避坑指南,让你能快速在自己的项目里落地这个功能。
2. 核心思路与方案选型
实现这个功能,听起来简单,但细究起来有几个关键决策点。不同的选择,意味着不同的复杂度、安全性和性能表现。
2.1 渲染位置:前端渲染 vs 后端渲染
这是第一个要做的抉择,它直接影响架构。
前端渲染是目前最主流、最推荐的方式。即后端API返回原始的Markdown字符串,由浏览器端的JavaScript库来负责解析和渲染。它的优势非常明显:
- 减轻服务器压力:渲染的计算成本转移到了用户浏览器,服务器只做纯粹的文本传输。
- 响应更快:对于需要频繁更新对话内容的场景(如流式输出),前端可以边接收边渲染,体验流畅。
- 灵活性高:前端可以轻松集成代码高亮、数学公式渲染等增强功能,且样式完全由CSS控制,易于定制主题。
后端渲染则是指服务器端将Markdown转换成HTML后,再将HTML返回给前端直接插入。这种方式在前端框架不成熟或需要服务端静态化(如SEO)的场景下有一定价值,但对于动态、实时的AI对话应用来说,缺点突出:服务器负载高、流式输出实现复杂、前端样式耦合深。因此,除非有极强的特殊需求,否则一律建议采用前端渲染方案。
2.2 前端Markdown渲染器选型
选定前端渲染,下一步就是挑一个趁手的“武器库”。社区选择很多,但经过多次项目实战,我主要推荐以下两个,它们代表了不同的技术路线:
1. Marked + Highlight.js(组合方案)这是一个经典组合。Marked是一个速度极快的Markdown解析器,它将Markdown字符串转换为HTML字符串。但它只负责转换,不管样式和代码高亮。
import { marked } from 'marked'; const html = marked(markdownText);然后,你需要配合Highlight.js来实现代码块的高亮。这需要额外一步操作,通常是在marked的配置中设置highlight函数。
import hljs from 'highlight.js'; import 'highlight.js/styles/github.css'; // 引入一个样式主题 marked.setOptions({ highlight: function(code, lang) { const language = hljs.getLanguage(lang) ? lang : 'plaintext'; return hljs.highlight(code, { language }).value; } });优点:轻量、高速、控制粒度细。你可以分别更新或替换解析器和高亮库。缺点:需要自己组合和配置,对于数学公式(KaTeX)等扩展需要额外集成。
2. Remark + Rehype + Unified生态(现代函数式方案)这是一个更强大、更模块化的生态系统。Unified是一个处理文本的接口,Remark处理Markdown,Rehype处理HTML。你可以像组装管道一样组合插件。
import { unified } from 'unified'; import remarkParse from 'remark-parse'; import remarkRehype from 'remark-rehype'; import rehypeHighlight from 'rehype-highlight'; import rehypeStringify from 'rehype-stringify'; const processor = unified() .use(remarkParse) // 解析Markdown为语法树 .use(remarkRehype) // 将Markdown树转为HTML树 .use(rehypeHighlight) // 代码高亮插件 .use(rehypeStringify); // 将HTML树序列化为字符串 const html = await processor.process(markdownText);优点:极其灵活和强大。通过语法树(AST)操作,你可以实现任何复杂的转换(如自定义组件、链接重写、内容过滤)。插件生态丰富。缺点:概念稍复杂,初始学习曲线比Marked陡峭,包体积可能更大。
选型建议:
- 追求简单快捷:选择
Marked + Highlight.js。大部分项目够用。 - 项目复杂,需要深度定制(如将
![图片]渲染成自定义的懒加载组件):选择Remark生态。 - 使用React且希望直接渲染React组件:可以考虑
react-markdown库,它底层基于Remark生态,允许你将Markdown标签映射到你的React组件上,非常强大。
2.3 后端职责:净化与传递
后端不是旁观者。它的核心职责是安全。你不能直接把用户输入或AI生成的原始Markdown丢给前端渲染器,这可能导致XSS(跨站脚本)攻击。例如,如果有人让AI生成包含<script>alert('xss')</script>的“Markdown”,而你的渲染器配置不当,这段脚本就可能被执行。
因此,后端必须进行净化(Sanitization)。有两种主要方式:
- 输出时净化:在返回给前端前,使用库(如
DOMPurify的服务器端版本,或js-xss)对即将生成的HTML进行过滤,移除所有危险的标签和属性。 - 输入时约束/标记化:更优雅的方式是在调用AI模型时,就在系统提示词(System Prompt)中明确约束输出格式为“安全的Markdown”,并避免使用原生HTML。同时,后端可以解析Markdown,将其转换为安全的中间表示(如自定义的JSON结构),再传给前端由前端组件渲染,这能彻底杜绝HTML注入。
在我们的方案中,通常采用第一种,因为更简单。但务必记住:永远不要信任来自外部的数据,包括你认为“可控”的AI。
3. 前端渲染核心实现与深度配置
我们以最常用的Marked + Highlight.js组合在Vue/React项目中的实现为例,深入每一步的细节。
3.1 基础集成与安全加固
首先安装依赖:
npm install marked highlight.js # 或 yarn add marked highlight.js创建一个MarkdownRenderer.vue组件(React思路类似):
<template> <div class="ai-reply-content" v-html="renderedHtml"></div> </template> <script> import { marked } from 'marked'; import hljs from 'highlight.js'; import 'highlight.js/styles/github-dark.css'; // 选择一款喜欢的代码高亮主题 // 可选:引入DOMPurify在客户端做二次防护(如果后端已做,则非必须) // import DOMPurify from 'dompurify'; export default { name: 'MarkdownRenderer', props: { content: { type: String, required: true } }, computed: { renderedHtml() { if (!this.content) return ''; // 配置marked marked.setOptions({ highlight: (code, lang) => { const validLang = hljs.getLanguage(lang) ? lang : 'plaintext'; try { return hljs.highlight(code, { language: validLang }).value; } catch (err) { return hljs.highlight(code, { language: 'plaintext' }).value; } }, // 重要:禁用marked自带的HTML解析,防止XSS sanitize: false, // 我们后面会统一处理,所以这里先关闭 silent: true // 静默模式,解析错误不抛出异常 }); // 1. 将Markdown转换为原始HTML const rawHtml = marked(this.content); // 2. (关键安全步骤) 净化HTML // 方案A:如果引入了DOMPurify // const cleanHtml = DOMPurify.sanitize(rawHtml); // return cleanHtml; // 方案B:更激进的方案,使用一个简单的自定义过滤器(示例,生产环境建议用成熟库) // 这里仅作演示,实际请使用DOMPurify或类似库 const tempDiv = document.createElement('div'); tempDiv.innerHTML = rawHtml; // 移除所有<script>、<iframe>等危险标签 const scripts = tempDiv.querySelectorAll('script, iframe, object, embed'); scripts.forEach(el => el.remove()); return tempDiv.innerHTML; } } }; </script> <style scoped> .ai-reply-content { line-height: 1.6; /* 基础样式,如字体、颜色等 */ } /* 全局样式,用于修饰渲染后的内容 */ .ai-reply-content >>> pre { background-color: #f6f8fa; border-radius: 6px; padding: 16px; overflow: auto; } .ai-reply-content >>> code { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; padding: 0.2em 0.4em; background-color: rgba(175, 184, 193, 0.2); border-radius: 3px; } .ai-reply-content >>> img { max-width: 100%; height: auto; } </style>注意:上面的安全过滤方案B非常简陋,仅用于演示概念。在生产环境中,你必须使用像
DOMPurify这样经过严格测试的库来处理净化。DOMPurify可以精确地配置白名单,决定哪些标签和属性可以保留。
3.2 高级功能扩展:数学公式、流程图与自定义组件
基础渲染搞定后,产品经理可能会提新需求:“AI生成的数学公式和流程图也要能看啊”。没问题,我们可以通过扩展来实现。
1. 数学公式支持(KaTeX)数学公式在Markdown中通常用$$...$$(块级)或$...$(行内)表示。我们需要一个渲染引擎,KaTeX是性能最好的选择之一。
npm install katex修改我们的marked配置和组件:
import katex from 'katex'; import 'katex/dist/katex.css'; // 自定义渲染器,覆盖marked默认的代码和行内文本渲染 const renderer = new marked.Renderer(); const originalCodeRenderer = renderer.code; const originalParagraphRenderer = renderer.paragraph; // 处理行内数学公式 const inlineMathRegex = /\$(.+?)\$/g; // 处理块级数学公式 const blockMathRegex = /\$\$(.+?)\$\$/gs; renderer.code = function(code, language) { // 如果语言是`math`,则用KaTeX渲染 if (language === 'math') { try { return katex.renderToString(code, { displayMode: true, throwOnError: false }); } catch (e) { return `<pre>${e.message}</pre>`; } } // 否则交给原来的代码渲染器(即高亮) return originalCodeRenderer.call(this, code, language); }; // 在段落中扫描并替换行内数学公式 renderer.paragraph = function(text) { // 先处理块级公式(因为$$可能跨行,需要特殊处理,这里简化) // 更健壮的做法是在marked解析前,用正则将数学公式部分替换为占位符。 // 此处提供一个简单思路: let processedText = text; processedText = processedText.replace(blockMathRegex, (match, p1) => { try { return katex.renderToString(p1, { displayMode: true, throwOnError: false }); } catch (e) { return match; } }); processedText = processedText.replace(inlineMathRegex, (match, p1) => { try { return katex.renderToString(p1, { displayMode: false, throwOnError: false }); } catch (e) { return match; } }); return `<p>${processedText}</p>`; }; marked.setOptions({ renderer, // 使用自定义渲染器 highlight: /* ... 原有的高亮逻辑 ... */, });实操心得:在段落中混合处理公式和文本的正则替换比较棘手,容易出错。更推荐的做法是使用
Remark生态的remark-math和rehype-katex插件,它们能更优雅地处理AST,实现精准替换。
2. 流程图、时序图支持(Mermaid)Mermaid是一个用文本生成图表的强大工具。AI可以生成Mermaid语法,我们需要在前端激活它。
npm install mermaid实现思路是:在Markdown转换为HTML后,我们不需要在marked阶段处理。而是等HTML插入到DOM后,用Mermaid去查找并渲染所有带有class="mermaid"的代码块。
<script> import mermaid from 'mermaid'; export default { // ... 其他逻辑 mounted() { this.$nextTick(() => { this.initMermaid(); }); }, updated() { // 内容更新后重新尝试渲染Mermaid this.$nextTick(() => { this.initMermaid(); }); }, methods: { initMermaid() { // 配置Mermaid(可选) mermaid.initialize({ startOnLoad: false, // 我们手动触发 theme: 'default' }); // 找到所有.mermaid元素并渲染 // 注意:由于我们使用v-html,需要从当前组件根元素下查找 const mermaidElements = this.$el.querySelectorAll('pre code.language-mermaid'); mermaidElements.forEach((el) => { const parentPre = el.closest('pre'); const chartDefinition = el.textContent; try { // 创建一个新的div用于Mermaid渲染 const mermaidDiv = document.createElement('div'); mermaidDiv.className = 'mermaid'; mermaidDiv.textContent = chartDefinition; // 替换原来的pre元素 parentPre.parentNode.replaceChild(mermaidDiv, parentPre); // 手动渲染这个div mermaid.init(undefined, mermaidDiv); } catch (error) { console.error('Mermaid渲染失败:', error); } }); } } }; </script>注意事项:
Mermaid的渲染是异步的,且会替换DOM节点。在动态内容中(如AI流式输出),需要小心处理渲染时机,避免重复渲染或节点丢失。一个常见的技巧是给包含Mermaid的代码块一个特殊的标识,并在内容稳定后(如流式输出结束)批量渲染。
3. 自定义组件渲染(以React + react-markdown为例)如果你希望将![图片]渲染成自带懒加载、错误处理的<Image>组件,或者将链接渲染成跟踪点击事件的组件,react-markdown是绝佳选择。
import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; // 支持表格、删除线等扩展语法 import rehypeHighlight from 'rehype-highlight'; import 'highlight.js/styles/github-dark.css'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; // 另一种高亮方案 import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; const CustomImage = ({ src, alt }) => ( <img src={src} alt={alt} loading="lazy" style={{ maxWidth: '100%' }} onError={(e) => { e.target.src = '/fallback-image.png'; }} /> ); const CustomLink = ({ href, children }) => ( <a href={href} target="_blank" rel="noopener noreferrer" onClick={() => trackClick(href)}> {children} </a> ); const AiReply = ({ content }) => { return ( <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]} components={{ // 将原生标签映射到自定义组件 img: CustomImage, a: CustomLink, // 自定义代码高亮组件(覆盖默认) code({ node, inline, className, children, ...props }) { const match = /language-(\w+)/.exec(className || ''); return !inline && match ? ( <SyntaxHighlighter style={vscDarkPlus} language={match[1]} PreTag="div" {...props} > {String(children).replace(/\n$/, '')} </SyntaxHighlighter> ) : ( <code className={className} {...props}> {children} </code> ); } }} > {content} </ReactMarkdown> ); };这种方式将Markdown元素与你的React组件系统完美融合,提供了最大的灵活性和控制力。
4. 后端协同与流式输出优化
前端渲染器准备好了,后端的工作同样重要,尤其是在追求实时体验的流式输出场景下。
4.1 设计安全的API接口
后端API返回的数据结构应该清晰。通常,我们会返回一个JSON对象,包含AI回复的文本和可能的元数据。
{ "id": "msg_123", "role": "assistant", "content": "以下是解决方案:\n\n```python\ndef quick_sort(arr):\n if len(arr) <= 1:\n return arr\n pivot = arr[len(arr) // 2]\n left = [x for x in arr if x < pivot]\n middle = [x for x in arr if x == pivot]\n right = [x for x in arr if x > pivot]\n return quick_sort(left) + middle + quick_sort(right)\n```\n\n这个算法的时间复杂度是**O(n log n)**。", "created_at": 1698301200 }content字段就是包含Markdown的纯文本。关键点:在将content存入数据库或返回前,应进行必要的清理。虽然主要净化在前端,但后端可以做一些预防性措施,例如:
- 过滤掉明显恶意的HTML标签(如
<script>、<iframe>)。 - 限制Markdown的嵌套深度,防止DoS攻击(虽然罕见)。
- 如果AI模型支持,在系统指令中明确:“请只用Markdown语法,不要使用原生HTML。”
4.2 流式输出(Streaming)场景下的渲染策略
当AI生成内容很长时,为了用户体验,我们常采用流式输出(Server-Sent Events或WebSocket),让文字一个字一个字地“打”出来。这时,Markdown渲染就面临挑战:你不能等一整段Markdown接收完再渲染,那样就失去了流式的意义;但边接收边渲染,如果遇到不完整的Markdown语法(比如代码块只收到了开始符```,没有结束符),渲染器会出错或显示混乱。
解决方案是:增量渲染与语法缓冲。
- 累积缓冲区:前端维护一个缓冲区,存放从流中接收到的原始文本碎片。
- 智能触发渲染:不是每收到一个字符就渲染整个缓冲区。而是设定一个触发策略,例如:
- 按段落触发:当检测到换行符
\n\n时,渲染缓冲区中上一个完整段落的内容。 - 按语法块触发:当检测到代码块结束符
```或列表项结束等相对完整的Markdown结构时,渲染该结构。 - 定时触发:每收到N个字符或每过M毫秒渲染一次,作为保底策略。
- 按段落触发:当检测到换行符
- 渲染局部:渲染时,不是每次都渲染全部历史内容,而是只渲染新完成的部分,并将其追加到DOM中。对于仍在输入中的部分(如一个未结束的代码块),可以先用纯文本或特殊样式(灰色背景)显示,待收到结束符后再重新渲染该部分。
这是一个简化的示例逻辑:
// 前端流式处理示例 let buffer = ''; let partialBuffer = ''; // 存放可能不完整的最后一段/块 const eventSource = new EventSource('/api/chat/stream'); eventSource.onmessage = (event) => { const chunk = event.data; buffer += chunk; partialBuffer += chunk; // 尝试找到一个合理的断点(如句号+空格,或代码块结束) const lastCompleteSentenceEnd = buffer.lastIndexOf('。 ') + 1; // 简单示例 const lastCodeBlockEnd = buffer.lastIndexOf('\n```\n'); let renderCutPoint = Math.max(lastCompleteSentenceEnd, lastCodeBlockEnd); if (renderCutPoint > 0) { const toRender = buffer.substring(0, renderCutPoint); const toKeep = buffer.substring(renderCutPoint); // 渲染 toRender appendToDOM(markdownToHtml(toRender)); buffer = toKeep; partialBuffer = toKeep; } else { // 没有找到完整断点,用特殊样式显示partialBuffer(如浅灰色) displayPartialContent(markdownToHtmlPartial(partialBuffer)); } }; // 流结束时,渲染剩余所有内容 eventSource.onclose = () => { appendToDOM(markdownToHtml(buffer)); };实操心得:流式Markdown渲染是前端的一个难点,没有完美的方案。需要在“实时性”和“渲染正确性”之间做权衡。一个折中的好办法是,对于代码块这类结构敏感的内容,可以延迟渲染,即先以纯文本显示,待其完整后再进行语法高亮。这比渲染出错的体验要好。
5. 样式定制、性能优化与常见问题
5.1 样式定制:让渲染结果融入你的产品
默认的Markdown渲染样式可能很简陋。你需要精心设计CSS,使其符合产品的设计语言。
/* Markdown内容容器基础样式 */ .ai-reply-content { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; color: #24292f; line-height: 1.8; word-wrap: break-word; } /* 标题 */ .ai-reply-content h1, .ai-reply-content h2 { padding-bottom: 0.3em; border-bottom: 1px solid #eaecef; margin-top: 1.5em; margin-bottom: 0.8em; } .ai-reply-content h3, .ai-reply-content h4 { margin-top: 1.2em; } /* 代码块 */ .ai-reply-content pre { background-color: #f6f8fa; border-radius: 8px; padding: 1em; overflow: auto; margin: 1em 0; border: 1px solid #e1e4e8; } .ai-reply-content code:not(pre code) { background-color: rgba(175, 184, 193, 0.2); padding: 0.2em 0.4em; border-radius: 4px; font-size: 0.9em; } /* 引用块 */ .ai-reply-content blockquote { border-left: 4px solid #ddd; padding-left: 1em; color: #666; margin: 1em 0; font-style: italic; } /* 表格 */ .ai-reply-content table { border-collapse: collapse; width: 100%; margin: 1em 0; } .ai-reply-content th, .ai-reply-content td { border: 1px solid #dfe2e5; padding: 0.5em 1em; text-align: left; } .ai-reply-content th { background-color: #f6f8fa; font-weight: 600; } /* 列表 */ .ai-reply-content ul, .ai-reply-content ol { padding-left: 2em; margin: 1em 0; } .ai-reply-content li { margin-bottom: 0.3em; }使用CSS作用域(如Vue的scoped,或CSS-in-JS)来确保样式只影响AI回复区域,不会污染页面其他部分。
5.2 性能优化要点
- 避免重复渲染:在React/Vue中,确保
MarkdownRenderer组件的contentprop只在真正变化时才更新。使用React.memo或Vue的computed属性进行缓存。 - 代码高亮懒加载:
Highlight.js支持按需加载语言包。如果AI回复中不常出现冷门语言,可以配置只加载常用语言(如javascript,python,java,bash等),减少初始包体积。import hljs from 'highlight.js/lib/core'; import javascript from 'highlight.js/lib/languages/javascript'; import python from 'highlight.js/lib/languages/python'; hljs.registerLanguage('javascript', javascript); hljs.registerLanguage('python', python); // 在highlight函数中,对于未注册的语言,回退到'plaintext' - 虚拟滚动:如果对话历史非常长,包含大量Markdown内容,直接渲染所有DOM节点会导致性能下降。考虑使用虚拟滚动技术(如
react-window、vue-virtual-scroller),只渲染可视区域内的内容。 - Worker隔离:
Marked的解析和Highlight.js的高亮在超长文本时可能是CPU密集型任务。可以考虑将这些操作放到Web Worker中,避免阻塞主线程导致页面卡顿。
5.3 常见问题与排查实录
问题1:XSS安全漏洞
- 现象:用户发现回复中包含了可执行的JavaScript脚本。
- 根因:没有对渲染前的HTML或Markdown进行净化,或者净化规则有误。
- 解决:
- 强制使用净化库:在前端或后端,使用
DOMPurify。 - 配置严格的净化白名单:只允许安全的标签(
p,b,i,code,pre,ul,li...)和有限的属性(href,src需验证协议)。 - 测试:构造包含
<script>,onerror=,javascript:等payload的输入,测试渲染结果是否被安全过滤。
- 强制使用净化库:在前端或后端,使用
问题2:代码块语言检测失败或高亮错乱
- 现象:代码块没有高亮,或者高亮颜色乱七八糟。
- 根因:
- AI生成的代码块语言标识不正确(如写成了
```jsx但你的高亮库不支持jsx)。 Highlight.js自动检测语言不准确。
- AI生成的代码块语言标识不正确(如写成了
- 解决:
- 规范化提示词:在给AI的系统指令中,明确要求使用常见的语言标识符(如
python,javascript,bash,json等)。 - 提供兜底逻辑:在高亮函数中,先检测语言是否被支持,不支持则回退到
plaintext或javascript。 - 手动指定:如果场景允许,可以让用户在发送请求时指定代码语言。
- 规范化提示词:在给AI的系统指令中,明确要求使用常见的语言标识符(如
问题3:流式输出时格式混乱
- 现象:文字逐个出现时,Markdown格式(如列表、代码块)中途断裂,显示异常。
- 根因:渲染时机不当,在不完整的语法片段上进行了渲染。
- 解决:
- 实现“缓冲-渲染”策略:如上文所述,积累一定量的字符或等待一个完整语法单元后再渲染。
- 区分“稳定内容”和“输入中内容”:对已完成的段落进行完整Markdown渲染,对正在输入的部分用纯文本或特殊样式显示。
- 考虑非流式替代方案:如果内容本身不长,或者对实时性要求不是极高,可以放弃逐字输出,改为分段输出(如AI每生成一个完整句子或段落就返回一次)。
问题4:图片加载慢或失败影响布局
- 现象:回复中的图片加载卡顿,或者失败后显示破裂图标。
- 解决:
- 懒加载:为
<img>标签添加loading="lazy"属性。 - 错误处理:监听
onerror事件,替换为统一的占位图或隐藏该图片。 - 尺寸限制与CDN:后端可以对AI生成或用户上传的图片进行压缩,并存储到CDN。前端可以指定
max-width: 100%防止图片撑破布局。 - 使用自定义图片组件:如前面
react-markdown示例所示,用自定义组件统一管理图片的加载、错误和点击行为。
- 懒加载:为
问题5:移动端样式适配
- 现象:在手机上,表格或长代码行会横向溢出屏幕,需要左右滑动才能看全,体验差。
- 解决:
- 表格:为表格容器添加
overflow-x: auto样式,使其可以横向滚动。
.ai-reply-content table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; /* iOS平滑滚动 */ }- 长代码行:同样为
<pre>标签添加overflow-x: auto。也可以使用CSS属性white-space: pre-wrap; word-break: break-all;让长单词或URL换行,但这可能破坏代码结构。更好的办法是保持可滚动,并确保滚动条在移动端可用。
- 表格:为表格容器添加
实现AI回复的Markdown渲染,是一个从后端提示词工程、数据安全,到前端解析、渲染、样式、性能的完整链条。每个环节都需要仔细考量。从我的经验来看,前期多花时间在方案选型和安全设计上,后期就能避免很多棘手的“坑”。尤其是流式渲染和自定义组件这两块,根据产品需求的复杂度,选择合适的实现路径,不要过度设计。
