Markdown渲染难题:在AI对话流中优雅处理代码高亮与XSS防御

在构建AI对话应用(如ChatGPT类产品)的过程中,我们往往会沉浸在模型能力的调优上,却容易忽视前端展示层的“深坑”。当大模型返回包含Markdown格式的复杂内容,尤其是代码块时,如何将其安全、美观地渲染到DOM中,成为了一个棘手的工程问题。

一、背景与痛点:AI对话流中的“暗礁”

与传统的CMS内容渲染不同,AI对话流具有两个显著特征:流式输出内容不可控

  1. 流式输出的解析难题:大模型通常以SSE(Server-Sent Events)方式逐字返回数据。当返回一段代码块时,可能会出现“截断”现象。例如,模型刚输出了 ```javascript,还没输出闭合的 ```,此时如果粗暴渲染,整个页面布局可能会因为未闭合的标签而崩坏,或者代码块被当作普通文本解析,用户体验极差。
  2. XSS(跨站脚本攻击)隐患:Markdown允许混入HTML标签。虽然大模型通常经过对齐训练,但在特定Prompt诱导下,或者当你接入外部知识库检索增强生成(RAG)时,返回的内容可能包含恶意脚本。比如返回 <img src=x onerror=alert(1)>,如果直接使用 v-htmldangerouslySetInnerHTML 渲染,后果不堪设想。

作为从Web开发转型AI的工程师,我们必须用工程化的手段解决这些问题,而不是简单地引入一个库了事。

二、核心技术讲解:构建安全渲染管道

要解决上述问题,我们需要构建一个三阶段的渲染管道:解析 → 清洗 → 高亮

1. 选择合适的解析器

很多开发者习惯使用 markedmarkdown-it。在AI场景下,markdown-it 因为其强大的插件生态和更严谨的解析规则,往往更适合企业级应用。我们需要利用它来处理流式传输中的边界情况,并生成标准的HTML Token。

2. XSS防御机制

这是最关键的一环。永远不要信任LLM输出的内容。仅仅依赖Markdown解析器的默认安全策略是不够的。我们需要引入 DOMPurify,这是一个 XSS 清理专家,能够通过白名单机制过滤掉危险的HTML标签和属性。

3. 代码高亮策略

后端渲染高亮(Pygments等)会增加服务端压力,且不适合流式场景。前端渲染是主流选择。highlight.jsPrism.js 是常用库。在AI场景下,推荐使用 highlight.js,因为它支持自动语言检测,这对模型有时未指定语言标签的代码块非常友好。

三、实战代码:构建安全的Markdown渲染组件

下面我们以 React + TypeScript 为例,封装一个支持流式更新、XSS防御且带高亮的渲染组件。

技术栈
- react-markdown: 轻量级,易于集成
- remark-gfm: 支持GitHub Flavored Markdown(表格、删除线等)
- rehype-highlight: 代码高亮插件
- dompurify: XSS防御

1. 安装依赖

npm install react-markdown remark-gfm rehype-highlight dompurify
npm install --save-dev @types/dompurify

2. 封装安全渲染组件

这是一个经过生产环境验证的组件封装模式:

