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

如何用setTimeout处理用户停止输入?修复聊天应用输入指示器异常

输入指示器闪烁问题修复方案

问题根源

  1. 定时器引用丢失:你用普通变量typingTimeout存储定时器ID,组件每次渲染(比如状态更新、props变化)时这个变量都会被重置为null,导致之前的定时器无法被clearTimeout清除。多个定时器同时运行,到时间就会发送is_typing: false的消息,和持续输入时发送的true消息交替触发,造成接收端指示器闪烁。
  2. 触发过于频繁:handleTyping绑定在onKeyDown上,每次按键(包括回车、退格等非输入按键)都会触发,频繁发送is_typing: true,加重了状态冲突。

修复步骤

1. 用useRef保存定时器引用

把普通变量typingTimeout换成useRef,确保跨渲染周期保留定时器ID:

// 替换原来的 let typingTimeout = null;
const typingTimeout = useRef(null);

2. 修改handleTyping逻辑

  • 清除定时器时使用typingTimeout.current
  • 发送is_typing: false时检查WebSocket连接状态,避免连接已关闭时报错
const handleTyping = () => {
  // 清除之前的定时器
  if (typingTimeout.current) {
    clearTimeout(typingTimeout.current);
  }

  if (!ws.current || ws.current.readyState !== WebSocket.OPEN) {
    return;
  }

  // 发送正在输入的信号
  ws.current.send(
    JSON.stringify({
      type: "chat_typing",
      token: token,
      recipientId: recipientId,
      is_typing: true,
    })
  );

  // 设置新的定时器,5秒后发送停止输入信号
  typingTimeout.current = setTimeout(() => {
    if (ws.current && ws.current.readyState === WebSocket.OPEN) {
      ws.current.send(
        JSON.stringify({
          type: "chat_typing",
          token: token,
          recipientId: recipientId,
          is_typing: false,
        })
      );
    }
  }, 5000);
};

3. 调整输入框的事件绑定

把onKeyDown={handleTyping}换成在onChange中触发,只在输入内容变化时执行输入检测,避免非输入按键触发:

<input
  autoFocus
  className="message-input"
  type="text"
  value={message}
  onChange={(e) => {
    setMessage(e.target.value);
    handleTyping(); // 在内容变化时触发输入检测
  }}
/>

4. 优化接收端状态判断

简化渲染条件,避免初始空状态导致的异常判断:

{isTyping.is_typing && isTyping.sender !== currentUser.username ? (
  <p className="typing-indicator">{isTyping.message}</p>
) : null}

5. 组件卸载时清理资源

在useEffect的返回函数中清除定时器和关闭WebSocket,避免内存泄漏:

useEffect(() => {
  fetchData();
  handleNewChat();

  // 组件卸载时清理
  return () => {
    if (typingTimeout.current) {
      clearTimeout(typingTimeout.current);
    }
    if (ws.current) {
      ws.current.close();
    }
  };
}, [serverURL, recipientId]);

完整修改后的组件代码

import React, { useEffect, useRef, useState } from "react";
import axios from "axios";

