前端 + AI 进阶 Day 3:打字机效果 + 流式交互控制

当然可以!以下是严格按照你之前 Day 1、Day 2 的模板风格 编写的 Day 3 内容,聚焦 打字机效果 + 流式交互控制(暂停/继续/回退),并完成与虚拟列表的集成。


前端 + AI 进阶学习路线|Week 1-2:流式体验优化

Day 3:打字机效果 + 流式交互控制

学习时间:2025年12月27日(星期六)
关键词:打字机效果、流式控制、暂停/继续、回退、自动滚动


🎯 今日学习目标

  • 实现可交互的“打字机”流式输出(逐字/逐 token)
  • 支持 暂停 / 继续 / 回退 三种用户控制操作
  • 将流式消息与 虚拟列表聊天界面 完整集成
  • 新消息到达时自动滚动到底部(但允许用户手动滚动查看历史)

💡 为什么需要流式交互控制?

在真实 AI 聊天场景中,用户可能:

  • 暂停输出,仔细阅读当前内容
  • 误触发送后想回退(撤销正在生成的 AI 回复)
  • 网络慢时希望手动继续生成

❌ 若只有“自动流式”,体验是单向、不可控
✅ 加入交互控制,才能实现以人为本的 AI 对话


📚 核心设计思路

功能 实现方式
打字机效果 useState 逐步拼接文本,useEffect + setInterval 控制节奏
暂停/继续 isStreaming 状态控制定时器启停
回退(撤销) 清除当前流式消息,恢复到上一条完整消息
自动滚动 监听消息变化 + scrollIntoView,但需判断用户是否正在手动滚动

⚠️ 关键难点:滚动控制 —— 不能在用户查看历史时强行拉到底部!


🔧 动手实践:构建可交互的流式聊天组件

前提:你已完成 Day 1(虚拟列表)Day 2(流式 Markdown)

步骤 1:整合虚拟列表 + 流式 Markdown(复用组件)

确保你有:

  • VirtualChatList.jsx(来自 Day 1)
  • StreamingMarkdown.jsx(来自 Day 2)

步骤 2:编写 StreamingChat 主组件

// src/components/StreamingChat.jsx
import { useState, useEffect, useRef } from 'react';
import VirtualChatList from './VirtualChatList';
import StreamingMarkdown from './StreamingMarkdown';

const StreamingChat = () => {
  // 所有消息(含完整历史 + 当前流式消息)
  const [messages, setMessages] = useState([
    { id: 0, role: 'user', content: '你好!请介绍一下你自己。' },
    // 初始 AI 消息留空,等待流式生成
  ]);

  // 流式控制状态
  const [streamingMessage, setStreamingMessage] = useState(''); // 当前正在生成的文本
  const [isStreaming, setIsStreaming] = useState(false);
  const [streamIndex, setStreamIndex] = useState(0); // 当前已输出字符数

  const fullAIResponse = `# 我是 AI 助手 🤖

我可以帮你:
- 解答技术问题
- 生成代码(支持 **JavaScript**, **Python** 等)
- 渲染数学公式:$E = mc^2$

\`\`\`js
// 示例:打字机效果
function typeWriter(text) {
  let i = 0;
  const timer = setInterval(() => {
    if (i < text.length) {
      console.log(text[i++]);
    } else {
      clearInterval(timer);
    }
  }, 50);
}
\`\`\`

欢迎随时提问!`;

  const chatEndRef = useRef(null);
  const userIsScrolling = useRef(false);
  const streamingInterval = useRef(null);

  // 监听滚动,判断用户是否在手动查看历史
  const handleScroll = () => {
    if (!chatEndRef.current) return;
    const { scrollTop, scrollHeight, clientHeight } = chatEndRef.current.parentElement;
    const isAtBottom = scrollHeight - scrollTop - clientHeight < 10; // 允许 10px 误差
    userIsScrolling.current = !isAtBottom;
  };

  // 启动流式输出
  const startStreaming = () => {
    setStreamingMessage('');
    setStreamIndex(0);
    setIsStreaming(true);
  };

  // 暂停
  const pauseStreaming = () => {
    setIsStreaming(false);
    if (streamingInterval.current) {
      clearInterval(streamingInterval.current);
    }
  };

  // 继续
  const resumeStreaming = () => {
    setIsStreaming(true);
  };

  // 回退(撤销当前 AI 流式消息)
  const rollbackStreaming = () => {
    pauseStreaming();
    // 移除最后一条(不完整的 AI 消息)
    setMessages(prev => prev.slice(0, -1));
    setStreamingMessage('');
    setStreamIndex(0);
  };

  // 模拟“发送消息”
  const handleSend = () => {
    // 添加用户消息(实际应从输入框获取)
    const newUserMessage = { id: messages.length, role: 'user', content: '再讲讲流式渲染?' };
    setMessages(prev => [...prev, newUserMessage]);

    // 启动 AI 流式响应
    setTimeout(() => {
      startStreaming();
    }, 300);
  };

  // 流式生成逻辑(核心)
  useEffect(() => {
    if (!isStreaming || streamIndex >= fullAIResponse.length) {
      if (streamIndex > 0) {
        // 流式结束,将完整消息加入历史
        const finalMessage = {
          id: messages.length,
          role: 'assistant',
          content: streamingMessage,
        };
        setMessages(prev => [...prev, finalMessage]);
        setStreamingMessage('');
        setStreamIndex(0);
        setIsStreaming(false);
      }
      return;
    }

    streamingInterval.current = setInterval(() => {
      setStreamIndex(prev => {
        const next = prev + 1;
        setStreamingMessage(fullAIResponse.slice(0, next));
        return next;
      });
    }, 30); // 每 30ms 输出一个字符

    return () => {
      if (streamingInterval.current) {
        clearInterval(streamingInterval.current);
      }
    };
  }, [isStreaming, streamIndex]);

  // 自动滚动到底部(仅当用户未手动滚动时)
  useEffect(() => {
    if (chatEndRef.current && !userIsScrolling.current) {
      chatEndRef.current.scrollIntoView({ behavior: 'smooth' });
    }
  }, [messages, streamingMessage]);

  // 构建渲染用的消息列表(含流式中消息)
  const renderMessages = [...messages];
  if (streamingMessage) {
    renderMessages.push({
      id: 'streaming',
      role: 'assistant',
      content: streamingMessage,
    });
  }

  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
      {/* 聊天消息区域 */}
      <div
        ref={chatEndRef}
        onScroll={handleScroll}
        style={{
          flex: 1,
          overflow: 'auto',
          border: '1px solid #eee',
          borderRadius: '8px',
          marginBottom: '16px',
          maxHeight: '600px',
        }}
      >
        <VirtualChatList messages={renderMessages} />
      </div>

      {/* 控制按钮 */}
      <div style={{ display: 'flex', gap: '8px' }}>
        {!isStreaming && streamIndex === 0 && (
          <button onClick={handleSend}>发送测试消息</button>
        )}
        {isStreaming ? (
          <button onClick={pauseStreaming}>⏸ 暂停</button>
        ) : streamIndex > 0 && streamIndex < fullAIResponse.length ? (
          <button onClick={resumeStreaming}>▶ 继续</button>
        ) : null}
        {streamIndex > 0 && (
          <button onClick={rollbackStreaming}>↩ 回退</button>
        )}
      </div>
    </div>
  );
};

