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
问题根源
初始动画触发两次:
ShowSequence内部维护了独立的sequence状态,初始复制props值后,父组件的useEffect会给sequence添加新元素,导致子组件sequence更新,额外触发一次useEffect执行,重复触发动画。- 组件挂载时index从0执行一次,sequence更新后又会再次触发useEffect,造成初始动画重复。
元素始终保留动画:
- 使用
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
相关产品推荐
相关产品推荐

