You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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;
最佳实现方案

核心逻辑是分离已渲染内容与待打字内容,精准识别新增项后按队列执行动画:

实现思路

  1. 状态拆分:用三个状态分别管理已完成渲染的项、待打字的项队列、当前正在打字的项及进度
  2. 新增项识别:每次接收新数据时,对比已渲染列表,提取从未展示过的项加入队列
  3. 队列化动画:依次处理队列中的项,完成一个再启动下一个,避免动画混乱

完整代码

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.21 17:02:18