Skip to content

12-前端接入 Agent:React 实战

岗位能力对标

招聘要求本节覆盖
前端集成能力✅ React 组件设计、状态管理
实时通信能力✅ SSE/EventSource、流式渲染
性能优化能力✅ 虚拟列表、防抖节流

学习目标

学完本节,你将能够:

  • 设计可复用的聊天界面组件架构
  • 实现流式消息的实时渲染(SSE + ReadableStream)
  • 管理复杂对话状态(历史记录、会话切换、中断重试)
  • 开发 ChatGPT 风格的完整聊天界面
  • 掌握前端性能优化技巧

前置知识

  • React Hooks(useState、useEffect、useRef、useCallback)
  • TypeScript 基础类型定义
  • HTTP 协议与 Fetch API
  • 已完成第 11 章后端 SSE 接口开发

一、聊天界面组件设计

1.1 组件架构图

ChatContainer (容器)
├── ChatHeader (头部:标题、设置、新对话)
├── ChatMessageList (消息列表)
│   ├── ChatMessage (单条消息)
│   │   ├── Avatar (头像)
│   │   ├── MessageContent (内容)
│   │   └── MessageActions (复制、重新生成)
│   └── TypingIndicator (输入指示器)
├── ChatInput (输入区域)
│   ├── Textarea (多行输入)
│   ├── SendButton (发送按钮)
│   └── StopButton (停止生成)
└── ChatSidebar (侧边栏:历史会话)
    └── SessionItem (会话项)

1.2 核心组件实现

MessageList 组件

tsx
// components/ChatMessageList.tsx
import React, { useRef, useEffect } from 'react';
import { ChatMessage } from './ChatMessage';
import { TypingIndicator } from './TypingIndicator';
import type { Message } from '../types';

interface Props {
  messages: Message[];
  isLoading: boolean;
  streamingMessage?: string;
}

export const ChatMessageList: React.FC<Props> = ({
  messages,
  isLoading,
  streamingMessage
}) => {
  const scrollRef = useRef<HTMLDivElement>(null);
  
  // 自动滚动到底部
  useEffect(() => {
    if (scrollRef.current) {
      scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
    }
  }, [messages, streamingMessage]);

  return (
    <div ref={scrollRef} className="message-list">
      {messages.map((msg, index) => (
        <ChatMessage
          key={msg.id}
          message={msg}
          isLast={index === messages.length - 1}
        />
      ))}
      
      {/* 流式消息预览 */}
      {streamingMessage && (
        <ChatMessage
          message={{
            id: 'streaming',
            role: 'assistant',
            content: streamingMessage,
            timestamp: Date.now()
          }}
          isStreaming
        />
      )}
      
      {isLoading && !streamingMessage && <TypingIndicator />}
    </div>
  );
};

ChatInput 组件

tsx
// components/ChatInput.tsx
import React, { useState, useCallback, useRef } from 'react';

interface Props {
  onSend: (message: string) => void;
  onStop?: () => void;
  isLoading: boolean;
  disabled?: boolean;
}

export const ChatInput: React.FC<Props> = ({
  onSend,
  onStop,
  isLoading,
  disabled
}) => {
  const [input, setInput] = useState('');
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  // 自动调整高度
  const adjustHeight = useCallback(() => {
    const textarea = textareaRef.current;
    if (textarea) {
      textarea.style.height = 'auto';
      textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
    }
  }, []);

  const handleSend = useCallback(() => {
    const trimmed = input.trim();
    if (!trimmed || isLoading || disabled) return;
    
    onSend(trimmed);
    setInput('');
    
    // 重置高度
    if (textareaRef.current) {
      textareaRef.current.style.height = 'auto';
    }
  }, [input, isLoading, disabled, onSend]);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
  };

  return (
    <div className="chat-input-container">
      <div className="input-wrapper">
        <textarea
          ref={textareaRef}
          value={input}
          onChange={(e) => {
            setInput(e.target.value);
            adjustHeight();
          }}
          onKeyDown={handleKeyDown}
          placeholder="输入消息... (Shift+Enter 换行)"
          disabled={disabled}
          rows={1}
        />
        
        {isLoading ? (
          <button onClick={onStop} className="stop-btn">
            ⏹ 停止
          </button>
        ) : (
          <button
            onClick={handleSend}
            disabled={!input.trim() || disabled}
            className="send-btn"
          >

          </button>
        )}
      </div>
    </div>
  );
};