function ChatRoom({ serverURL, recipientId }) {
  const ws = useRef(null);
  const typingTimeout = useRef(null);

  const [loading, setLoading] = useState(true);
  const [newMessageLoading, setNewMessageLoading] = useState(false);
  const [error, setError] = useState(null);
  const [isTyping, setIsTyping] = useState({});

  const [currentUser, setCurrentUser] = useState(null);
  const [recipient, setRecipient] = useState(null);
  const [chatHistory, setChatHistory] = useState([]);
  const [message, setMessage] = useState("");

  const token = localStorage.getItem("Token");

  const fetchData = async () => {
    try {
      const currentUserResponse = await axios.get(
        `http://${serverURL}/user/token/${token}`
      );
      setCurrentUser(currentUserResponse.data);

      const recipientResponse = await axios.get(
        `http://${serverURL}/user/id/${recipientId}`
      );
      setRecipient(recipientResponse.data);

      const chatroomResponse = await axios.get(
        `http://${serverURL}/chatroom/${token}/${recipientId}`
      );

      if (!chatroomResponse) {
        setChatHistory([]);
      } else {
        const messagesResponse = await axios.get(
          `http://${serverURL}/chatroom/${chatroomResponse.data.id}/messages`
        );
        setChatHistory(messagesResponse.data);
      }

      setLoading(false);
    } catch (error) {
      console.error("Error fetching data: ", error);
      setError(error);
      setLoading(false);
    }
  };

  const handleNewChat = async () => {
    axios
      .get(`http://${serverURL}/test_token`, {
        headers: {
          Authorization: `Token ${token}`,
        },
      })
      .then((response) => {
        if (response.data.split(" ")[0] === "Passed") {
          try {
            ws.current = new WebSocket(
              `ws://${serverURL}/ws/socket-server/?token=${token}&recipient=${recipientId}`
            );

            ws.current.onopen = () => {
              console.log("Websocket connection established.");
            };

            ws.current.onmessage = (event) => {
              const message = JSON.parse(event.data);
              const type = message.type;

              if (type === "chat.typing") {
                setIsTyping(message);
              } else {
                const newMessage = message;
                setChatHistory((prevChatHistory) => [
                  newMessage,
                  ...prevChatHistory,
                ]);

                setNewMessageLoading(false);
              }
            };
          } catch (error) {
            console.error(error);
          }
        }
      })
      .catch((error) => {
        setError(error);
        console.error(error);
      });
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    setNewMessageLoading(true);
    if (ws.current && ws.current.readyState === WebSocket.OPEN) {
      ws.current.send(
        JSON.stringify({
          type: "chat_message",
          token: token,
          recipientId: recipientId,
          message: message,
        })
      );
      // 发送消息后立即停止输入指示器
      if (typingTimeout.current) {
        clearTimeout(typingTimeout.current);
        ws.current.send(
          JSON.stringify({
            type: "chat_typing",
            token: token,
            recipientId: recipientId,
            is_typing: false,
          })
        );
      }
    }
    setMessage("");
  };

  const handleTyping = () => {
    if (typingTimeout.current) {
      clearTimeout(typingTimeout.current);
    }

    if (!ws.current || ws.current.readyState !== WebSocket.OPEN) {
      return;
    }

    ws.current.send(
      JSON.stringify({
        type: "chat_typing",
        token: token,
        recipientId: recipientId,
        is_typing: true,
      })
    );

    typingTimeout.current = setTimeout(() => {
      if (ws.current && ws.current.readyState === WebSocket.OPEN) {
        ws.current.send(
          JSON.stringify({
            type: "chat_typing",
            token: token,
            recipientId: recipientId,
            is_typing: false,
          })
        );
      }
    }, 5000);
  };

  useEffect(() => {
    fetchData();
    handleNewChat();

    return () => {
      if (typingTimeout.current) {
        clearTimeout(typingTimeout.current);
      }
      if (ws.current) {
        ws.current.close();
      }
    };
  }, [serverURL, recipientId]);

  return (
    <>
      {loading || newMessageLoading ? (
        <p className="loading">Loading...</p>
      ) : (
        <div className="chatroom">
          <div className="chat-title">
            <h3>Chat with {recipient.username}</h3>
          </div>
          <div className="feed">
            {isTyping.is_typing && isTyping.sender !== currentUser.username ? (
              <p className="typing-indicator">{isTyping.message}</p>
            ) : null}

            {chatHistory.length === 0 ? (
              <p>Get chatting...</p>
            ) : (
              chatHistory.map((message) => (
                <div
                  className={
                    currentUser.id === message.sender
                      ? "message-user"
                      : "message"
                  }
                  key={message.id}
                >
                  <div className="message-content">
                    <h4>
                      {currentUser.id === message.sender
                        ? "You"
                        : recipient.username}
                    </h4>
                    <p>{message.content}</p>
                  </div>
                </div>
              ))
            )}
          </div>
          <div className="message-form">
            <form onSubmit={handleSubmit}>
              <input
                autoFocus
                className="message-input"
                type="text"
                value={message}
                onChange={(e) => {
                  setMessage(e.target.value);
                  handleTyping();
                }}
              />
              <input className="send-btn" type="submit" value="Send" />
            </form>
          </div>
        </div>
      )}
    </>
  );
}

export default ChatRoom;

额外建议

  • 后端Django Channels消费者中,建议对同一用户的chat_typing消息做防抖处理,避免短时间内接收过多重复消息。
  • 可以调整定时器的延迟时间(当前是5秒),根据产品需求优化用户体验。

内容的提问来源于stack exchange,提问作者Dan Gray

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.25 05:54:59