XMarkdown 流式渲染引擎设计解析
本文基于 Ant Design X / XMarkdown 源码实现分析
需求背景
在 AI 对话应用(ChatGPT、Claude、Cursor 等)中,流式输出 Markdown 是提升体验的关键技术。当用户在屏幕上看到文字逐字符出现时,不仅能获得"正在输入"的心理暗示,也能在长文本场景下更快开始阅读。
核心挑战:Markdown 语法是声明式的,AI 逐字符输出时,前一个字符可能与后一个字符组合成完全不同的语义。例如输出 [link](http 时,不应渲染为 [link](http(畸形链接),而应等待完整语法或合理截断。
一、总体架构
XMarkdown 采用两阶段分离架构(Two-Stage Architecture),配合流式预处理层:
架构分层职责
| 层次 | 模块 | 核心职责 | 关键问题 |
|---|---|---|---|
| 流式输入层 | useStreaming | 维护状态机,缓冲不完整语法 | 如何识别并处理 8+ 种 Token? |
| 核心处理层 | Parser | Markdown → HTML,注入 Tail 光标 | 如何在流式场景下注入占位符? |
| 核心处理层 | Renderer | HTML 净化与 React 组件映射 | 如何防止 XSS 同时保留自定义组件? |
| 输出层 | AnimationText | 可选淡入动画 | 如何避免动画导致的性能问题? |
设计哲学:关注点分离
┌─────────────────────────────────────────────────────────┐
│ 流式 Markdown 渲染 │
├─────────────────────────────────────────────────────────┤
│ useStreaming │ Parser │ Renderer │ Animation │
│ ───────────── │ ───────── │ ───────── │ ──────── │
│ 状态管理 │ 格式转换 │ 安全映射 │ 视觉增强 │
│ Token 识别 │ 尾部注入 │ 组件替换 │ │
└─────────────────────────────────────────────────────────┘这种分离带来三个好处:
- 可测试:每个阶段可独立单元测试
- 可替换:可替换 Parser 或 Renderer 实现(如从 marked 切换到 remark)
- 可扩展:新增 Token 类型只需修改
useStreaming
二、流式输入层:状态机设计
2.1 核心问题
当 AI 输出 [link](https://exam 时,渲染引擎面临决策:
| 方案 | 行为 | 体验 |
|---|---|---|
| 立即渲染 | 显示 [link](https://exam | 畸形内容闪烁 |
| 静默等待 | 不显示任何内容 | 无反馈延迟 |
| 缓冲后渲染 | 等待完整语法或合理截断 | ✅ 最佳体验 |
XMarkdown 选择缓冲后渲染,通过状态机识别当前"不完整但有效"的语法状态。
2.2 Token 类型定义
typescript
enum StreamCacheTokenType {
Text = 'text', // 纯文本(默认状态)
Link = 'link', // 行内链接 [text](url)
Image = 'image', // 图片 
InlineCode = 'inlineCode', // 行内代码 `code`
Emphasis = 'emphasis', // 强调 **bold**
Html = 'html', // 原始 HTML <div>
List = 'list', // 列表项 - item
Table = 'table', // 表格 | col |
}2.3 识别器接口设计
每种 Token 类型对应一个 Recognizer 对象:
typescript
interface Recognizer {
/** 判断 pending 是否为当前类型的起始 */
isStartOfToken(pending: string): boolean;
/** 判断在流式输入过程中,pending 是否仍可能变成有效语法 */
isStreamingValid(pending: string): boolean;
/** 切换 Token 类型时,提取已确认的字符子串 */
getCommitPrefix(pending: string): string | null;
}以 Link 为例说明三个方法的配合:
typescript
const linkRecognizer: Recognizer = {
isStartOfToken: (pending) => /^\[/.test(pending),
isStreamingValid: (pending) => {
// 链接语法: [text](url)
// 已收到 [text](url,括号已闭合,语法完整或可能已结束
// 已收到 [text](,仍可能在等待 url
return !/\]\([^)]*\)$/.test(pending); // 未闭合时不返回 true
},
getCommitPrefix: (pending) => {
// 当从 Link 切换到其他 Token 时调用
// 例如: [link](url) `code 中,从 ) 切换到 `
const match = pending.match(/^(.+)\)(.+)$/);
if (match) return match[1] + ')'; // 提交 [link](url)
return null;
}
};2.4 代码块绕过机制
关键问题:代码块内的 [link](url) 不应被识别为链接。
typescript
function isInsideCodeBlock(markdown: string): boolean {
// 计算当前是否在代码块内部
// 原理:统计 ``` 出现的奇偶次数
const codeBlockCount = (markdown.match(/```/g) || []).length;
const inlineCodeCount = (markdown.match(/`/g) || []).length;
// 简化判断:任意一种代码标记出现奇数次即在代码块内
return codeBlockCount % 2 !== 0 || inlineCodeCount % 2 !== 0;
}核心处理逻辑:
typescript
function processCharacter(char: string) {
pending += char;
// 🚨 代码块内:绕过所有识别器,直接提交
if (isInsideCodeBlock(completeMarkdown + pending)) {
commitAllPending();
return;
}
// 正常识别流程...
}2.5 状态机执行流程
三、核心处理层
3.1 Stage 1:Parser(Markdown → HTML)
typescript
class Parser {
parse(markdown: string, options: { injectTail: boolean }): string {
// 1. 使用 marked 解析
let html = marked.parse(markdown);
// 2. 注入 Tail 光标占位符
if (options.injectTail) {
html += '<xmd-tail />';
}
return html;
}
}关键设计:Tail 光标不是普通字符,而是一个自定义 HTML 标签 <xmd-tail />,这样可以在后续 Renderer 阶段被替换为任意 React 组件。
3.2 Stage 2:Renderer(HTML → React)
typescript
class Renderer {
render(html: string, components: ComponentsMap): ReactElement {
// 1. XSS 防护:净化危险标签和属性
const safeHtml = DOMPurify.sanitize(html, {
ADD_TAGS: ['xmd-tail'], // 允许自定义标签
});
// 2. HTML → React 元素树,同时映射自定义组件
return parseHTML(safeHtml, {
components: {
'xmd-tail': TailIndicator, // 替换为 React 组件
...components,
}
});
}
}双重安全策略:
- DOMPurify:过滤
<script>、事件属性(onclick)等 - 组件白名单:只有显式注册的组件才会被渲染
3.3 核心依赖对比
| 依赖 | 作用 | 替代方案 |
|---|---|---|
marked | Markdown → HTML | remark、markdown-it |
dompurify | XSS 净化 | isomorphic-dompurify、sanitize-html |
html-react-parser | HTML → React | react-html-parser、rehype-react |
四、Tail 光标注入机制
4.1 三层架构
┌────────────────────────────────────────────────────────┐
│ Tail 光标注入流程 │
├────────────────────────────────────────────────────────┤
│ │
│ Parser Renderer │
│ ────── ──────── │
│ 生成 '<xmd-tail />' ────▶ 识别 '<xmd-tail />' │
│ │ │
│ ▼ │
│ 映射为 TailIndicator 组件 │
│ │ │
│ ▼ │
│ 用户自定义的光标样式 │
└────────────────────────────────────────────────────────┘4.2 自定义光标
tsx
// 方式 1:使用字符
<XMarkdown
content={content}
streaming={{
hasNextChunk: true,
tail: { content: '▋' } // 闪烁竖线
}}
/>
// 方式 2:使用组件
<XMarkdown
content={content}
streaming={{
hasNextChunk: true,
tail: {
component: MyCustomCursor // 完全自定义
}
}}
/>五、动画层:AnimationText
5.1 实现原理
当新的文本块被渲染时,包裹在 AnimationText 组件中,通过 CSS 动画实现淡入:
tsx
const AnimationText: React.FC<AnimationTextProps> = ({
children,
duration = 200,
}) => {
const style: React.CSSProperties = {
animation: `xmd-fade-in ${duration}ms ease-in-out`,
};
return <span className="xmd-animation-text" style={style}>
{children}
</span>;
};5.2 CSS 动画定义
css
@keyframes xmd-fade-in {
from {
opacity: 0;
transform: translateY(0.2em); /* 轻微上移 */
}
to {
opacity: 1;
transform: translateY(0);
}
}
.xmd-animation-text {
display: inline-block;
will-change: transform, opacity; /* 开启 GPU 加速 */
}六、竞品对比
| 维度 | XMarkdown | markdown-it | react-markdown |
|---|---|---|---|
| 流式渲染 | ✅ 原生支持 | ❌ 需自行实现 | ❌ 需自行实现 |
| 不完整语法处理 | ✅ 状态机 | ❌ 不支持 | ❌ 不支持 |
| XSS 防护 | ✅ DOMPurify | ❌ 需自行配置 | ✅ 内置 |
| React 组件映射 | ✅ 原生支持 | ❌ 不支持 | ✅ 支持 |
| 包大小 | ~15KB | ~50KB | ~30KB |
| Tree-shaking | ✅ | ✅ | ✅ |
| TypeScript | ✅ | ✅ | ✅ |
结论:如果你需要开箱即用的流式 Markdown 渲染,XMarkdown 是目前社区中为数不多的成熟方案。如果你的场景不需要流式渲染,react-markdown 是更通用的选择。
七、快速上手
7.1 安装
sh
npm install @ant-design/xsh
pnpm add @ant-design/x7.2 基本使用
tsx
import { XMarkdown } from '@ant-design/x';
function AIChat() {
const [content, setContent] = useState('');
return (
<XMarkdown
content={content}
streaming={{
enable: true, // 开启流式渲染
hasNextChunk: hasMore, // 是否还有更多数据
tail: { content: '▋' }, // 光标样式
}}
/>
);
}
// 模拟流式输入
function simulateStream(text: string, onChunk: (c: string) => void) {
for (const char of text) {
setTimeout(() => onChunk(char), 50);
}
}7.3 自定义组件映射
tsx
<XMarkdown
content={markdown}
components={{
// 自定义链接渲染
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener">
{children} 🔗
</a>
),
// 自定义代码块渲染
code: ({ className, children }) => (
<SyntaxHighlighter language={className?.replace('language-', '')}>
{children}
</SyntaxHighlighter>
),
}}
/>八、扩展阅读
如果你对某个模块感兴趣,可以进一步了解:
| 主题 | 关联技术 |
|---|---|
| Markdown 解析原理 | LL(1) 文法、递归下降解析器 |
| XSS 防护 | DOMPurify 净化策略、白名单机制 |
| React 调和算法 | html-react-parser 的 DOM → React 映射 |
| 流式传输协议 | Server-Sent Events (SSE)、WebSocket |
