多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

AI对话应用Markdown渲染全栈实践:从安全解析到流式优化

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处理MarkdownRehype处理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生成包含scriptalert(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 classai-reply-content v-htmlrenderedHtml/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. 流程图、时序图支持MermaidMermaid是一个用文本生成图表的强大工具。AI可以生成Mermaid语法我们需要在前端激活它。npm install mermaid实现思路是在Markdown转换为HTML后我们不需要在marked阶段处理。而是等HTML插入到DOM后用Mermaid去查找并渲染所有带有classmermaid的代码块。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} loadinglazy style{{ maxWidth: 100% }} onError{(e) { e.target.src /fallback-image.png; }} / ); const CustomLink ({ href, children }) ( a href{href} target_blank relnoopener 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]} PreTagdiv {...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\npython\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 常见问题与排查实录问题1XSS安全漏洞现象用户发现回复中包含了可执行的JavaScript脚本。根因没有对渲染前的HTML或Markdown进行净化或者净化规则有误。解决强制使用净化库在前端或后端使用DOMPurify。配置严格的净化白名单只允许安全的标签p,b,i,code,pre,ul,li...和有限的属性href,src需验证协议。测试构造包含script,onerror,javascript:等payload的输入测试渲染结果是否被安全过滤。问题2代码块语言检测失败或高亮错乱现象代码块没有高亮或者高亮颜色乱七八糟。根因AI生成的代码块语言标识不正确如写成了jsx但你的高亮库不支持jsx。Highlight.js自动检测语言不准确。解决规范化提示词在给AI的系统指令中明确要求使用常见的语言标识符如python,javascript,bash,json等。提供兜底逻辑在高亮函数中先检测语言是否被支持不支持则回退到plaintext或javascript。手动指定如果场景允许可以让用户在发送请求时指定代码语言。问题3流式输出时格式混乱现象文字逐个出现时Markdown格式如列表、代码块中途断裂显示异常。根因渲染时机不当在不完整的语法片段上进行了渲染。解决实现“缓冲-渲染”策略如上文所述积累一定量的字符或等待一个完整语法单元后再渲染。区分“稳定内容”和“输入中内容”对已完成的段落进行完整Markdown渲染对正在输入的部分用纯文本或特殊样式显示。考虑非流式替代方案如果内容本身不长或者对实时性要求不是极高可以放弃逐字输出改为分段输出如AI每生成一个完整句子或段落就返回一次。问题4图片加载慢或失败影响布局现象回复中的图片加载卡顿或者失败后显示破裂图标。解决懒加载为img标签添加loadinglazy属性。错误处理监听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渲染是一个从后端提示词工程、数据安全到前端解析、渲染、样式、性能的完整链条。每个环节都需要仔细考量。从我的经验来看前期多花时间在方案选型和安全设计上后期就能避免很多棘手的“坑”。尤其是流式渲染和自定义组件这两块根据产品需求的复杂度选择合适的实现路径不要过度设计。
返回列表