You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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运行规则的写法,是导致异常的核心原因:

  1. 直接修改状态/Props对象:直接给toast对象新增isDeleting/canDelete/isLoaded属性、用splice直接修改传入的toastList(Props)和本地state的list数组,React无法追踪这种直接修改,不会触发正确重渲染,还会读取到过期状态值。
  2. 使用无效的DOM事件:把初始化逻辑写在div的onLoad事件中,该事件仅在body/img/iframe等资源类标签上生效,普通div的onLoad不会触发,初始化逻辑执行时机完全不可控。
  3. 列表key使用数组索引:渲染列表时key取map的索引i,列表增删时key会变化,导致React错误复用DOM节点,出现通知重载、状态错乱问题。
  4. 定时器未做清理:悬停暂停时没有清除已设置的关闭定时器,会出现多个定时器同时操作同一个toast的情况,逻辑冲突。
  5. 状态重复存储:父组件传入toastList,子组件又存储一份本地list状态,两边状态同步不及时,极易出现状态不一致。
  6. 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({});
相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.28 04:45:37