二、流式消息处理

2.1 SSE (Server-Sent Events) 实现

tsx
// hooks/useSSE.ts
import { useCallback, useRef, useState } from 'react';

interface SSEOptions {
  url: string;
  body: object;
  onMessage: (chunk: string) => void;
  onError?: (error: Error) => void;
  onComplete?: () => void;
}

export const useSSE = () => {
  const [isStreaming, setIsStreaming] = useState(false);
  const abortRef = useRef<AbortController | null>(null);

  const startStream = useCallback(async (options: SSEOptions) => {
    const { url, body, onMessage, onError, onComplete } = options;
    
    // 取消之前的请求
    if (abortRef.current) {
      abortRef.current.abort();
    }
    
    abortRef.current = new AbortController();
    setIsStreaming(true);

    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Accept': 'text/event-stream'
        },
        body: JSON.stringify(body),
        signal: abortRef.current.signal
      });

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      const reader = response.body?.getReader();
      const decoder = new TextDecoder();
      let buffer = '';

      while (reader) {
        const { done, value } = await reader.read();
        
        if (done) break;

        buffer += decoder.decode(value, { stream: true });
        const lines = buffer.split('\n');
        buffer = lines.pop() || '';

        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const data = line.slice(6);
            
            if (data === '[DONE]') {
              onComplete?.();
              setIsStreaming(false);
              return;
            }

            try {
              const parsed = JSON.parse(data);
              const content = parsed.choices?.[0]?.delta?.content || '';
              if (content) {
                onMessage(content);
              }
            } catch (e) {
              // 非 JSON 数据直接输出
              onMessage(data);
            }
          }
        }
      }

      onComplete?.();
    } catch (error) {
      if (error instanceof Error && error.name !== 'AbortError') {
        onError?.(error);
      }
    } finally {
      setIsStreaming(false);
    }
  }, []);

  const stopStream = useCallback(() => {
    abortRef.current?.abort();
    abortRef.current = null;
    setIsStreaming(false);
  }, []);

  return { startStream, stopStream, isStreaming };
};

2.2 EventSource 方案(备选)

tsx
// hooks/useEventSource.ts
import { useEffect, useRef, useCallback, useState } from 'react';

export const useEventSource = (url: string | null) => {
  const [data, setData] = useState<string>('');
  const [error, setError] = useState<Error | null>(null);
  const [isConnected, setIsConnected] = useState(false);
  const esRef = useRef<EventSource | null>(null);

  const connect = useCallback(() => {
    if (!url) return;

    // 关闭旧连接
    esRef.current?.close();

    const es = new EventSource(url);
    esRef.current = es;

    es.onopen = () => setIsConnected(true);

    es.onmessage = (event) => {
      if (event.data === '[DONE]') {
        es.close();
        setIsConnected(false);
        return;
      }
      setData((prev) => prev + event.data);
    };

    es.onerror = (err) => {
      setError(new Error('EventSource error'));
      setIsConnected(false);
      es.close();
    };

    return () => es.close();
  }, [url]);

  const disconnect = useCallback(() => {
    esRef.current?.close();
    setIsConnected(false);
  }, []);

  const reset = useCallback(() => {
    setData('');
    setError(null);
  }, []);

  useEffect(() => {
    const cleanup = connect();
    return () => {
      cleanup?.();
      disconnect();
    };
  }, [connect, disconnect]);

  return { data, error, isConnected, disconnect, reset };
};

2.3 流式消息渲染优化

