React函数组件keydown事件监听器添加及状态更新异常排查
问题分析与解决方案
失效原因拆解
空依赖useEffect绑定document事件:
闭包陷阱导致updateActiveCellId始终捕获初始的activeCellId值(42),每次触发事件都只会把状态设为43,后续更新无效;且未清理事件监听,存在内存泄漏风险。网格div直接绑定onKeyDown无效:
div默认不具备焦点能力,无法接收键盘事件,必须通过tabIndex属性将其设置为可聚焦元素。useRef绑定div事件无效:
同样存在div无焦点的问题,且闭包导致activeCellId无法获取最新值,同时未做事件清理。
解决方案
以下是两种可行的实现方案,推荐第一种React原生事件绑定方式:
方案一:网格容器绑定事件(推荐)
通过给网格容器添加tabIndex使其可聚焦,配合函数式更新状态避免闭包问题,同时自动聚焦容器:
import { useState, useRef, useEffect } from 'react'; const GridCells: React.FC = () => { const gridArray = [...Array(100).keys()]; const color = [ "bg-slate-50", "bg-slate-100", "bg-slate-200", "bg-slate-300", "bg-slate-400", "bg-slate-500", "bg-slate-600", "bg-slate-700", "bg-slate-800", "bg-slate-900", ]; const [activeCellId, setActiveCellId] = useState(42); const gridRef = useRef<HTMLDivElement>(null); // 组件挂载时自动聚焦网格容器 useEffect(() => { gridRef.current?.focus(); }, []); const updateActiveCellId = (e: React.KeyboardEvent) => { e.preventDefault(); // 阻止页面滚动等默认行为 switch(e.key) { case 'ArrowUp': setActiveCellId(prev => Math.max(prev - 10, 0)); // 防止越界到负数 break; case 'ArrowDown': setActiveCellId(prev => Math.min(prev + 10, 99)); // 防止越界超过99 break; case 'ArrowLeft': setActiveCellId(prev => prev % 10 !== 0 ? prev - 1 : prev); // 行首无法左移 break; case 'ArrowRight': setActiveCellId(prev => prev % 10 !== 9 ? prev + 1 : prev); // 行尾无法右移 break; default: return; } }; return ( <div ref={gridRef} className="grid-rows-10 grid grid-cols-10 gap-0.5 outline-none" // 移除默认聚焦边框(可选) tabIndex={0} onKeyDown={updateActiveCellId} > {gridArray.map((value, id) => { const colorId = Math.floor(id / 10); return ( <div key={id} className={`h-10 w-10 ${color[colorId]} ${id === activeCellId ? 'scale-125 bg-yellow-400' : ''}`} > {id} </div> ); })} </div> ); }; export default GridCells;
方案二:全局document绑定事件
如果需要全局监听键盘事件(无需聚焦网格),可使用useCallback包裹事件函数并处理依赖:
import { useState, useEffect, useCallback } from 'react'; const GridCells: React.FC = () => { const gridArray = [...Array(100).keys()]; const color = [ "bg-slate-50", "bg-slate-100", "bg-slate-200", "bg-slate-300", "bg-slate-400", "bg-slate-500", "bg-slate-600", "bg-slate-700", "bg-slate-800", "bg-slate-900", ]; const [activeCellId, setActiveCellId] = useState(42); // useCallback确保函数依赖更新时重新生成 const updateActiveCellId = useCallback((e: KeyboardEvent) => { e.preventDefault(); switch(e.key) { case 'ArrowUp': setActiveCellId(prev => Math.max(prev - 10, 0)); break; case 'ArrowDown': setActiveCellId(prev => Math.min(prev + 10, 99)); break; case 'ArrowLeft': setActiveCellId(prev => prev % 10 !== 0 ? prev - 1 : prev); break; case 'ArrowRight': setActiveCellId(prev => prev % 10 !== 9 ? prev + 1 : prev); break; default: return; } }, []); useEffect(() => { document.addEventListener('keydown', updateActiveCellId); // 组件卸载时清理事件监听 return () => { document.removeEventListener('keydown', updateActiveCellId); }; }, [updateActiveCellId]); return ( <div className="grid-rows-10 grid grid-cols-10 gap-0.5"> {gridArray.map((value, id) => { const colorId = Math.floor(id / 10); return ( <div key={id} className={`h-10 w-10 ${color[colorId]} ${id === activeCellId ? 'scale-125 bg-yellow-400' : ''}`} > {id} </div> ); })} </div> ); }; export default GridCells;
核心优化点
tabIndex={0}:让网格容器成为可聚焦元素,能够接收键盘事件。- 函数式更新状态:
setActiveCellId(prev => ...)确保每次更新基于最新状态值,避开闭包陷阱。 - 边界处理:通过
Math.max/Math.min和取余运算,保证activeCellId始终在0-99的合法范围内。
内容的提问来源于stack exchange,提问作者Fear4Haunt
相关产品推荐
相关产品推荐

