Next.js中使用stopPropagation()实现点击外部关闭下拉菜单的异常排查
问题根因
- 重复绑定事件监听的逻辑堆积
原代码中useEffect没有添加空依赖数组,也没有编写事件清理逻辑,每次组件重渲染都会重复给document.body绑定一个执行setState(false)的点击监听,且没有任何移除机制。Next.js默认开启严格模式,开发环境下会模拟组件挂载-卸载-重挂载的运行流程,因为缺少清理逻辑,首次挂载时绑定的监听器会被残留,最终导致body上堆积大量重复的状态重置逻辑。 - React合成事件与原生事件的冒泡时序差异
React 17之后将合成事件的委托节点从document迁移到了应用根容器(Next.js中对应#__next节点)。如果项目中使用createPortal将按钮、下拉菜单等元素渲染到body层级,原生事件的DOM冒泡路径将不再经过React根容器:- 点击按钮时,原生事件会沿着DOM树先冒泡到body,触发body上绑定的
setState(false)逻辑 - 之后React才会在内部组件树中触发按钮的
onClick回调,执行stopPropagation()——但此时原生事件已经完成了body层的逻辑触发,停止冒泡根本无法拦截已经执行过的操作 - 最后回调中执行
setState(!state),因为闭包捕获的旧状态已经被提前重置为false,计算得到的新值就是true,最终就会出现状态卡在true、无法正常切换的现象
你fork沙箱后运行正常,是因为沙箱示例代码没有使用Portal将元素挂载到body层,原生冒泡路径始终经过Next.js根容器,stopPropagation可以正常拦截后续冒泡;而自己项目中实际使用了Portal结构,才会触发这个异常。这类问题在普通React项目中不容易复现,是因为多数React项目会将Portal挂载在应用根容器下,而非直接挂到body,原生冒泡路径始终经过React委托节点,拦截逻辑可以正常生效。
- 点击按钮时,原生事件会沿着DOM树先冒泡到body,触发body上绑定的
- 非函数式状态更新的闭包陷阱
原代码使用setState(!state)直接读取组件作用域的state值计算新值,一旦有其他异步逻辑提前修改了state值,回调中拿到的state依然是过期的闭包值,就会出现状态更新的时序混乱。
标准解决方案
- 修复事件绑定的基础问题
给useEffect添加空依赖数组,补上组件卸载时的监听清理逻辑,从根源上避免重复绑定:useEffect(() => { const handleBodyClick = () => { setState(false); } document.body.addEventListener("click", handleBodyClick); // 组件卸载时移除监听 return () => { document.body.removeEventListener("click", handleBodyClick); } }, []); - 放弃依赖
stopPropagation实现外部点击关闭,改用点击目标判断的稳定逻辑
不再依赖事件冒泡拦截,而是在body的点击回调中判断当前点击的DOM元素是否在按钮、下拉菜单的内部,如果在内部就不执行关闭操作。这种实现不受Portal挂载位置、事件委托层级的影响,稳定性更强:const menuRef = useRef(null); const triggerRef = useRef(null); useEffect(() => { const handleBodyClick = (e) => { // 判断点击目标是否在触发按钮或下拉菜单内部 const isClickInsideMenu = menuRef.current?.contains(e.target); const isClickOnTrigger = triggerRef.current?.contains(e.target); if (!isClickInsideMenu && !isClickOnTrigger) { setState(false); } } document.body.addEventListener("click", handleBodyClick); return () => document.body.removeEventListener("click", handleBodyClick); }, []); const onButtonClick = () => { // 状态更新用函数式写法,避免闭包陷阱 setState(prev => !prev); } return ( <div> <button ref={triggerRef} onClick={onButtonClick}>Click</button> <div ref={menuRef}>下拉菜单内容</div> <h1>{state ? "true" : "false"}</h1> </div> ) - 状态更新统一使用函数式写法
所有依赖之前状态计算新值的场景,都通过setState(prev => 新值)的方式更新,避免闭包捕获旧状态的问题。
内容的提问来源于stack exchange,提问作者Uria Levi
相关产品推荐
相关产品推荐