tsx
// components/StreamingMessage.tsx
import React, { memo, useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';

interface Props {
  content: string;
}

// 使用 memo 避免不必要的重渲染
export const StreamingMessage: React.FC<Props> = memo(({ content }) => {
  // 防抖渲染:每 50ms 更新一次 DOM
  const debouncedContent = useDebounce(content, 50);

  return (
    <div className="streaming-message">
      <ReactMarkdown
        components={{
          code({ node, inline, className, children, ...props }: any) {
            const match = /language-(\w+)/.exec(className || '');
            return !inline && match ? (
              <SyntaxHighlighter
                language={match[1]}
                PreTag="div"
                {...props}
              >
                {String(children).replace(/\n$/, '')}
              </SyntaxHighlighter>
            ) : (
              <code className={className} {...props}>
                {children}
              </code>
            );
          }
        }}
      >
        {debouncedContent}
      </ReactMarkdown>
      
      {/* 光标闪烁效果 */}
      <span className="cursor-blink">▊</span>
    </div>
  );
});

// 自定义防抖 Hook
function useDebounce<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = React.useState(value);

  React.useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debounced;
}

三、状态管理

3.1 Zustand Store 设计

tsx
// store/chatStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Message, Session } from '../types';

interface ChatState {
  // 会话列表
  sessions: Session[];
  currentSessionId: string | null;
  
  // 消息状态
  messages: Message[];
  isLoading: boolean;
  streamingContent: string;
  
  // Actions
  createSession: () => string;
  switchSession: (id: string) => void;
  deleteSession: (id: string) => void;
  
  addMessage: (message: Message) => void;
  updateLastMessage: (content: string) => void;
  setStreamingContent: (content: string) => void;
  appendStreamingContent: (chunk: string) => void;
  clearStreaming: () => void;
  
  setIsLoading: (loading: boolean) => void;
  retryLastMessage: () => void;
}

export const useChatStore = create<ChatState>()(
  persist(
    (set, get) => ({
      sessions: [],
      currentSessionId: null,
      messages: [],
      isLoading: false,
      streamingContent: '',

      createSession: () => {
        const newSession: Session = {
          id: Date.now().toString(),
          title: '新对话',
          createdAt: Date.now(),
          updatedAt: Date.now()
        };
        
        set((state) => ({
          sessions: [newSession, ...state.sessions],
          currentSessionId: newSession.id,
          messages: []
        }));
        
        return newSession.id;
      },

      switchSession: (id) => {
        const session = get().sessions.find((s) => s.id === id);
        if (session) {
          set({
            currentSessionId: id,
            messages: session.messages || []
          });
        }
      },

      deleteSession: (id) => {
        set((state) => {
          const newSessions = state.sessions.filter((s) => s.id !== id);
          return {
            sessions: newSessions,
            currentSessionId: state.currentSessionId === id
              ? newSessions[0]?.id || null
              : state.currentSessionId,
            messages: state.currentSessionId === id ? [] : state.messages
          };
        });
      },

      addMessage: (message) => {
        set((state) => {
          const newMessages = [...state.messages, message];
          
          // 更新会话标题(第一条用户消息)
          const updatedSessions = state.sessions.map((s) =>
            s.id === state.currentSessionId
              ? {
                  ...s,
                  title: s.title === '新对话' && message.role === 'user'
                    ? message.content.slice(0, 20) + '...'
                    : s.title,
                  updatedAt: Date.now(),
                  messages: newMessages
                }
              : s
          );
          
          return { messages: newMessages, sessions: updatedSessions };
        });
      },

      updateLastMessage: (content) => {
        set((state) => {
          const newMessages = [...state.messages];
          const lastMsg = newMessages[newMessages.length - 1];
          if (lastMsg && lastMsg.role === 'assistant') {
            lastMsg.content = content;
          }
          return { messages: newMessages };
        });
      },

      setStreamingContent: (content) => set({ streamingContent: content }),
      
      appendStreamingContent: (chunk) =>
        set((state) => ({
          streamingContent: state.streamingContent + chunk
        })),
      
      clearStreaming: () => set({ streamingContent: '' }),

      setIsLoading: (loading) => set({ isLoading: loading }),

      retryLastMessage: () => {
        const { messages, addMessage, createSession } = get();
        
        // 找到最后一条用户消息
        const lastUserMsgIndex = [...messages]
          .reverse()
          .findIndex((m) => m.role === 'user');
        
        if (lastUserMsgIndex === -1) return;
        
        const actualIndex = messages.length - 1 - lastUserMsgIndex;
        const userMessage = messages[actualIndex];
        
        // 删除该消息之后的所有消息(包括失败的 AI 回复)
        const newMessages = messages.slice(0, actualIndex + 1);
        
        set({ messages: newMessages, isLoading: true });
        
        // 重新发送请求...
      }
    }),
    {
      name: 'chat-storage',
      partialize: (state) => ({ sessions: state.sessions })
    }
  )
);

