在React大模型网站中实现流式推送Markdown转换 并开启rehype-raw等支持HTML注入是一个常见且重要的需求。
-
实时渲染:大模型响应是流式的,需要边接收边渲染
-
完整支持:既要渲染标准Markdown,又要支持HTML内容
-
安全性:HTML注入需要可控,防止XSS攻击
一、ReactMarkdown 是什么
ReactMarkdown 是一个把 Markdown 转成 React 组件的库。
但它和 marked、markdown-it 最大的区别是:
- 默认 不渲染 HTML
- 默认 不能把 HTML 字符串插入进去
- 默认 不能输出 dangerouslySetInnerHTML
它是 严格安全的 Markdown → React 转换工具。
基础使用:
javascript
import React from 'react'
import {createRoot} from 'react-dom/client'
import Markdown from 'react-markdown'
const markdown = '# Hi, *Pluto*!'
createRoot(document.body).render(<Markdown>{markdown}</Markdown>)
二、插件rehype-raw
rehype-raw 是 处理 Markdown 中的原生 HTML 的插件库 ,专门用于 ReactMarkdown 生态。rehype-raw 是一个 rehype 插件 (rehype 是 HTML 的处理器),作用是让 ReactMarkdown 能够安全地解析并渲染 Markdown 内容中的 HTML 标签。
默认情况下:<ReactMarkdown>{content}</ReactMarkdown> 不会解析 HTML 标签 (例如 <span>、<div>、sup> 等都会被直接当作文本输出)。
javascript
<ReactMarkdown rehypePlugins={[rehypeRaw]}>
{content}
</ReactMarkdown>
加上 rehype-raw,HTML 标签就会被当作 HTML 并正常渲染。
示例:
javascript
const aa = {
content:
'<span class="ref" data-id="doc1">[1]</span>识别到当前用户诉求...'
};
import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
<ReactMarkdown rehypePlugins={[rehypeRaw]}>
{aa.content}
</ReactMarkdown>
这样 <span class="ref"> 就能被正常渲染
还可以对其进行定制化操作例如:
-
[1]悬浮显示 segmentContent -
点击跳转参考文档
-
自动编号
-
多文档引用自动合并
javascript
import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import { Tooltip } from 'antd';
<ReactMarkdown
rehypePlugins={[rehypeRaw]}
components={{
span({node, ...props}) {
if (props.className === 'ref') {
const id = props['data-id'];
return (
<Tooltip title={`文档来源:${id}`}>
<span style={{ color: 'blue', cursor: 'pointer' }}>
{props.children}
</span>
</Tooltip>
);
}
return <span {...props} />;
}
}}
>
{aa.content}
</ReactMarkdown>
三、rehype-sanitize
rehype-sanitize 是一个用于 sanitize(净化)HTML 内容的 rehype 插件,专门用于防止 XSS 攻击。在 React 大模型网站中,当开启 rehype-raw 支持 HTML 注入时,必须 配合 rehype-sanitize 使用。
安全风险场景:
javascript
// 大模型可能返回的危险内容
const dangerousContent = `
# 看起来正常的回复
<div onclick="alert('XSS')">点击我</div>
<script>stealCookies();</script>
<img src="x" onerror="maliciousCode()">
<iframe src="javascript:alert('attack')"></iframe>
<a href="javascript:stealData()">安全链接</a>
`;
// 如果没有 rehype-sanitize,这些代码会被执行!
使用方法:
javascript
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
const SafeMarkdownRenderer = ({ content }) => {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[
rehypeRaw, // 允许解析 HTML
rehypeSanitize, // 净化 HTML,防止 XSS
]}
>
{content}
</ReactMarkdown>
);
};
2. 默认的安全规则
rehype-sanitize 默认使用 GitHub 的 sanitization 规则:
-
✅ 允许:大多数安全标签(div, span, p, br, strong, em 等)
-
✅ 允许:安全属性(class, id, style, href, src 等)
-
❌ 禁止:
<script>,<iframe>,<object>,<embed> -
❌ 禁止:事件处理器(onclick, onerror, onload 等)
-
❌ 禁止:JavaScript URL(javascript:, data: 等)
自定义允许的标签和属性:
javascript
import ReactMarkdown from 'react-markdown';
import rehypeSanitize from 'rehype-sanitize';
const CustomSanitizeRenderer = () => {
// 自定义 sanitize 配置
const sanitizeOptions = {
tagNames: [
// 基础标签
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'br', 'hr',
'div', 'span',
// 列表
'ul', 'ol', 'li',
// 强调
'strong', 'em', 'b', 'i', 'u',
'del', 'ins', 's', 'strike',
// 链接和图片
'a', 'img',
// 代码
'code', 'pre', 'blockquote',
// 表格(如果需要)
'table', 'thead', 'tbody', 'tr', 'th', 'td',
// 自定义标签(大模型可能使用的)
'details', 'summary', 'kbd', 'sup', 'sub',
],
attributes: {
// 全局允许的属性
'*': ['className', 'style', 'title', 'id'],
// 链接允许的属性
'a': ['href', 'target', 'rel', 'download'],
// 图片允许的属性
'img': ['src', 'alt', 'width', 'height', 'loading'],
// 代码块允许的属性
'code': ['className'], // 用于语法高亮
// 自定义属性
'span': ['data-*'], // 允许所有 data-* 属性
'div': ['data-*'],
},
protocols: {
// 允许的协议
href: ['http', 'https', 'mailto', 'tel'],
src: ['http', 'https', 'data'], // 谨慎使用 data:
},
strip: ['script', 'style'], // 完全移除这些标签及其内容
};
return (
<ReactMarkdown
rehypePlugins={[
rehypeRaw,
[rehypeSanitize, sanitizeOptions] // 传入配置
]}
>
{content}
</ReactMarkdown>
);
};
四、remark-gfm
remark-gfm 是 让 ReactMarkdown 支持 GitHub 风格 Markdown (GFM) 的官方插件。让 ReactMarkdown 支持 "更多 Markdown 语法。
包括:
| 功能 | 是否需要 remark-gfm |
|---|---|
- 无序列表 |
不需要 |
1. 有序列表 |
不需要 |
| 粗体 / 斜体 | 不需要 |
| 表格(| col1 | col2 |) | ✔️ 需要 |
任务列表(- [x] 已完成) |
✔️ 需要 |
自动链接(直接粘贴 URL 自动变成 <a>) |
✔️ 需要 |
删除线(~~删除~~) |
✔️ 需要 |
使用方法:
javascript
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
const CompleteMarkdownRenderer = () => {
const markdownContent = `
# GFM 完整示例
## 1. 表格示例
| 特性 | 说明 | 状态 |
|------|------|------|
| 表格 | 支持完整的表格语法 | ✅ |
| 删除线 | ~~过时内容标记~~ | ✅ |
| 任务列表 | 项目管理功能 | ✅ |
## 2. 自动链接
- 网站: https://github.com
- 邮箱: contact@example.com
- 普通文本不会自动链接
## 3. 任务列表
### 项目进度
- [x] 需求分析
- [x] 系统设计
- [ ] 编码实现
- [ ] 测试验证
- [ ] 部署上线
## 4. 删除线应用
原价:~~¥199~~ 现价:¥99
## 5. 表格中的复杂内容
| 项目 | 描述 | 包含 |
|------|------|------|
| 基础 | 核心功能 | **加粗**、*斜体*、\`代码\` |
| 扩展 | 高级功能 | ~~删除线~~、[链接](url) |
`;
return (
<div className="markdown-container">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
// 自定义表格渲染
table: ({ children }) => (
<div className="table-wrapper">
<table className="gfm-table">{children}</table>
</div>
),
// 自定义任务列表项
input: ({ checked, node }) => {
const type = node?.properties?.type;
if (type === 'checkbox') {
return (
<input
type="checkbox"
checked={checked || false}
readOnly
className="task-checkbox"
/>
);
}
return <input {...node.properties} />;
},
// 处理删除线
del: ({ children }) => (
<span className="strikethrough-text">{children}</span>
)
}}
>
{markdownContent}
</ReactMarkdown>
</div>
);
};
五、总结
基础不需要定制化的话直接引入即可,支持大部分基础场景:
javascript
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}>
{content}
</ReactMarkdown>
- 完整可用的 React Markdown 渲染组件
- 支持流式渲染、GFM、HTML、表格、任务列表
- 防止 XSS 攻击
完整的 React SSE 推流 + Markdown 累积渲染模板:
javascript
import React, { useEffect, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import _ from 'lodash';
import dayjs from 'dayjs';
interface ChatItem {
id: string;
role: 'user' | 'assistant';
content: string;
createdAt: number;
updatedAt: number;
}
interface ChatProps {
apiUrl: string;
userInput: string;
}
export default function ChatAI({ apiUrl, userInput }: ChatProps) {
const [chatList, setChatList] = useState<ChatItem[]>([]);
const [generating, setGenerating] = useState(false);
const startGenerating = (input: string) => {
if (!input) return;
setGenerating(true);
const data = { question: input };
const queryString = `data=${encodeURIComponent(JSON.stringify(data))}`;
const eventSource = new EventSource(`${apiUrl}?${queryString}`);
const conversationId = Date.now().toString();
let accumulatedContent = '';
// 创建占位消息
setChatList((old) => [
...old,
{
id: conversationId,
role: 'assistant',
content: '',
createdAt: dayjs().valueOf(),
updatedAt: dayjs().valueOf(),
},
]);
eventSource.onmessage = (event) => {
const parsed = JSON.parse(event.data);
accumulatedContent += parsed.content || '';
// 更新 chatList,触发 React 渲染
setChatList((old) => {
const next = _.cloneDeep(old);
const idx = next.findIndex((i) => i.id === conversationId);
if (idx !== -1) {
next[idx].content = accumulatedContent;
next[idx].updatedAt = dayjs().valueOf();
}
return next;
});
if (parsed.type === 'End') {
eventSource.close();
setGenerating(false);
}
};
eventSource.onerror = () => {
eventSource.close();
setGenerating(false);
};
};
useEffect(() => {
if (userInput) startGenerating(userInput);
}, [userInput]);
return (
<div>
<h3>AI 对话助手</h3>
<div
style={{
border: '1px solid #ccc',
padding: 10,
height: 400,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: 15,
}}
>
{chatList.map((item) => (
<div key={item.id} style={{ whiteSpace: 'pre-wrap' }}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
>
{item.content}
</ReactMarkdown>
</div>
))}
</div>
{generating && <div>生成中...</div>}
</div>
);
}
安装依赖:
bash
npm install react-markdown remark-gfm rehype-raw