梳理React onMouseEnter等事件状态冲突 解决移动端首次点击无法展开导航子项问题
问题根因
- 移动端浏览器存在触摸转鼠标的模拟机制:用户手指触摸屏幕时,会在真实click事件触发前,自动在当前触摸位置依次派发模拟的
mouseover、mouseenter、mousemove事件,目的是兼容只实现了鼠标交互逻辑的老旧网页。 - 现有逻辑存在时序冲突,最终状态不符合预期:
- 组件初始状态
isActive为false - 用户首次触摸时,浏览器先派发模拟的
onMouseEnter事件,执行setIsActive(true)触发状态更新 - 紧接着真实click事件派发,但click回调的闭包捕获的
isActive还是更新前的旧值false,执行setIsActive(!isActive)时,实际会把isActive设回false - 两次状态更新叠加的最终结果是
isActive保持false,子元素不会展开,形成“首次点击无反应、第二次点击才生效”的感知。
- 组件初始状态
修正方案
核心思路是从根源上隔离移动端的模拟鼠标事件,避免无意义的状态干扰,同时修正状态更新的闭包问题,具体改动如下:
- 增加触摸状态标记,在触摸事件触发时打标记,拦截后续100ms内派发的模拟鼠标事件,不让模拟事件修改组件状态
- 所有状态更新改为函数式写法,直接基于最新的状态值做变更,避免闭包捕获旧值导致的逻辑错乱
- 完全保留PC端的鼠标悬停交互逻辑,不影响原有桌面端的使用体验
修正后的完整组件代码:
const ListItem = ({ children, title = '' }) => { const [isActive, setIsActive] = useState(false) // 用ref存储触摸标记,避免频繁触发组件重渲染 const isTouchingRef = useRef(false) useEffect(() => { console.log('isActive', isActive) }, [isActive]) return ( <li className={`depth1${isActive ? ' is-active' : ''}`} onTouchStart={() => { // 触摸开始时打标记,100ms后清除(匹配浏览器模拟鼠标事件的常规派发延迟) isTouchingRef.current = true setTimeout(() => { isTouchingRef.current = false }, 100) }} onMouseEnter={() => { // 触摸触发的模拟鼠标事件直接跳过,不修改状态 if (isTouchingRef.current) return console.log('onMouseEnter') setIsActive(true) }} onMouseLeave={() => { if (isTouchingRef.current) return console.log('onMouseLeave') setIsActive(false) }} > <a href="#" onClick={(e) => { console.log('onClick a') e.preventDefault() // 用函数式更新,拿到最新的状态值做取反,彻底规避闭包旧值问题 setIsActive(prev => !prev) }} > {title} </a> {isActive && ( <ul onClick={() => { console.log('onClick ul') setIsActive(prev => !prev) }} > {children} </ul> )} </li> ) }
方案效果
- 移动端首次点击时,触摸触发的模拟
onMouseEnter会被拦截,不会修改状态,真实click事件触发时直接把isActive从false更新为true,子元素立刻展开,不需要二次点击 - PC端没有触摸事件触发的标记,原有的悬停展开、移出收起的逻辑完全不受影响,和原有体验一致
- 100ms的拦截阈值匹配主流移动端浏览器的事件派发延迟,不会误判真实用户的鼠标操作
可选优化:可以通过window.matchMedia('(hover: hover)')判断当前设备是否支持悬停交互,对于完全不支持悬停的纯触摸设备,可直接跳过鼠标事件的状态修改逻辑,减少不必要的事件判断开销。
内容的提问来源于stack exchange,提问作者dev_el
相关产品推荐
相关产品推荐