3.2 对话流程 Hook

tsx
// hooks/useChat.ts
import { useCallback } from 'react';
import { useChatStore } from '../store/chatStore';
import { useSSE } from './useSSE';
import type { Message } from '../types';

export const useChat = () => {
  const {
    messages,
    isLoading,
    streamingContent,
    currentSessionId,
    addMessage,
    setIsLoading,
    appendStreamingContent,
    clearStreaming,
    createSession
  } = useChatStore();

  const { startStream, stopStream, isStreaming } = useSSE();

  const sendMessage = useCallback(async (content: string) => {
    // 确保有当前会话
    if (!currentSessionId) {
      createSession();
    }

    // 添加用户消息
    const userMessage: Message = {
      id: Date.now().toString(),
      role: 'user',
      content,
      timestamp: Date.now()
    };
    addMessage(userMessage);

    setIsLoading(true);
    clearStreaming();

    // 构建消息历史
    const history = [...messages, userMessage].map((m) => ({
      role: m.role,
      content: m.content
    }));

    // 启动流式请求
    await startStream({
      url: '/api/chat/stream',
      body: { messages: history },
      
      onMessage: (chunk) => {
        appendStreamingContent(chunk);
      },
      
      onError: (error) => {
        console.error('Stream error:', error);
        setIsLoading(false);
        
        // 添加错误消息
        addMessage({
          id: Date.now().toString(),
          role: 'assistant',
          content: '抱歉,发生了错误,请稍后重试。',
          timestamp: Date.now(),
          isError: true
        });
      },
      
      onComplete: () => {
        // 将流式内容转为正式消息
        const finalContent = useChatStore.getState().streamingContent;
        
        addMessage({
          id: Date.now().toString(),
          role: 'assistant',
          content: finalContent,
          timestamp: Date.now()
        });
        
        clearStreaming();
        setIsLoading(false);
      }
    });
  }, [
    messages,
    currentSessionId,
    addMessage,
    setIsLoading,
    clearStreaming,
    appendStreamingContent,
    startStream,
    createSession
  ]);

  return {
    messages,
    isLoading,
    isStreaming,
    streamingContent,
    sendMessage,
    stopStream
  };
};

四、实战:ChatGPT 风格聊天界面

4.1 完整页面组件

tsx
// pages/ChatPage.tsx
import React from 'react';
import { ChatMessageList } from '../components/ChatMessageList';
import { ChatInput } from '../components/ChatInput';
import { ChatSidebar } from '../components/ChatSidebar';
import { ChatHeader } from '../components/ChatHeader';
import { useChat } from '../hooks/useChat';
import { useChatStore } from '../store/chatStore';
import './ChatPage.css';

export const ChatPage: React.FC = () => {
  const {
    messages,
    isLoading,
    isStreaming,
    streamingContent,
    sendMessage,
    stopStream
  } = useChat();

  const { sessions, currentSessionId, createSession, switchSession } = useChatStore();

  return (
    <div className="chat-page">
      <ChatSidebar
        sessions={sessions}
        currentId={currentSessionId}
        onSelect={switchSession}
        onNewChat={createSession}
      />
      
      <div className="chat-main">
        <ChatHeader
          title={sessions.find((s) => s.id === currentSessionId)?.title || '新对话'}
          onNewChat={createSession}
        />
        
        <ChatMessageList
          messages={messages}
          isLoading={isLoading}
          streamingMessage={streamingContent}
        />
        
        <ChatInput
          onSend={sendMessage}
          onStop={stopStream}
          isLoading={isStreaming}
        />
      </div>
    </div>
  );
};

4.2 样式文件

css
/* ChatPage.css */
.chat-page {
  display: flex;
  height: 100vh;
  background: #343541;
  color: #ececf1;
}

.chat-sidebar {
  width: 260px;
  background: #202123;
  border-right: 1px solid #4d4d4f;
  display: flex;
  flex-direction: column;
}

