React+Antd Pro实现AI对话:优雅接收后端流式输出

在AI对话类产品开发中,“打字机效果”(后端逐段返回AI回复)是提升用户体验的关键特性。传统接口一次性返回完整数据的模式,会让用户面临长时间空白等待,而流式输出能让后端生成一段内容就返回一段,前端实时渲染,模拟“边想边说”的自然交互,大幅降低等待感知。本文结合React+Antd Pro技术栈,从原理、实现、优化到避坑,完整拆解后端流式输出的接收与AI对话模块的落地方案,适配企业级项目开发需求。

一、场景背景与核心技术解析

1. 流式输出的核心价值

AI大模型生成回复时,需经历语义理解、内容推理、逐句生成等过程,完整回复耗时可能达数秒。流式输出通过“分块返回、实时渲染”,将等待过程转化为可见的内容生成过程,既缓解用户焦虑,又让交互更贴近真人对话场景。此外,流式输出还支持“中途打断”功能,用户可在AI未生成完回复时停止请求,提升操作灵活性。

2. 前端流式接收方案选型

前端接收后端流式数据主要有三种技术方案,需结合AI对话场景的单向数据流特性选型,具体对比如下:

技术方案 核心特性 兼容性 适配场景
Fetch API + ReadableStream 原生支持、轻量无依赖、单向流、可通过AbortController取消请求 现代浏览器全支持,IE不兼容 AI对话、日志实时输出(仅后端推数据场景)
Server-Sent Events (SSE) 基于HTTP、自动重连、单向流、自带事件监听机制 主流浏览器支持,IE不兼容 实时通知、股票行情推送(长连接低频率更新场景)
WebSocket TCP长连接、双向通信、低延迟 全浏览器支持(含IE10+) 实时聊天、多人协作(需前后端双向交互场景)

本文选用Fetch API + ReadableStream方案,核心原因的是AI对话仅需后端推流,无需前端向后端实时发送数据,原生方案无需额外依赖,更贴合Antd Pro轻量开发理念,且可通过AbortController灵活控制请求生命周期。

3. 后端接口规范约定

前端流式接收的前提是后端返回符合规范的数据,需提前与后端约定以下内容,避免解析异常:

  • 响应头配置:必须设置 Content-Type: text/event-stream; charset=utf-8(标识为流式数据)和 Transfer-Encoding: chunked(分块传输),同时需关闭缓存 Cache-Control: no-cache

  • 数据格式约定:每行返回JSON格式数据,以 \n\n 作为分块分隔符(后端需确保分隔符唯一,避免与内容冲突),示例如下: {"content":"你好,","finished":false}\n\n {"content":"我是AI助手,请问有什么可以帮你?","finished":true}\n\n

  • 状态标识字段:通过 finished: boolean 标记流是否结束,便于前端处理加载状态重置、内容收尾等逻辑;可选添加 error: string 字段传递异常信息。

二、前端完整实现(React+Antd Pro)

本章节基于Antd Pro 5.x版本,结合官方@ant-design/pro-chat组件实现对话UI(内置气泡样式、加载动画,无需重复造轮子),全程使用TypeScript保证类型安全。

1. 环境准备与依赖安装

首先确保项目已集成Antd Pro核心依赖,再安装Pro Chat组件:

# 安装Pro Chat组件(适配Antd Pro的对话UI组件)
npm install @ant-design/pro-chat --save
# 若需处理节流/防抖,安装lodash(可选)
npm install lodash --save

2. 核心逻辑实现(分步骤拆解)

步骤1:定义状态与Ref变量

在React函数组件中,定义对话列表、输入内容、加载状态等核心状态,同时用useRef保存AbortController实例(用于取消流式请求),避免组件重渲染导致实例丢失:

import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { Chat, ChatMessageItemProps } from '@ant-design/pro-chat';
import { Button, Input, message } from 'antd';
import type { AbortController } from 'node-abort-controller'; // 类型声明(可选)
​
const AIChatModule: React.FC = () => {
  // 对话列表:符合Pro Chat组件的消息格式要求
  const [messages, setMessages] = useState<ChatMessageItemProps[]>([
    { content: '你好!我是AI助手,请问有什么可以帮你?', role: 'assistant', key: 'init-1' },
  ]);
  // 用户输入内容
  const [inputValue, setInputValue] = useState('');
  // AI回复加载状态(控制输入框禁用、加载动画)
  const [isLoading, setIsLoading] = useState(false);
  // 保存AbortController实例,用于取消未完成的流式请求
  const abortControllerRef = useRef<AbortController | null>(null);
​
  // 清空对话逻辑
  const handleClearChat = useCallback(() => {
    setMessages([]);
    setInputValue('');
    // 取消未结束的流式请求,避免内存泄漏
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
      abortControllerRef.current = null;
    }
    setIsLoading(false);
  }, []);
