如何用setTimeout处理用户停止输入?修复聊天应用输入指示器异常
输入指示器闪烁问题修复方案
问题根源
- 定时器引用丢失:你用普通变量
typingTimeout存储定时器ID,组件每次渲染(比如状态更新、props变化)时这个变量都会被重置为null,导致之前的定时器无法被clearTimeout清除。多个定时器同时运行,到时间就会发送is_typing: false的消息,和持续输入时发送的true消息交替触发,造成接收端指示器闪烁。 - 触发过于频繁:
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
相关产品推荐
相关产品推荐

