React自定义Toast组件自动关闭及hover暂停功能异常排查
自定义React Toast组件自动关闭/悬停暂停功能异常修复
问题现象
自定义开发的React Toast组件原本运行正常,新增通知定时自动关闭功能后出现异常。
预期交互逻辑:
- Toast弹窗展示到设定时长后,先播放CSS淡出动画,再从DOM中移除
- 用户鼠标悬停在通知上时,暂停该通知的关闭流程,鼠标移开后继续执行关闭操作
实际运行表现极不稳定: - 功能偶尔可正常运行
- 偶发所有通知无法显示、后续新增通知逐个重新加载的问题
- 存在多种不符合预期的异常表现
相关代码
Toast.css
.toast-container { font-size: 24px; box-sizing: border-box; position: fixed; z-index: 10; } .toast-popup { padding: 12px; display: flex; align-items: center; justify-content: space-between; width: 500px; border: solid #f2f2f2; border-radius: 8px; box-shadow: 0 0 10px #999; margin-bottom: 1rem; opacity: 0.9; } .toast-popup:hover { box-shadow: 0 0 12px deepskyblue; opacity: 1 !important; animation-play-state: paused; } .success { background-color: #5cb85c; } .info { background-color: #5bc0de; } .warning { background-color: #f0ad4e; } .danger { background-color: #d9534f; } .toast-text { justify-self: flex-start; width: 100%; padding: 6px 0 6px 6px; opacity: 0.9; } .toast-title { font-weight: 700; font-size: 32px; text-align: left; padding-bottom: 0px; color: #f2f2f2; } .toast-message { padding-top: 0px; text-align: left; color: #f2f2f2; } .toast-icon { float: left; margin: 0 20px 0 10px; opacity: 0.9; } .toast-icon img { width: 50px; height: 50px; fill: #f2f2f2; opacity: 0.9; } .close-button { float: right; align-self: flex-start; font-weight: 600; color: #f2f2f2; background: none; border: none; opacity: 0.9; cursor: pointer; } .top-right { top: 2rem; right: 2rem; } .top-right-slide { top: 2rem; right: 2rem; transition: transform .6s ease-in-out; animation: toast-in-right .7s; } .bottom-right { bottom: 2rem; right: 2rem; } .bottom-right-slide { bottom: 2rem; right: 2rem; transition: transform .6s ease-in-out; animation: toast-in-right .7s; } .top-left { top: 2rem; left: 2rem; } .top-left-slide { top: 2rem; left: 2rem; transition: transform .6s ease-in; animation: toast-in-left .7s; } .bottom-left { bottom: 2rem; left: 2rem; } .bottom-left-slide { bottom: 2rem; left: 2rem; transition: transform .6s ease-in; animation: toast-in-left .7s; } .fadeout { animation: 4s linear 5s 1 normal forwards running toast-fadeout; } @keyframes toast-in-right { from { transform: translateX(100%); } to { transform: translateX(0); } } @keyframes toast-in-left { from { transform: translateX(-100%); } to { transform: translateX(0); } } @keyframes toast-fadeout { from { opacity: 0.9; } to { opacity: 0; } }
Toast.js
注:代码中添加了较多console.log调试日志
import React, {useEffect, useState} from 'react'; import icon_success from './icons/feathericons/check-circle.svg'; import icon_info from './icons/feathericons/info.svg'; import icon_warning from './icons/feathericons/alert-triangle.svg'; import icon_danger from './icons/feathericons/alert-octagon.svg'; import './Toast.css'; const Toast = (props) => { const {toastList, position} = props; const [list, setList] = useState(toastList); const [prevId, setPrevId] = useState(0); // 同步父组件传入的toast列表 useEffect(() => { console.log('useEffect()'); console.log('useEffect() toastList:'); console.log(toastList); setList([...toastList]); }, [toastList]); const markForDeletion = (toast) => { if( toast.isDeleting ) { return; } console.log(`toast ${toast.id} marked for deletion`) toast.isDeleting = true; setTimeout(() => {attemptDeletion(toast)}, 5000); } const attemptDeletion = (toast) => { console.log(`attempting to delete toast ${toast.id}. canDelete = ${toast.canDelete}`); if( toast.canDelete ) { deleteToast(toast.id); } else { console.log(`cannot delete toast ${toast.id}. `); } } const getIcon = (variant) => { switch( variant ) { case 'success': return icon_success; break; case 'info': return icon_info; break; case 'warning': return icon_warning; break; case 'danger': return icon_danger; break; } } const generateId = (toast) => { if( typeof(toast.id) === 'number' ) { return toast.id; } toast.id = prevId + 1; setPrevId(toast.id); return toast.id; } const deleteToast = (id) => { console.log(`deleting toast ${id}`); const deletionIdxList = list.findIndex(e => e.id === id); const deletionIdxToastList = toastList.findIndex(e => e.id === id); console.log(`deletionIdxToastList: ${deletionIdxToastList}`); if(deletionIdxList == null || deletionIdxList === -1) { console.log(`cannot find list idx of id ${id}`); console.log('list:'); console.log(list); return; } if(deletionIdxToastList == null || deletionIdxToastList === -1) { console.log(`cannot find toastList idx of id ${id}`); console.log('toastList:'); console.log(toastList); return; } console.log('list before deletion:'); console.log(list); console.log('toastList before deletion:'); console.log(toastList); console.log('list[deletionIdxList]:'); console.log(list[deletionIdxList]); list.splice(deletionIdxList, 1); console.log('toastList[deletionIdxToastList]:'); console.log(toastList[deletionIdxToastList]); toastList.splice(deletionIdxToastList, 1); setList([...list]); console.log(`toast ${id} deleted successfully`); console.log('list after deletion:'); console.log(list); console.log('toastList after deletion:'); console.log(toastList); } return ( <> <div className={`toast-container ${position}`} > { list.map((toast, i) => ( <div key={i} className={`toast-popup ${toast.variant} ${toast.isDeleting ? (position + ' fadeout') : (position + '-slide')}`} onLoad={() => { if( !toast.isLoaded ) { toast.Id = generateId(toast); toast.canDelete = true; toast.isDeleting = false; toast.isLoaded = true; console.log(`on load ${toast.id}`); setTimeout(() => markForDeletion(toast), 500); } }} onMouseOver={() => { toast.canDelete === true ? toast.canDelete = false : null; toast.isDeleting === true ? toast.isDeleting = false : null; console.log(`mouse over ${toast.id}`); }} onMouseLeave={() => { toast.canDelete === false ? toast.canDelete = true : null; markForDeletion(toast); console.log(`mouse leave ${toast.id}`); }} > <div className={'toast-icon'}> <img src={getIcon(toast.variant)} /> </div> <div className={'toast-text'}> <div className={'toast-title'}> {toast.variant.charAt(0).toUpperCase() + toast.variant.slice(1)} </div> <div className={'toast-message'}>{toast.message}</div> </div> <button className={'close-button'} onClick={() => { toast.canDelete = true; deleteToast(toast.id) } }> X </button> </div> )) } </div> </> ) } Toast.defaultProps = { position: 'bottom-right' } export default Toast;
测试用Home.js片段
注:这是类组件,当前正在改造存量项目,移除对react-toastify库的依赖
// 省略构造函数和其他无关代码... toastSuccess() { const newToast = { variant: 'success', message: 'This is a test of the success variant toast pop-up.' } this.setState({ toastList: [...this.state.toastList, newToast] }); } toastInfo() { const newToast = { variant: 'info', message: 'This is a test of the info variant toast pop-up.' } this.setState({ toastList: [...this.state.toastList, newToast] }); } toastWarning() { const newToast = { variant: 'warning', message: 'This is a test of the warning variant toast pop-up.' } this.setState({ toastList: [...this.state.toastList, newToast] }); } toastDanger() { const newToast = { variant: 'danger', message: 'This is a test of the danger variant toast pop-up.' } this.setState({ toastList: [...this.state.toastList, newToast] }); } render() { return ( <div className="Home" style={{height:'100%'}}> <Toast toastList={this.state.toastList} position={'bottom-right'} /> <div style={{display:'flex', justifyContent:'center'}}> <Button onClick={() => this.toastSuccess()}>Success</Button> <Button onClick={() => this.toastInfo()}>Info</Button> <Button onClick={() => this.toastWarning()}>Warning</Button> <Button onClick={() => this.toastDanger()}>Danger</Button> </div> {/* 其他无关内容 */} </div> ); }
问题根因
代码存在多处违反React运行规则的写法,是导致异常的核心原因:
- 直接修改状态/Props对象:直接给toast对象新增
isDeleting/canDelete/isLoaded属性、用splice直接修改传入的toastList(Props)和本地state的list数组,React无法追踪这种直接修改,不会触发正确重渲染,还会读取到过期状态值。 - 使用无效的DOM事件:把初始化逻辑写在div的
onLoad事件中,该事件仅在body/img/iframe等资源类标签上生效,普通div的onLoad不会触发,初始化逻辑执行时机完全不可控。 - 列表key使用数组索引:渲染列表时key取map的索引i,列表增删时key会变化,导致React错误复用DOM节点,出现通知重载、状态错乱问题。
- 定时器未做清理:悬停暂停时没有清除已设置的关闭定时器,会出现多个定时器同时操作同一个toast的情况,逻辑冲突。
- 状态重复存储:父组件传入
toastList,子组件又存储一份本地list状态,两边状态同步不及时,极易出现状态不一致。 - ID生成逻辑不可靠:在渲染过程中生成ID、修改prevId状态,会导致渲染过程中触发状态更新,引发无限重渲染或ID重复问题。
修复方案
1. 统一状态管理到父组件
移除子组件的本地list状态,所有toast增删逻辑通过父组件状态操作,子组件仅负责渲染和事件触发。
父组件新增toast时直接生成唯一ID,不要在子组件渲染阶段生成:
// 父组件类属性,作为ID自增计数器,不需要放入state toastIdCounter = 0; // 统一的新增toast方法 addToast = (variant, message) => { const newToast = { id: ++this.toastIdCounter, variant, message, isFading: false // 控制淡出动画class } this.setState(prev => ({ toastList: [...prev.toastList, newToast] })) } // 三个操作toast状态的方法,传给子组件调用 markToastFading = (id) => { this.setState(prev => ({ toastList: prev.toastList.map(t => t.id === id ? {...t, isFading: true} : t) })) } removeToast = (id) => { this.setState(prev => ({ toastList: prev.toastList.filter(t => t.id !== id) })) } resetToastFade = (id) => { this.setState(prev => ({ toastList: prev.toastList.map(t => t.id === id ? {...t, isFading: false} : t) })) }
父组件渲染Toast时传入上述方法,触发toast的按钮直接调用addToast即可:
<Toast toastList={this.state.toastList} position="bottom-right" markFading={this.markToastFading} removeToast={this.removeToast} resetFade={this.resetToastFade} /> {/* 按钮部分 */} <Button onClick={() => this.addToast('success', '这是成功提示')}>Success</Button> <Button onClick={() => this.addToast('info', '这是信息提示')}>Info</Button> <Button onClick={() => this.addToast('warning', '这是警告提示')}>Warning</Button> <Button onClick={() => this.addToast('danger', '这是错误提示')}>Danger</Button>
2. 重写Toast子组件逻辑
- 移除本地list、prevId状态,所有数据从props获取
- 用
useRef存储每个toast对应的定时器ID,悬停时清除定时器,避免内存泄漏和逻辑冲突 - 列表key使用toast的唯一ID,禁止用数组索引
- 定时器初始化逻辑放到
useEffect中,不要使用无效的div onLoad事件 - 所有状态修改都调用父组件传入的方法,禁止直接修改对象
修复后的Toast核心逻辑:
import React, { useEffect, useRef } from 'react'; import icon_success from './icons/feathericons/check-circle.svg'; import icon_info from './icons/feathericons/info.svg'; import icon_warning from './icons/feathericons/alert-triangle.svg'; import icon_danger from './icons/feathericons/alert-octagon.svg'; import './Toast.css'; const Toast = ({toastList, position, markFading, removeToast, resetFade}) => { // 存储每个toast对应的定时器,key为toast id const timerRef = useRef({});
相关产品推荐
相关产品推荐