import React, { useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import DOMPurify from 'dompurify';
import 'highlight.js/styles/github-dark.css'; // 引入高亮样式

interface MarkdownRendererProps {
  content: string; // AI返回的原始Markdown文本
}

const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content }) => {

  // 核心逻辑:在渲染前进行清洗
  // 虽然react-markdown默认不解析HTML,但为了防止通过props注入或特殊配置开启HTML的情况
  // 结合DOMPurify是双重保险。
  // 注意:react-markdown默认是安全的,但如果我们允许HTML标签,就必须清洗。
  // 这里演示一种“防御性编程”的思路:如果你需要支持部分HTML标签,请使用DOMPurify。

  const sanitizedContent = useMemo(() => {
    // 假设场景:我们需要允许部分安全的HTML标签,如 <span> 或 <sub>
    // 如果完全禁止HTML,react-markdown默认配置即可。
    // 这里演示如何清洗内容,防止XSS
    return DOMPurify.sanitize(content, {
      ALLOWED_TAGS: ['span', 'sub', 'sup', 'br', 'b', 'i'], // 仅允许极少数安全的标签
      ALLOWED_ATTR: [] // 禁止所有属性,防止事件注入
    });
  }, [content]);

  return (
    <div className="markdown-body">
      <ReactMarkdown
        // 1. 支持 GFM (表格、任务列表等)
        remarkPlugins={[remarkGfm]}

        // 2. 代码高亮插件
        rehypePlugins={[rehypeHighlight]}

        // 3. 自定义组件渲染逻辑(处理代码块的特殊样式)
        components={{
          // 自定义代码块渲染,增加复制按钮容器或行号逻辑
          code({ node, inline, className, children, ...props }) {
            const match = /language-(\w+)/.exec(className || '');
            return !inline && match ? (
              <div className="code-block-wrapper">
                <div className="code-lang-label">{match[1]}</div>
                <code className={className} {...props}>
                  {children}
                </code>
              </div>
            ) : (
              <code className={className} {...props}>
                {children}
              </code>
            );
          }
        }}
      >
        {/* 注意:如果使用DOMPurify清洗过,这里传入的是HTML安全文本 */}
        {/* 但react-markdown期望的是Markdown源码。
            实际上,最佳实践是:让react-markdown处理Markdown,
            仅在你确实需要渲染原始HTML片段时,才在特定组件中使用DOMPurify。
            这里为了展示双重保险,如果content包含恶意HTML,会被DOMPurify移除。 */}
        {sanitizedContent}
      </ReactMarkdown>
    </div>
  );
};

export default MarkdownRenderer;

3. 处理流式输出的“抖动”问题

在流式输出中,代码块未闭合会导致页面布局混乱。我们需要一个预处理逻辑,在渲染前检测并补全未闭合的代码块。

# Python后端示例:流式文本预处理工具
def preprocess_streaming_markdown(text: str) -> str:
    """
    检测未闭合的代码块并临时补全,防止前端渲染崩坏
    """
    # 统计代码块标记的数量
    # 使用简单的状态机逻辑
    count = text.count("```")

    # 如果代码块标记是奇数,说明有未闭合的块
    if count % 2 != 0:
        # 追加一个闭合标记
        return text + "\n```"

    return text

# 在SSE推送前调用此函数
# safe_text = preprocess_streaming_markdown(chunk)

前端接收到这段经过“补全”的文本后,即使模型还没吐完,渲染器也能正确识别这是一个代码块,从而保持UI的稳定性。

四、总结与思考

在AI应用开发中,前端不再仅仅是“画页面”,而是要处理非结构化数据的实时渲染与安全治理。

  1. 安全是底线:大模型输出内容的不可预测性,要求我们必须将前端安全防御等级提升到“输入即威胁”的高度。DOMPurify 配合 React 的默认转义机制,构成了双重保险。
  2. 体验是核心竞争力:流式输出中的代码块抖动、表格渲染错乱,这些细节直接决定了用户对AI产品“智能感”的认知。通过简单的文本预处理补全闭合标签,是一个低成本高收益的工程技巧。
  3. 性能考量:在高频流式更新下,Markdown解析和高亮计算是CPU密集型任务。在实际生产中,建议使用 useMemo 缓存渲染结果,并考虑使用 requestAnimationFrame 控制渲染频率,避免低端设备卡顿。

从Web开发转向AI工程,本质上是将确定性的逻辑处理能力,扩展到不确定性的数据处理领域。掌握这些细节,正是资深工程师的核心竞争力所在。


关于作者
我是一个出生于2015年的全栈开发者,CSDN博主。在Web领域深耕多年后,我正在探索AI与开发结合的新方向。我相信技术是有温度的,代码是有灵魂的。这个专栏记录的不仅是学习笔记,更是一个普通程序员在时代浪潮中的思考与成长。如果你也对AI开发感兴趣,欢迎关注我的专栏,我们一起学习,共同进步。

📢 技术交流
学习路上不孤单!我建了一个AI学习交流群,欢迎志同道合的朋友加入,一起探讨技术、分享资源、答疑解惑。
QQ群号:1082081465
进群暗号:CSDN

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