​
  // 组件卸载时取消请求,清理资源
  useEffect(() => {
    return () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, []);
​
  // 缓存对话列表,减少不必要的重渲染
  const memoizedMessages = useMemo(() => messages, [messages]);
步骤2:封装流式请求函数

核心实现sendMessage函数,负责发起流式请求、逐段解析数据、实时更新对话内容,同时处理异常、取消、流结束等边界场景:

const sendMessage = useCallback(async () => {
  const content = inputValue.trim();
  if (!content || isLoading) return;

  // 1. 新增用户消息到对话列表,重置输入框
  const userMessage: ChatMessageItemProps = {
    content,
    role: 'user',
    key: Date.now().toString(), // 用时间戳保证key唯一
  };
  setMessages(prev => [...prev, userMessage]);
  setInputValue('');
  setIsLoading(true);

  // 2. 创建AbortController,关联请求生命周期
  const abortController = new AbortController();
  abortControllerRef.current = abortController;

  // 3. 初始化AI回复(空内容,用于实时更新)
  const assistantKey = (Date.now() + 1).toString();
  let assistantContent = '';
  setMessages(prev => [
    ...prev,
    { content: '', role: 'assistant', key: assistantKey, loading: true },
  ]);

  try {
    // 4. 发起流式Fetch请求
    const response = await fetch('/api/ai/chat', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        // 若项目有权限校验,添加Token头
        'Authorization': `Bearer ${localStorage.getItem('token')}`,
      },
      body: JSON.stringify({
        message: content,
        // 携带历史对话,实现多轮上下文关联
        history: messages.map(item => ({
          role: item.role,
          content: item.content,
        })),
      }),
      signal: abortController.signal, // 绑定取消信号
    });

    // 处理HTTP错误(非2xx状态码)
    if (!response.ok) {
      throw new Error(`请求失败:${response.status} ${response.statusText}`);
    }

    // 5. 读取流式响应,逐段解析
    const reader = response.body?.getReader();
    if (!reader) {
      throw new Error('后端未返回流式数据,无法解析');
    }
    const decoder = new TextDecoder('utf-8'); // 解决中文乱码问题

    // 循环读取流数据,直到流结束
    while (true) {
      const { done, value } = await reader.read();
      if (done) {
        // 流结束,更新AI消息状态(移除loading)
        setMessages(prev =>
          prev.map(item =>
            item.key === assistantKey ? { ...item, loading: false } : item
          )
        );
        break;
      }

      // 解析分块数据(按约定的\n\n分隔,过滤空行)
      const chunk = decoder.decode(value, { stream: true });
      const validLines = chunk.split('\n\n').filter(line => line.trim() && line.startsWith('{'));

      for (const line of validLines) {
        try {
          const data = JSON.parse(line);
          // 累加AI回复内容,实时更新UI
          assistantContent += data.content;
          setMessages(prev =>
            prev.map(item =>
              item.key === assistantKey
                ? { ...item, content: assistantContent, loading: !data.finished }
                : item
            )
          );
          // 若流结束,提前退出循环
          if (data.finished) {
            reader.cancel(); // 主动关闭读取器
            break;
          }
        } catch (parseError) {
          console.error('解析流式数据失败:', parseError, '原始数据:', line);
        }
      }
    }
  } catch (error) {
    // 排除用户主动取消请求的异常
    if ((error as Error).name !== 'AbortError') {
      message.error(`对话失败:${(error as Error).message}`);
      // 更新AI消息为错误提示
      setMessages(prev =>
        prev.map(item =>
          item.key === assistantKey
            ? { ...item, content: '抱歉,对话服务暂时不可用,请稍后重试。', loading: false }
            : item
        )
      );
    }
    // 重置状态
    setIsLoading(false);
    abortControllerRef.current = null;
  }
}, [inputValue, isLoading, messages, memoizedMessages]);
步骤3:渲染对话UI(集成Antd Pro组件)

结合Pro Chat组件快速实现对话界面,包含对话列表、输入框、发送按钮、清空按钮,同时支持回车发送功能:

