前端 + 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 消息
- 滚动查看历史 → 不会自动拉回底部
- 新消息完成 → 自动平滑滚动到底部
🤔 思考与延伸
- 性能优化:频繁
setState触发重渲染,是否可合并?
→ 可尝试useReducer+requestAnimationFrame - 真实 API 对接:如何将
fullAIResponse替换为fetchEventSource(SSE)?
→ Day 4 将接入真实 AI 流式接口 - 多消息流式:能否同时流式多个 AI 消息?(如 Agent 分步思考)
→ 需要更复杂的状态管理
📅 明日预告
Day 4:接入真实 AI 流式接口(SSE / WebSocket)
- 使用
fetchEventSource消费 OpenAI / Ollama 的 SSE 流 - 动态解析 token 并渲染
- 错误重试 + 连接状态提示
✍️ 小结
今天,我们不仅让 AI “说话”,还让它能“听话”——用户可以随时暂停、继续、回退,真正掌握对话节奏。结合虚拟列表与流式 Markdown,一个高性能、可交互的 AI 聊天界面已初具雏形!
💬 你在实现自动滚动时是否遇到“抖动”?试试用
behavior: 'smooth'+ 判断滚动位置误差。欢迎分享你的控制交互设计!

浙公网安备 33010602011771号