React实现类ChatGPT的列表打字动画(WebSocket分块场景)
问题描述
通过WebSocket订阅接收分块JSON数据,数据示例:
- 首次接收:
{ "ObjectKeyHere": ["string 1", "string 2"] }
- 后续接收(包含新增字段):
{ "ObjectKeyHere": ["string 1", "string 2"], "AnotherObjectKeyHere": ["string 3", "string 4"] }
需求:仅对最新分块中的新增列表项应用类似ChatGPT的打字动画。当前实现的TypingList组件效果不符合预期,代码如下:
const TypingList = ({ data, speed = 50 }) => { const [currentChar, setCurrentChar] = useState({ value: '', index: 0, }); const [displayedData, setDisplayedData] = useState([]); let timeoutId = null; const typeNextCharacter = () => { if (currentChar.index >= data.length) return; const nextChar = data[currentChar.index]; setCurrentChar((prev) => ({ ...prev, value: prev.value + nextChar, index: prev.index + 1 })); setDisplayedData((prev) => [...prev, nextChar]); timeoutId = setTimeout(typeNextCharacter, speed); }; useEffect(() => { typeNextCharacter(); return () => clearTimeout(timeoutId); }, [data]); const renderListItem = (item, displayed) => ( <li key={item} className={displayed ? '' : 'hidden'}>{item}</li> ); return ( <div className={styles.typingText}> <ul> {displayedData.map((item, i) => renderListItem(item, i <= currentChar.index))} {renderCursor()} </ul> </div> ); }; export default TypingList;
最佳实现方案
核心逻辑是分离已渲染内容与待打字内容,精准识别新增项后按队列执行动画:
实现思路
- 状态拆分:用三个状态分别管理已完成渲染的项、待打字的项队列、当前正在打字的项及进度
- 新增项识别:每次接收新数据时,对比已渲染列表,提取从未展示过的项加入队列
- 队列化动画:依次处理队列中的项,完成一个再启动下一个,避免动画混乱
完整代码
import { useState, useEffect, useRef } from 'react'; const TypingList = ({ data, speed = 50 }) => { // 已完成渲染的所有列表项 const [renderedItems, setRenderedItems] = useState([]); // 等待执行打字动画的项队列 const [typingQueue, setTypingQueue] = useState([]); // 当前正在打字的项:{ content: 完整文本, currentLength: 当前已渲染长度 } const [currentTypingItem, setCurrentTypingItem] = useState(null); // 存储定时器ID,防止内存泄漏 const timeoutRef = useRef(null); // 扁平化提取JSON中的所有列表项 const flattenAllItems = (dataObj) => { return Object.values(dataObj).flat(); }; // 监听新数据,提取新增项加入队列 useEffect(() => { const allCurrentItems = flattenAllItems(data); // 筛选出未渲染且不在当前打字中的新增项 const newItems = allCurrentItems.filter(item => !renderedItems.includes(item) && !(currentTypingItem?.content === item) ); if (newItems.length > 0) { setTypingQueue(prev => [...prev, ...newItems]); } }, [data, renderedItems, currentTypingItem]); // 执行打字动画逻辑 const processTyping = () => { if (!currentTypingItem) { // 队列有项则取出第一个开始打字 if (typingQueue.length > 0) { const nextItem = typingQueue[0]; setCurrentTypingItem({ content: nextItem, currentLength: 0 }); setTypingQueue(prev => prev.slice(1)); } return; } if (currentTypingItem.currentLength >= currentTypingItem.content.length) { // 当前项打字完成,移入已渲染列表 setRenderedItems(prev => [...prev, currentTypingItem.content]); setCurrentTypingItem(null); timeoutRef.current = setTimeout(processTyping, speed); return; } // 推进当前项的打字进度 setCurrentTypingItem(prev => ({ ...prev, currentLength: prev.currentLength + 1 })); timeoutRef.current = setTimeout(processTyping, speed); }; // 启动/继续打字动画 useEffect(() => { if (!currentTypingItem && typingQueue.length > 0) { processTyping(); } // 清理定时器 return () => clearTimeout(timeoutRef.current); }, [currentTypingItem, typingQueue]); // 渲染打字光标 const renderCursor = () => ( <span className="typing-cursor" style={{ animation: 'blink 1s infinite' }}>|</span> ); return ( <div className="typing-text"> <ul> {/* 已完成渲染的项 */} {renderedItems.map(item => ( <li key={item}>{item}</li> ))} {/* 当前正在打字的项 */} {currentTypingItem && ( <li> {currentTypingItem.content.slice(0, currentTypingItem.currentLength)} {renderCursor()} </li> )} </ul> </div> ); }; export default TypingList;
关键优化点
- 精准新增项识别:通过对比已渲染列表和当前打字项,确保只处理真正新增的内容
- 队列化处理:避免多个项同时触发动画,保证打字顺序和视觉一致性
- 内存安全:使用
useRef存储定时器ID,在组件卸载或状态变化时及时清理 - 状态清晰:拆分状态职责,避免原代码中状态耦合导致的逻辑混乱
内容的提问来源于stack exchange,提问作者ConstantinEx
相关产品推荐
相关产品推荐