export default StreamingChat;

步骤 3:更新 VirtualChatList 以支持动态高度(关键!)

修改 Day 1 的 VirtualChatList.jsx,支持 动态计算消息高度

// src/components/VirtualChatList.jsx(更新版)
import { useVirtual } from 'react-virtual';
import { useRef, useMemo } from 'react';
import StreamingMarkdown from './StreamingMarkdown';

const VirtualChatList = ({ messages }) => {
  const parentRef = useRef(null);
  const rowRefs = useRef(new Map());

  // 使用 measureElement 支持动态高度
  const virtualizer = useVirtual({
    size: messages.length,
    parentRef,
    estimateSize: () => 80, // 初始估算
    overscan: 5,
    measureElement: (el) => el?.getBoundingClientRect().height || 60,
  });

  return (
    <div
      ref={parentRef}
      style={{
        height: '100%',
        overflow: 'auto',
        position: 'relative',
      }}
    >
      <div
        style={{
          height: `${virtualizer.totalSize}px`,
          width: '100%',
          position: 'relative',
        }}
      >
        {virtualizer.virtualItems.map((virtualRow) => {
          const message = messages[virtualRow.index];
          return (
            <div
              key={virtualRow.index}
              ref={virtualizer.measureElement}
              data-index={virtualRow.index}
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                transform: `translateY(${virtualRow.start}px)`,
                padding: '12px',
                borderBottom: '1px solid #f0f0f0',
                boxSizing: 'border-box',
              }}
            >
              <div style={{ fontWeight: 'bold', color: message.role === 'user' ? '#1890ff' : '#52c41a' }}>
                {message.role === 'user' ? '👤 用户' : '🤖 AI'}
              </div>
              <div style={{ marginTop: '8px' }}>
                <StreamingMarkdown content={message.content} />
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
};

export default VirtualChatList;

步骤 4:在 App.jsx 中使用

// src/App.jsx
import StreamingChat from './components/StreamingChat';

function App() {
  return (
    <div style={{ padding: '20px', fontFamily: 'sans-serif', maxWidth: '800px', margin: '0 auto' }}>
      <h1>AI 聊天:流式交互控制</h1>
      <StreamingChat />
    </div>
  );
}

export default App;

✅ 效果验证

  • 点击“发送测试消息” → AI 开始逐字输出
  • 点击 ⏸ 暂停 → 输出停止
  • 点击 ▶ 继续 → 从断点继续
  • 点击 ↩ 回退 → 移除当前 AI 消息
  • 滚动查看历史 → 不会自动拉回底部
  • 新消息完成 → 自动平滑滚动到底部

🤔 思考与延伸

  1. 性能优化:频繁 setState 触发重渲染,是否可合并?
    → 可尝试 useReducer + requestAnimationFrame
  2. 真实 API 对接:如何将 fullAIResponse 替换为 fetchEventSource(SSE)?
    → Day 4 将接入真实 AI 流式接口
  3. 多消息流式:能否同时流式多个 AI 消息?(如 Agent 分步思考)
    → 需要更复杂的状态管理

📅 明日预告

Day 4:接入真实 AI 流式接口(SSE / WebSocket)

  • 使用 fetchEventSource 消费 OpenAI / Ollama 的 SSE 流
  • 动态解析 token 并渲染
  • 错误重试 + 连接状态提示

✍️ 小结

今天,我们不仅让 AI “说话”,还让它能“听话”——用户可以随时暂停、继续、回退,真正掌握对话节奏。结合虚拟列表与流式 Markdown,一个高性能、可交互的 AI 聊天界面已初具雏形!

💬 你在实现自动滚动时是否遇到“抖动”?试试用 behavior: 'smooth' + 判断滚动位置误差。欢迎分享你的控制交互设计!

posted @ 2025-12-26 15:18  XiaoZhengTou  阅读(2)  评论(0)    收藏  举报