// 处理输入框回车发送
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
  if (e.key === 'Enter' && !isLoading) {
    sendMessage();
  }
}, [isLoading, sendMessage]);

return (
  <div style={: '100%', height: '100vh', padding: '20px', boxSizing: 'border-box' }}>
    {/* 顶部操作栏 */}
    <div style={', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
      <h2 style={AI对话助手<Button type="default" onClick={handleClearChat} disabled={isLoading}>
        清空对话
      </Button>
    

    {/* 对话列表区域 */}<Chat
      messages={memoizedMessages}
      style={{ height: 'calc(100% - 100px)', marginBottom: '16px' }}
      // 自定义AI消息样式(可选)
      renderAssistantMessage={(message) => (
        <div style={<div style={0%', background: '#1890ff', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white' }}>
            AI
          <div style={ '8px' }}>
            {message.content}
          
      )}
    />

    {/* 输入框区域 */}
    <div style={', gap: '8px' }}>
      <Input
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
        onKeyDown={handleKeyDown}
        placeholder="请输入你的问题..."
        disabled={isLoading}
        style={{ flex: 1 }}
      />
      <Button
        type="primary"
        onClick={sendMessage}
        disabled={isLoading || !inputValue.trim()}
        size="large"
      >
        发送
      </Button>
    
);
};

export default AIChatModule;
步骤4:路由配置(Antd Pro规范)

将AI对话组件配置到Antd Pro的路由中(config/routes.ts),使其成为可访问页面:

import type { RouteObject } from '@umijs/max';

const routes: RouteObject[] = [
  {
    path: '/',
    redirect: '/ai-chat',
  },
  {
    path: '/ai-chat',
    name: 'AI对话',
    component: './AIChatModule', // 对应组件文件路径
    icon: 'message', // 可选:配置菜单图标
    access: 'canReadAI', // 可选:权限控制(需配合access.ts)
  },
];

export default routes;

三、体验优化与避坑指南

1. 核心体验优化点

(1)自然打字机效果(控制渲染速度)

默认逐块渲染可能导致内容弹出过快,模拟真人打字速度可提升交互体验,实现字符级延迟渲染:

// 封装字符级渲染函数
const renderTextByChar = useCallback((targetKey: string, newContent: string) => {
  let currentIndex = 0;
  const totalLength = newContent.length;
  // 清除之前的定时器(避免多次请求叠加)
  const timer = window.setInterval(() => {
    setMessages(prev =>
      prev.map(item => {
        if (item.key === targetKey) {
          const currentText = item.content || '';
          const nextText = newContent.slice(0, currentIndex + 1);
          return { ...item, content: nextText };
        }
        return item;
      })
    );
    currentIndex++;
    if (currentIndex >= totalLength) {
      clearInterval(timer);
    }
  }, 30); // 30ms/字符,可根据需求调整速度
}, []);

// 在流式解析中替换原直接赋值逻辑
// assistantContent += data.content;
// setMessages(...)
renderTextByChar(assistantKey, assistantContent + data.content);
(2)对话历史持久化

通过localStorage保存对话历史,刷新页面后自动恢复,提升用户体验:

// 组件挂载时加载历史对话
useEffect(() => {
  const savedHistory = localStorage.getItem('ai_chat_history');
  if (savedHistory) {
    try {
      const parsedHistory = JSON.parse(savedHistory) as ChatMessageItemProps[];
      setMessages(parsedHistory);
    } catch (e) {
      console.error('解析对话历史失败:', e);
      localStorage.removeItem('ai_chat_history');
    }
  }
}, []);

// 对话更新时保存到本地存储
useEffect(() => {
  if (memoizedMessages.length > 0) {
    localStorage.setItem('ai_chat_history', JSON.stringify(memoizedMessages));
  }
}, [memoizedMessages]);
(3)请求中断与异常兜底

除组件卸载时取消请求外,可增加“打断AI”按钮,允许用户中途停止回复生成:

// 新增打断AI按钮逻辑
const handleInterruptAI = useCallback(() => {
  if (abortControllerRef.current && isLoading) {
    abortControllerRef.current.abort();
    abortControllerRef.current = null;
    setIsLoading(false);
    // 更新AI消息状态,标记为已中断
    setMessages(prev =>
      prev.map(item =>
        item.role === 'assistant' && item.loading
          ? { ...item, content: item.content + '(已中断)', loading: false }
          : item
      )
    );
  }
}, [isLoading]);

// 在UI中添加按钮(建议放在对话列表顶部)
<Button type="text" onClick={handleInterruptAI} disabled={!isLoading}>
  打断AI
</Button>
(4)性能优化:减少重渲染

频繁更新对话列表会导致组件重复渲染,可通过以下方式优化:

  • useMemo缓存对话列表(前文已实现memoizedMessages);

  • 将AI消息更新逻辑拆分为独立函数,避免闭包导致的依赖项冗余;

  • 对长文本回复做分段渲染,避免单次更新大量DOM。

2. 常见避坑点与解决方案

(1)跨域请求异常

流式请求的跨域配置比普通请求更严格,后端需额外暴露流式相关响应头,否则前端无法读取response.body。以下是不同后端的配置示例:

  • Spring Boot(Java):

         @Configuration
    public class CorsConfig implements WebMvcConfigurer {
      @Override
      public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
          .allowedOrigins("前端域名,如http://localhost:8000")
          .allowedMethods("GET", "POST", "OPTIONS")
          .allowedHeaders("*")
          .exposedHeaders("Transfer-Encoding", "Content-Type") // 暴露流式响应头
          .allowCredentials(true)
          .maxAge(3600);
      }
    }

  • Node.js(Express):

    const cors = require('cors');
    app.use(cors({
      origin: 'http://localhost:8000',
      exposedHeaders: ['Transfer-Encoding', 'Content-Type'], // 关键配置
      credentials: true
    }));

(2)中文乱码问题

若后端返回的流式数据存在中文乱码,需确保两点:一是后端设置charset=utf-8,二是前端用TextDecoder('utf-8')解析(前文已实现),避免使用默认编码。

(3)流数据解析不完整

后端返回的分块数据若未严格按\n\n分隔,会导致前端解析出空行或畸形JSON。解决方案:

  • 后端确保每个JSON对象末尾添加\n\n,避免内容中包含该分隔符;

  • 前端解析时增加过滤逻辑,仅保留以{开头、}结尾的有效行(前文validLines逻辑已处理)。

(4)组件卸载后内存泄漏

若用户在AI回复过程中切换页面,未取消的流式请求会继续占用资源,导致内存泄漏。前文通过useEffect监听组件卸载,自动取消请求,彻底解决该问题。

四、扩展场景与总结

1. 扩展场景落地

(1)多轮对话上下文优化

若需支持多轮对话(AI记忆历史内容),需在请求体中携带完整对话历史,后端结合历史记录生成回复。前文history参数已实现该逻辑,仅需后端配合处理即可。

(2)切换为WebSocket方案

若需实现“用户实时打断AI+AI实时推送通知”的双向交互场景,可切换为WebSocket,核心逻辑替换如下:

// 初始化WebSocket连接
const initWebSocket = useCallback(() => {
  const ws = new WebSocket(`ws://${window.location.host}/ws/ai-chat`);
  ws.onopen = () => console.log('WebSocket连接成功');
  ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    // 复用前文的消息更新逻辑
    assistantContent += data.content;
    setMessages(prev => prev.map(...));
  };
  ws.onclose = () => console.log('WebSocket连接关闭');
  ws.onerror = (error) => message.error(`WebSocket错误:${error.message}`);
  return ws;
}, []);

// 发送消息
const sendWsMessage = useCallback((content: string) => {
  const ws = initWebSocket();
  ws.send(JSON.stringify({ message: content, history: messages }));
}, [messages]);
(3)语音交互集成

结合Web Speech API实现语音输入/输出:

  • 语音输入:将语音转为文字后调用流式请求;

  • 语音输出:AI回复内容生成后,通过SpeechSynthesis播放语音。

2. 总结

本文基于React+Antd Pro技术栈,实现了AI对话模块中后端流式输出的接收与渲染,核心要点如下:

  1. 选型逻辑:根据AI对话单向流特性,选用Fetch+ReadableStream方案,兼顾轻量性与可控性;

  2. 核心实现:通过AbortController管理请求生命周期,逐段解析流式数据,结合Pro Chat组件快速落地UI;

  3. 体验优化:从打字机效果、历史持久化、性能优化多维度提升交互体验;

  4. 避坑核心:严格遵守前后端接口规范,处理跨域、解析异常、内存泄漏等边界问题。

该方案可直接复用至企业级AI对话项目,同时提供了扩展场景的落地思路,适配不同需求的迭代优化。

(注:文档部分内容可能由 AI 生成)

Logo

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

更多推荐