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

React列表遍历动画延迟Bug:初始触发两次且元素卡顿

问题描述

需求是遍历按钮列表,为每个按钮依次添加动画,且在下一次迭代时移除前一个元素的动画。但运行代码时出现两个问题:

  • 初始阶段动画会触发两次
  • 有一个元素始终保留着动画效果

组件代码如下:

import type { NextPage } from 'next'
import Head from 'next/head'
import { useRouter } from 'next/router'
import { useEffect, useRef, useState } from 'react'
import { IoCheckmark, IoClose, IoHome, IoRefresh } from 'react-icons/io5'
import Page from '../components/page/Page'
import styles from '../styles/Play.module.css'
import { distance } from '../utils/distance'
import { randomInt, randomFloat } from '../utils/random'

function ShowSequence(props: any) {
    const [index, setIndex] = useState(0);
    const [sequence, setSequence] = useState(props.sequence);
    const [timer, setTimer] = useState<any>();

    useEffect(() => {
        console.log(index)
        if (index > 0) document.getElementById(sequence[index - 1])?.classList.toggle(styles.animate);
        if (index < sequence.length) document.getElementById(sequence[index])?.classList.toggle(styles.animate);
        else return clearInterval(timer);

        setTimer(setTimeout(() => setIndex(index + 1), 3000));
    }, [index]);

    return <div className={styles.button}>
        {
            props.map ? props.map.map((button: any) => {
                return <button key={button.buttonId} className={styles.button} id={button.buttonId} style={{ top: button.y + "px", left: button.x + "px", backgroundColor: button.color }}></button>
            }) : null
        }
    </div>;
}

function DoTask(props: any) {
    return <div>

    </div>;
}

function ChooseSequence(props: any) {
    const [sequence, setSequence] = useState(props.sequence);
    const [index, setIndex] = useState(0);
    const [timer, setTimer] = useState<any>();
    const [buttonMap, setButtonMap] = useState<any>({});

    console.log(sequence);

    return <div className={styles.button}>
        {
            props.map ? props.map.map((button: any) => {
                return <button key={button.buttonId} className={styles.button} id={button.buttonId} style={{ top: button.y + "px", left: button.x + "px", backgroundColor: button.color }} onClick={(e) => {
                    let correctSequence = sequence[index] === button.buttonId;
                    e.currentTarget.classList.toggle(correctSequence ? styles.correctButton : styles.wrongButton);
                    buttonMap[button.buttonId] = correctSequence ? <IoCheckmark size={20} color="white"></IoCheckmark> : <IoClose size={20} color="white"></IoClose>;
                    setButtonMap(buttonMap);
                    setIndex(index + 1);
                }}>
                    { (buttonMap[button.buttonId]) ? buttonMap[button.buttonId] : button.buttonId }
                </button>
            }) : null
        }
    </div>;
}

function Error(props: any) {
    return <div className={styles.errorMenu}>
        <h1>You lost!</h1>
        <p>You reached level: {props.level}</p>
        <div className={styles.container}>
            <div className={styles.item}></div>
            <div className={styles.item}></div>
            <div className={styles.item}></div>
            <div className={styles.item}></div>
            <div className={styles.item}></div>
            <div className={styles.item}></div>
            <div className={styles.item}></div>
            <div className={styles.item}></div>
            <div className={styles.item}></div>
            <div className={styles.item}></div>
        </div>
        <div className={styles.row}>
            <button className={styles.retryButton} onClick={() => window.location.href = "/play"}><IoRefresh></IoRefresh></button>
            <button className={styles.closeButton} onClick={() => window.location.href = "/"}><IoHome></IoHome></button>
        </div>
    </div>;
}

enum State {
    SHOWSEQUENCE,
    DOTASK,
    CHOOSESEQUENCE,
    ERROR
}

