Skip to content

XMarkdown 流式渲染引擎设计解析

本文基于 Ant Design X / XMarkdown 源码实现分析

需求背景

在 AI 对话应用(ChatGPT、Claude、Cursor 等)中,流式输出 Markdown 是提升体验的关键技术。当用户在屏幕上看到文字逐字符出现时,不仅能获得"正在输入"的心理暗示,也能在长文本场景下更快开始阅读。

核心挑战:Markdown 语法是声明式的,AI 逐字符输出时,前一个字符可能与后一个字符组合成完全不同的语义。例如输出 [link](http 时,不应渲染为 [link](http(畸形链接),而应等待完整语法或合理截断。

一、总体架构

XMarkdown 采用两阶段分离架构(Two-Stage Architecture),配合流式预处理层:

架构分层职责

层次模块核心职责关键问题
流式输入层useStreaming维护状态机,缓冲不完整语法如何识别并处理 8+ 种 Token?
核心处理层ParserMarkdown → HTML,注入 Tail 光标如何在流式场景下注入占位符?
核心处理层RendererHTML 净化与 React 组件映射如何防止 XSS 同时保留自定义组件?
输出层AnimationText可选淡入动画如何避免动画导致的性能问题?

设计哲学:关注点分离

┌─────────────────────────────────────────────────────────┐
│                    流式 Markdown 渲染                     │
├─────────────────────────────────────────────────────────┤
│  useStreaming    │  Parser     │  Renderer  │ Animation │
│  ─────────────   │  ─────────  │  ───────── │  ────────  │
│  状态管理         │  格式转换    │  安全映射   │  视觉增强   │
│  Token 识别      │  尾部注入    │  组件替换   │           │
└─────────────────────────────────────────────────────────┘

这种分离带来三个好处:

  1. 可测试:每个阶段可独立单元测试
  2. 可替换:可替换 Parser 或 Renderer 实现(如从 marked 切换到 remark)
  3. 可扩展:新增 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',         // 图片 ![alt](url)
  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,
      }
    });
  }
}

双重安全策略

  1. DOMPurify:过滤 <script>、事件属性(onclick)等
  2. 组件白名单:只有显式注册的组件才会被渲染

3.3 核心依赖对比

依赖作用替代方案
markedMarkdown → HTMLremarkmarkdown-it
dompurifyXSS 净化isomorphic-dompurifysanitize-html
html-react-parserHTML → Reactreact-html-parserrehype-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 加速 */
}

六、竞品对比

维度XMarkdownmarkdown-itreact-markdown
流式渲染✅ 原生支持❌ 需自行实现❌ 需自行实现
不完整语法处理✅ 状态机❌ 不支持❌ 不支持
XSS 防护✅ DOMPurify❌ 需自行配置✅ 内置
React 组件映射✅ 原生支持❌ 不支持✅ 支持
包大小~15KB~50KB~30KB
Tree-shaking
TypeScript

结论:如果你需要开箱即用的流式 Markdown 渲染,XMarkdown 是目前社区中为数不多的成熟方案。如果你的场景不需要流式渲染,react-markdown 是更通用的选择。

七、快速上手

7.1 安装

sh
npm install @ant-design/x
sh
pnpm add @ant-design/x

7.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

参考实现Ant Design X - XMarkdown 组件