前端 + AI 进阶 Day 3:打字机效果 + 流式交互控制
前端 + AI 进阶学习路线|Week 1-2:流式体验优化
Day 3:打字机效果 + 流式交互控制
学习时间:2025年12月27日(星期六)
关键词:打字机效果、流式控制、暂停/继续、回退、自动滚动
🎯 今日学习目标
- 实现可交互的“打字机”流式输出(逐字/逐 token)
- 支持 暂停 / 继续 / 回退 三种用户控制操作
- 将流式消息与 虚拟列表聊天界面 完整集成
- 实现智能 自动滚动(用户查看历史时不强行拉回底部)
💡 为什么需要流式交互控制?
在真实 AI 聊天场景中,用户需求是动态的:
- 想暂停输出,仔细阅读当前内容
- 误触发送后希望回退(撤销正在生成的 AI 回复)
- 网络慢时希望手动继续生成
❌ 若只有“自动流式”,体验是单向、不可控的
✅ 加入交互控制,才能实现以人为本的 AI 对话
📚 核心设计思路
| 功能 | 实现方式 |
|---|---|
| 打字机效果 | 用 useState 逐步拼接文本,useEffect + setInterval 控制节奏 |
| 暂停/继续 | 用 isStreaming 状态控制定时器启停 |
| 回退(撤销) | 清除当前流式消息,恢复到上一条完整消息 |
| 自动滚动 | 监听滚动位置,仅在用户未手动滚动时自动到底部 |
⚠️ 关键难点:滚动控制 —— 不能在用户查看历史时强行拉回底部!
🔧 动手实践:构建可交互的流式聊天组件
步骤 1:安装依赖(复用 Day 1 & Day 2)
npx create-react-app day03-streaming-control
cd day03-streaming-control
npm install react-virtual react-markdown remark-gfm rehype-highlight rehype-katex katex
💡 确保在组件中引入 KaTeX CSS(已在
StreamingMarkdown中处理)
步骤 2:复用并升级组件
复用文件:
src/components/StreamingMarkdown.jsx(Day 2)src/components/VirtualChatList.jsx(Day 1,升级为动态高度)
升级 VirtualChatList.jsx(支持动态高度)
// src/components/VirtualChatList.jsx
import { useVirtual } from 'react-virtual';
import { useRef } from 'react';
import StreamingMarkdown from './StreamingMarkdown';
const VirtualChatList = ({ messages }) => {
const parentRef = useRef(null);
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 16px',
borderBottom: '1px solid #f5f5f5',
boxSizing: 'border-box',
}}
>
<div style={{
fontWeight: '600',
color: message.role === 'user' ? '#1890ff' : '#52c41a',
marginBottom: '6px'
}}>
{message.role === 'user' ? '👤 用户' : '🤖 AI'}
</div>
<div>
<StreamingMarkdown content={message.content} />
</div>
</div>
);
})}
</div>
</div>
);
};
export default VirtualChatList;
步骤 3:编写主聊天组件(含控制逻辑)
// src/components/StreamingChat.jsx
import { useState, useRef, useEffect } from 'react';
import VirtualChatList from './VirtualChatList';
const StreamingChat = () => {
// 消息历史(含初始欢迎消息)
const [messages, setMessages] = useState([
{ id: 0, role: 'assistant', content: '你好!我是你的 AI 助手。你可以问我任何问题,我会逐字回答。' }
]);
// 流式状态
const [streamingMessage, setStreamingMessage] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [streamIndex, setStreamIndex] = useState(0);
const [isManualScrolling, setIsManualScrolling] = useState(false);
// 模拟 AI 响应内容(含 Markdown)
const fullAIResponse = `# 流式渲染(Streaming Rendering)
流式渲染是一种**逐步输出内容**的技术,特别适合 AI 聊天场景。
## 为什么需要流式?
- 用户无需等待完整响应
- 提升感知性能
- 支持**打字机效果**
## 代码示例
\`\`\`js
// 模拟流式输出
let index = 0;
const interval = setInterval(() => {
if (index < response.length) {
setDisplayedText(response.slice(0, ++index));
} else {
clearInterval(interval);
}
}, 20);
\`\`\`
## 数学公式
流式也可渲染公式:$\\sum_{i=1}^{n} x_i = X$
欢迎体验!`;
const chatContainerRef = useRef(null);
const streamingIntervalRef = useRef(null);
const streamingMessageRef = useRef('');
// 监听滚动,判断用户是否在查看历史
const handleScroll = () => {
if (!chatContainerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = chatContainerRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50; // 50px 容差
setIsManualScrolling(!isAtBottom);
};
// 自动滚动到底部(仅当用户未手动滚动)
useEffect(() => {
if (chatContainerRef.current && !isManualScrolling) {
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
}
}, [messages, streamingMessage, isManualScrolling]);
// 保持最新 streamingMessage(避免闭包 stale 问题)
useEffect(() => {
streamingMessageRef.current = streamingMessage;
}, [streamingMessage]);
// 启动流式输出
const startStreaming = () => {
setStreamingMessage('');
setStreamIndex(0);
setIsStreaming(true);
};
// 暂停
const pauseStreaming = () => {
setIsStreaming(false);
if (streamingIntervalRef.current) {
clearInterval(streamingIntervalRef.current);
}
};
// 继续
const resumeStreaming = () => {
setIsStreaming(true);
};
// 回退(撤销当前 AI 消息)
const rollbackStreaming = () => {
pauseStreaming();
setStreamingMessage('');
setStreamIndex(0);
// 移除最后一条(不完整的 AI 消息)
if (messages.length > 0 && messages[messages.length - 1].role === 'assistant') {
setMessages(prev => prev.slice(0, -1));
}
};
// 模拟发送消息
const handleSend = () => {
const newUserMsg = {
id: Date.now(),
role: 'user',
content: '再讲讲交互控制?'
};
setMessages(prev => [...prev, newUserMsg]);
setTimeout(() => startStreaming(), 300);
};
// 核心流式逻辑
useEffect(() => {
if (!isStreaming || streamIndex >= fullAIResponse.length) {
if (streamIndex > 0) {
// 流结束,保存完整消息
setMessages(prev => [
...prev,
{ id: Date.now(), role: 'assistant', content: streamingMessageRef.current }
]);
setStreamingMessage('');
setStreamIndex(0);
setIsStreaming(false);
}
return;
}
streamingIntervalRef.current = setInterval(() => {
setStreamIndex(prev => {
const next = prev + 1;
setStreamingMessage(fullAIResponse.slice(0, next));
return next;
});
}, 30); // 每 30ms 输出一个字符
return () => {
if (streamingIntervalRef.current) {
clearInterval(streamingIntervalRef.current);
}
};
}, [isStreaming, streamIndex]);
// 构建渲染消息列表(含流式中消息)
const renderMessages = [...messages];
if (streamingMessage) {
renderMessages.push({
id: 'streaming',
role: 'assistant',
content: streamingMessage,
});
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* 聊天消息区域 */}
<div
ref={chatContainerRef}
onScroll={handleScroll}
style={{
flex: 1,
overflow: 'auto',
border: '1px solid #e8e8e8',
borderRadius: '8px',
padding: '8px',
marginBottom: '16px',
backgroundColor: '#fff',
}}
>
<VirtualChatList messages={renderMessages} />
</div>
{/* 控制按钮 */}
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
{!isStreaming && streamIndex === 0 && (
<button
onClick={handleSend}
style={{
padding: '8px 16px',
backgroundColor: '#1890ff',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
发送测试消息
</button>
)}
{isStreaming ? (
<button
onClick={pauseStreaming}
style={{
padding: '8px 16px',
backgroundColor: '#faad14',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
⏸ 暂停
</button>
) : streamIndex > 0 && streamIndex < fullAIResponse.length ? (
<button
onClick={resumeStreaming}
style={{
padding: '8px 16px',
backgroundColor: '#52c41a',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
▶ 继续
</button>
) : null}
{streamIndex > 0 && (
<button
onClick={rollbackStreaming}
style={{
padding: '8px 16px',
backgroundColor: '#ff4d4f',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
↩ 回退
</button>
)}
</div>
</div>
);
};
export default StreamingChat;
步骤 4:在 App 中集成
// src/App.jsx
import 'katex/dist/katex.min.css'; // 必须引入
import StreamingChat from './components/StreamingChat';
function App() {
return (
<div style={{
padding: '20px',
fontFamily: 'Inter, -apple-system, sans-serif',
maxWidth: '800px',
margin: '0 auto',
height: '90vh'
}}>
<h1 style={{ textAlign: 'center', fontSize: '24px', fontWeight: '700', color: '#333' }}>
🎥 打字机效果 + 流式交互控制
</h1>
<p style={{ textAlign: 'center', color: '#666', marginBottom: '20px' }}>
支持暂停、继续、回退,智能滚动
</p>
<StreamingChat />
</div>
);
}
export default App;
✅ 效果验证
- 点击“发送测试消息” → AI 开始逐字输出(含代码/公式/表格)
- 点击 ⏸ 暂停 → 输出停止
- 点击 ▶ 继续 → 从断点继续
- 点击 ↩ 回退 → 移除当前 AI 消息
- 滚动查看历史 → 不会自动拉回底部
- 新消息完成 → 自动平滑滚动到底部
🤔 思考与延伸
-
性能优化:频繁
setState触发重渲染,是否可合并?
→ 可尝试useReducer+requestAnimationFrame优化更新频率 -
真实 API 对接:如何将模拟数据替换为
fetchEventSource(SSE)?
→ Day 4 将接入 Ollama / OpenAI 真实流式接口 -
多消息流式:能否同时流式多个 AI 消息?(如 Agent 分步思考)
→ 需要更复杂的状态管理(按消息 ID 区分流)
提示:为提升用户体验,可将“继续”按钮逻辑改为从断点续传(而非重新请求),但需后端支持。
📅 明日预告
Day 4:接入真实 AI 流式接口(SSE)
- 使用
fetchEventSource消费 OpenAI / Ollama 的 SSE 流 - 动态解析 token 并渲染
- 错误重试 + 连接状态提示
✍️ 小结
今天,我们不仅让 AI “说话”,还让它能“听话”——用户可以随时暂停、继续、回退,真正掌握对话节奏。结合虚拟列表与流式 Markdown,一个高性能、可交互的 AI 聊天界面已初具雏形。交互控制,是流式体验从“能用”到“好用”的关键跃迁。
💬 实践提示:如果自动滚动出现“抖动”,可增大容差值(如 50px → 80px)。欢迎分享你的交互控制设计!

浙公网安备 33010602011771号