.new-chat-btn {
  margin: 12px;
  padding: 12px;
  background: transparent;
  border: 1px solid #4d4d4f;
  border-radius: 6px;
  color: white;
  cursor: pointer;
  transition: background 0.2s;
}

.new-chat-btn:hover {
  background: #2d2d2d;
}

.chat-main {
  flex: 1;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.message-list {
  flex: 1;
  overflow-y: auto;
  padding: 20px 0;
}

.message {
  display: flex;
  padding: 20px;
  gap: 16px;
}

.message.user {
  background: #343541;
}

.message.assistant {
  background: #444654;
}

.message-avatar {
  width: 30px;
  height: 30px;
  border-radius: 3px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 14px;
}

.message.user .message-avatar {
  background: #5436da;
}

.message.assistant .message-avatar {
  background: #19c37d;
}

.message-content {
  flex: 1;
  max-width: 800px;
  line-height: 1.6;
}

.chat-input-container {
  padding: 20px;
  border-top: 1px solid #4d4d4f;
  background: #343541;
}

.input-wrapper {
  max-width: 800px;
  margin: 0 auto;
  display: flex;
  align-items: flex-end;
  gap: 12px;
  background: #40414f;
  border-radius: 12px;
  padding: 12px 16px;
}

.input-wrapper textarea {
  flex: 1;
  background: transparent;
  border: none;
  color: white;
  font-size: 16px;
  resize: none;
  outline: none;
  max-height: 200px;
}

.send-btn, .stop-btn {
  padding: 8px 12px;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  transition: opacity 0.2s;
}

.send-btn {
  background: #19c37d;
  color: white;
}

.send-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.stop-btn {
  background: #ef4444;
  color: white;
}

/* 流式消息光标 */
.cursor-blink {
  animation: blink 1s infinite;
  color: #19c37d;
}

@keyframes blink {
  0%, 50% { opacity: 1; }
  51%, 100% { opacity: 0; }
}

/* 代码块样式 */
pre {
  background: #1e1e1e;
  padding: 16px;
  border-radius: 8px;
  overflow-x: auto;
}

code {
  font-family: 'Fira Code', monospace;
  font-size: 14px;
}

五、性能优化

5.1 虚拟列表(大量消息时)

tsx
// components/VirtualMessageList.tsx
import React, { useRef, useCallback } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { Message } from '../types';

interface Props {
  messages: Message[];
  estimateSize?: number;
}

export const VirtualMessageList: React.FC<Props> = ({
  messages,
  estimateSize = 100
}) => {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: messages.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => estimateSize,
    overscan: 5 // 预渲染 5 条
  });

  const virtualItems = virtualizer.getVirtualItems();

  return (
    <div ref={parentRef} className="virtual-list-container">
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          width: '100%',
          position: 'relative'
        }}
      >
        {virtualItems.map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualItem.size}px`,
              transform: `translateY(${virtualItem.start}px)`
            }}
          >
            <ChatMessage message={messages[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  );
};

5.2 防抖节流优化

tsx
// hooks/useOptimizedScroll.ts
import { useEffect, useRef, useCallback } from 'react';

export const useOptimizedScroll = (
  callback: () => void,
  delay: number = 100
) => {
  const frameRef = useRef<number>();
  const timeoutRef = useRef<NodeJS.Timeout>();

  const throttledCallback = useCallback(() => {
    if (frameRef.current) return;

    frameRef.current = requestAnimationFrame(() => {
      callback();
      frameRef.current = undefined;
    });
  }, [callback]);

  const debouncedCallback = useCallback(() => {
    clearTimeout(timeoutRef.current);
    timeoutRef.current = setTimeout(callback, delay);
  }, [callback, delay]);

  useEffect(() => {
    return () => {
      if (frameRef.current) cancelAnimationFrame(frameRef.current);
      clearTimeout(timeoutRef.current);
    };
  }, []);

  return { throttledCallback, debouncedCallback };
};

六、避坑指南

6.1 常见错误

错误原因解决方案
消息闪烁/抖动流式更新过于频繁使用防抖(50-100ms)或 requestAnimationFrame
滚动位置乱跳新消息插入时未保持滚动记录滚动位置,更新后恢复
内存泄漏EventSource 未关闭组件卸载时调用 close()
重复消息快速点击发送按钮添加 isLoading 状态锁
特殊字符渲染错误Markdown 解析问题使用 react-markdown + 转义处理

6.2 最佳实践

tsx
// 1. 使用 useCallback 缓存回调
const handleSend = useCallback((msg: string) => {
  // ...
}, [deps]);

// 2. 使用 memo 避免不必要渲染
export const Message = memo(({ content }: Props) => {
  // ...
}, (prev, next) => prev.content === next.content);

// 3. 合理使用 useRef 避免闭包问题
const messagesRef = useRef(messages);
messagesRef.current = messages;

// 4. 错误边界处理
class ChatErrorBoundary extends React.Component {
  state = { hasError: false };
  
  static getDerivedStateFromError() {
    return { hasError: true };
  }
  
  render() {
    if (this.state.hasError) {
      return <div>聊天组件出错,请刷新页面</div>;
    }
    return this.props.children;
  }
}

七、面试考点

Q1: SSE 和 WebSocket 的区别?什么时候选择 SSE?

参考答案:

特性SSEWebSocket
通信方向单向(服务器→客户端)双向
协议HTTPWebSocket(ws/wss)
重连浏览器自动重连需手动实现
适用场景服务器推送、流式输出实时双向通信(游戏、协作)

选择 SSE 的场景:

  • AI 流式回复(单向数据流)
  • 股票行情推送
  • 日志实时展示

Q2: 如何处理流式消息的性能优化?

参考答案:

  1. 防抖渲染:流式数据高频更新,使用防抖(50-100ms)批量更新 DOM
  2. 虚拟列表:消息超过 100 条时使用虚拟滚动
  3. 增量渲染:只更新新增内容,不重新渲染整个列表
  4. 使用 requestAnimationFrame:确保渲染在正确时机执行
  5. Web Worker:复杂 Markdown 解析可移至 Worker 线程

Q3: React 状态管理方案对比?为什么选择 Zustand?

参考答案:

方案优点缺点适用场景
useState简单、内置跨组件困难局部状态
Context无需额外库性能问题(任意更新都重渲染)主题、语言
Redux生态完善、DevTools样板代码多大型应用
Zustand简洁、性能好、TypeScript 友好生态较小中小型应用
Jotai原子化、细粒度学习成本复杂状态依赖

选择 Zustand 的原因:

  • 代码简洁,无样板代码
  • 天然支持 TypeScript
  • 持久化中间件方便
  • 性能优秀(选择器自动优化)

Q4: 如何实现"停止生成"功能?

参考答案:

tsx
// 使用 AbortController 中断请求
const abortController = useRef<AbortController | null>(null);

const startStream = async () => {
  abortController.current = new AbortController();
  
  const response = await fetch('/api/stream', {
    signal: abortController.current.signal
  });
  
  const reader = response.body?.getReader();
  
  try {
    while (reader) {
      const { done } = await reader.read();
      if (done) break;
      // 处理数据...
    }
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('用户主动中断');
    }
  }
};

const stopStream = () => {
  abortController.current?.abort();
};

Q5: 如何优化首屏加载时间?

参考答案:

  1. 代码分割:路由级懒加载
tsx
const ChatPage = lazy(() => import('./pages/ChatPage'));
  1. 预加载关键资源
tsx
<link rel="preload" href="/wasm/markdown-parser.wasm" />
  1. 服务端渲染(SSR):改善首屏体验

  2. 虚拟列表:避免一次性渲染大量历史消息

  3. Web Worker:将 Markdown 解析移至后台线程


八、扩展阅读


九、课后练习

  1. 基础练习:使用本节课代码,实现一个支持 Markdown 渲染的聊天界面

  2. 进阶练习

    • 添加消息编辑功能(点击编辑历史消息,重新生成后续对话)
    • 实现消息搜索功能(高亮匹配内容)
    • 添加代码块复制按钮
  3. 挑战练习

    • 实现多模态输入(支持图片上传)
    • 添加语音输入功能(Web Speech API)
    • 实现消息分支功能(同一问题多个回答版本)

参考代码仓库

完整示例代码:github.com/example/react-chat-agent

根据 MIT 许可证发布