const Play: NextPage = () => {
    let [state, setState] = useState<State>(State.SHOWSEQUENCE);
    let [sequence, setSequence] = useState<number[]>([randomInt(1, 20), randomInt(1, 20), randomInt(1, 20), randomInt(1, 20)]);
    let [map, setMap] = useState<any[]>();
    let [level, setLevel] = useState(1);

    let component;

    useEffect(() => {
        if (state === State.SHOWSEQUENCE) {
            let newSequenceId = randomInt(1, 20);
            setSequence((prevSequence: number[]) => [...prevSequence, newSequenceId])
        }
    }, [state]);

    useEffect(() => {
        let buttonIds = Array.from({ length: 20 }, (v, k) => k + 1);
        const { innerWidth, innerHeight } = window;

        let colors: string[] = ["#c0392b", "#e67e22", "#27ae60", "#8e44ad", "#2c3e50"];

        let buttonMap: any[] = [];

        let rows = buttonIds.length / 10;
        let columns = rows > 0 ? buttonIds.length / rows : buttonIds.length;

        for (let row = 0; row < rows; row++) {
            for (let col = 0; col < columns; col++) {
                let color = colors[Math.floor(randomFloat() * colors.length)];
                let x = innerWidth / columns * col + 100;
                let y = innerHeight / rows * row + 100;
                let offsetX = (randomFloat() < .5) ? -1 : 1 * randomFloat() * ((innerWidth / columns) - 100);
                let offsetY = (randomFloat() < .5) ? -1 : 1 * randomFloat() * ((innerHeight / rows) - 100);
                if (x + offsetX + 100 > innerWidth) offsetX -= ((x + offsetX) - innerWidth) + 100;
                if (y + offsetY + 100 > innerHeight) offsetY -= ((y + offsetY) - innerHeight) + 100;
                buttonMap.push({ buttonId: buttonIds[row * columns + col], x: x + offsetX, y: y + offsetY, color })
            }
        }

        setMap(buttonMap);
    }, [])

    switch (state) {
        case State.SHOWSEQUENCE:
            component = <ShowSequence map={map} sequence={sequence} changeState={() => setState(State.DOTASK)}></ShowSequence>;
            break;
        case State.DOTASK:
            component = <DoTask changeState={() => setState(State.CHOOSESEQUENCE)} onError={() => setState(State.ERROR)}></DoTask>
            break;
        case State.CHOOSESEQUENCE:
            component = <ChooseSequence map={map} sequence={sequence} changeState={() => setState(State.SHOWSEQUENCE)} onError={() => setState(State.ERROR)}></ChooseSequence>;
            break;
    }

    return (
        <Page color="blue">
            { state === State.ERROR ? <Error level={level}></Error> : null }
            {component}
        </Page>
    )
}

export default Play
问题根源
  1. 初始动画触发两次:

    • ShowSequence内部维护了独立的sequence状态,初始复制props值后,父组件的useEffect会给sequence添加新元素,导致子组件sequence更新,额外触发一次useEffect执行,重复触发动画。
    • 组件挂载时index从0执行一次,sequence更新后又会再次触发useEffect,造成初始动画重复。
  2. 元素始终保留动画:

    • 使用classList.toggle()切换动画类,遍历到最后一个元素时,仅移除了前一个元素的动画,最后一个元素的动画类未被移除就结束循环。
    • 误用clearInterval(timer),实际使用的是setTimeout,应该用clearTimeout。
修复方案

1. 重构ShowSequence组件

移除内部sequence状态,直接使用props传递的值,用useRef管理定时器避免闭包问题,明确添加/移除动画类:

function ShowSequence(props: any) {
    const [index, setIndex] = useState(0);
    const timerRef = useRef<NodeJS.Timeout | null>(null);

    // 每次sequence更新时重置index
    useEffect(() => {
        setIndex(0);
        return () => {
            if (timerRef.current) clearTimeout(timerRef.current);
        };
    }, [props.sequence]);

    useEffect(() => {
        // 移除前一个元素的动画
        if (index > 0) {
            const prevButton = document.getElementById(props.sequence[index - 1]);
            prevButton?.classList.remove(styles.animate);
        }

        // 处理当前元素动画
        if (index < props.sequence.length) {
            const currentButton = document.getElementById(props.sequence[index]);
            currentButton?.classList.add(styles.animate);
            
            timerRef.current = setTimeout(() => {
                // 先移除当前动画,再更新索引
                currentButton?.classList.remove(styles.animate);
                setIndex(index + 1);
            }, 3000);
        } else {
            // 序列播放完成,切换到下一个状态
            props.changeState();
        }

        return () => {
            if (timerRef.current) clearTimeout(timerRef.current);
        };
    }, [index, props.sequence, props.changeState]);

    return <div className={styles.button}>
        {props.map?.map((button: any) => (
            <button 
                key={button.buttonId} 
                className={styles.button} 
                id={button.buttonId} 
                style={{ top: `${button.y}px`, left: `${button.x}px`, backgroundColor: button.color }}
            ></button>
        ))}
    </div>;
}

2. 修复父组件sequence更新逻辑

避免初始进入SHOWSEQUENCE状态时重复添加元素:

useEffect(() => {
    // 仅当从其他状态切换到SHOWSEQUENCE时添加新元素,初始挂载时跳过
    if (state === State.SHOWSEQUENCE && sequence.length === 4) {
        const newSequenceId = randomInt(1, 20);
        setSequence(prev => [...prev, newSequenceId]);
    }
}, [state, sequence.length]);

3. 其他优化

  • 用classList.add()/remove()替代toggle(),避免状态切换的不可预测性
  • 序列播放完成后调用props.changeState(),完善状态流转流程

内容的提问来源于stack exchange,提问作者DRE

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.18 16:01:11