React模态窗口无法关闭:成因及非setTimeout解决方案咨询
问题重现
以下是你的TransactionItem组件代码:
import c from '../Profile.module.css'; import { useState } from 'react'; import { createPortal } from 'react-dom'; import Modal from '../Modal/Modal'; const TransactionItem = (props) => { const modalRoot = document.querySelector('#root > div'); const [showModal, setShowModal] = useState(false); const short = str => str.substring(str.length - 4) + '...' + str.substring(0, 5); const handleClose = () => { setShowModal(false); } return ( <div className={c.transaction_item} onClick={() => setShowModal(true)}> <div className={c.transaсtion_data}> <div className={c.icon}><span>$</span></div> <div className={c.transaction_info}> {props.type === "+" ? 'Пополнение' : 'Перевод'} <div className={c.transaction_where}>{short(props.from)} -> {short(props.to)}</div> </div> </div> <span className={c.transaction_total}>{props.type + props.total}</span> {showModal && createPortal( <Modal onClose={handleClose} date='23.05.2024 в 09:41' {...props} />, modalRoot )} </div> ) } export default TransactionItem;
点击打开模态窗口后无法关闭,只有将handleClose修改为以下代码才能正常关闭:
const handleClose = () => { setTimeout(() => { setShowModal(false); }, 0) }
问题成因
核心原因是事件冒泡:
虽然你用createPortal把Modal渲染到了DOM的其他位置,但React的事件系统是基于组件树而非真实DOM树工作的。当点击Modal的关闭按钮时,事件会沿着React组件树向上冒泡,触发TransactionItem外层div的onClick={() => setShowModal(true)}。
这就导致一个矛盾:你刚调用setShowModal(false)关闭模态框,冒泡上来的事件又立刻把showModal设回true,视觉上就表现为模态框关不掉。
而setTimeout能生效,是因为它把状态更新放到了事件循环的下一轮执行,此时父元素的onClick事件已经执行完毕,不会再覆盖你的状态更新。
替代解决方案(无需setTimeout)
方案一:阻止事件冒泡
在Modal组件内部,给关闭按钮或Modal的外层容器添加事件处理,阻止事件向上冒泡:
比如在Modal的关闭按钮点击时:
// Modal组件内部的关闭按钮逻辑 const handleClose = (e) => { e.stopPropagation(); // 阻止事件冒泡到父组件 props.onClose(); };
或者直接给Modal的最外层容器添加onClick阻止冒泡:
// Modal组件的根元素 <div className="modal" onClick={(e) => e.stopPropagation()}> {/* Modal内容 */} <button onClick={props.onClose}>关闭</button> </div>
方案二:拆分触发区域
把打开模态框的点击事件,从整个transaction_item容器移到专门的触发元素上,比如只让交易信息区域触发打开操作:
// 修改TransactionItem的返回结构 return ( <div className={c.transaction_item}> {/* 仅该区域触发模态框打开 */} <div className={c.transaсtion_data} onClick={() => setShowModal(true)}> <div className={c.icon}><span>$</span></div> <div className={c.transaction_info}> {props.type === "+" ? 'Пополнение' : 'Перевод'} <div className={c.transaction_where}>{short(props.from)} -> {short(props.to)}</div> </div> </div> <span className={c.transaction_total}>{props.type + props.total}</span> {showModal && createPortal( <Modal onClose={handleClose} date='23.05.2024 в 09:41' {...props} />, modalRoot )} </div> );
这样点击Modal时,不会触发父元素的打开逻辑,自然不会出现状态被覆盖的问题。
方案三:父元素点击事件过滤
在父元素的onClick中,判断点击目标是否属于Modal相关元素,若是则不执行打开逻辑:
// TransactionItem中的点击处理 const handleOpen = (e) => { // 判断点击目标是否是Modal或其后代元素 if (e.target.closest('.modal')) return; setShowModal(true); }; // 外层div的onClick改为handleOpen <div className={c.transaction_item} onClick={handleOpen}>
这种方法需要给Modal添加统一的类名,不如前两种方案直接简洁。
内容的提问来源于stack exchange,提问作者Lorenzo Lavantelli

