如何让CSS动画与React倒计时效果保持同步?
问题描述
我正在开发一个React组件,实现圆形倒计时进度条搭配周期性文本倒计时:圆形进度条30秒完成一次倒计时循环,文本显示5-4-3-2-1-0循环往复。但当前文本与CSS动画不同步,圆形计时器会在文本循环中途结束。
CSS代码
.animation { animation: countdown 30s linear infinite forwards; }
React组件代码
const [reset, setReset] = useState(props.reset); const [period, setPeriod] = useState(props.period); const [seconds, setSeconds] = useState(reset); const [runTime, setRunTime] = useState(0); const circle = useRef<SVGCircleElement>(null); useLayoutEffect(() => { circle.current?.classList.add(css.animation); const interval = setInterval(() => { setSeconds((seconds) => { if (seconds > 0) { return seconds - 1; } else { return reset; } }); }, 1000); // Check total time setRunTime((runTime) => runTime + 1); if (runTime === period * 60) { clearInterval(this); setFeedback('Complete'); circle.current?.classList.remove(css.animation); } return () => clearInterval(interval); }, [seconds]); return ( <> <svg className={css.svg}> <circle ref={circle} r="18" cx="20" cy="20"></circle> </svg> <p id={css.countdownNumber}>{seconds}</p> </> );
请问useLayoutEffect中的计算是否会导致循环延迟?该如何让两个计时器保持同步?
问题解答
一、useLayoutEffect中的问题分析
当前实现确实会导致循环延迟和不同步,核心问题集中在以下几点:
- 依赖数组错误:依赖
seconds意味着每次文本更新都会重新执行useLayoutEffect,频繁创建并销毁setInterval,不仅会累积时间误差,还会反复重启CSS动画,直接打断动画连续性。 - runTime逻辑无效:
setRunTime调用后,闭包捕获的runTime仍是旧值,后续的周期判断永远无法触发;且clearInterval(this)是错误写法,this并非interval引用。 - 双计时器独立运行:CSS动画与JS
setInterval是两个独立计时系统,setInterval本身受事件循环阻塞影响存在精度问题,再加上频繁重建,必然导致两者不同步。
二、同步解决方案:单一时间源驱动
要实现完美同步,必须用同一个时间基准驱动文本和进度条,推荐通过计算起始时间与当前时间的差值来同步所有状态,避免依赖多个独立计时器。
优化后的代码示例
import { useState, useRef, useEffect } from 'react'; const Countdown = ({ reset = 5, period = 1 }) => { const [seconds, setSeconds] = useState(reset); const [isComplete, setIsComplete] = useState(false); const circleRef = useRef<SVGCircleElement>(null); const startTimeRef = useRef<number>(Date.now()); const animationFrameRef = useRef<number>(0); useEffect(() => { if (isComplete) return; const updateCountdown = () => { const elapsed = Date.now() - startTimeRef.current; const totalCycleMs = 30 * 1000; // CSS动画周期30秒 const textCycleMs = (reset + 1) * 1000; // 文本循环周期:5-0共6秒 // 计算当前文本显示的秒数 const textElapsed = elapsed % textCycleMs; const currentSecond = reset - Math.floor(textElapsed / 1000); setSeconds(currentSecond); // 检查是否到达总周期(period分钟) if (elapsed >= period * 60 * 1000) { setIsComplete(true); circleRef.current?.classList.remove('animation'); return; } animationFrameRef.current = requestAnimationFrame(updateCountdown); }; // 启动CSS动画和循环更新 circleRef.current?.classList.add('animation'); animationFrameRef.current = requestAnimationFrame(updateCountdown); // 清理函数 return () => { cancelAnimationFrame(animationFrameRef.current); circleRef.current?.classList.remove('animation'); }; }, [reset, period, isComplete]); return ( <> <svg className="svg"> <circle ref={circleRef} r="18" cx="20" cy="20"></circle> </svg> <p className="countdownNumber">{seconds}</p> </> ); }; export default Countdown;
关键优化点
- 单一时间源:用
Date.now()记录起始时间,通过流逝时间计算状态,完全规避setInterval的精度问题。 - 避免重复初始化:
useEffect仅在依赖项变化时执行一次,不会因文本更新重复创建计时器,保证动画和状态更新的连续性。 - 精确周期匹配:文本循环周期(6秒)是CSS动画周期(30秒)的约数,天然保证循环同步。
- 正确清理逻辑:用
requestAnimationFrame的返回值取消循环,避免内存泄漏。
额外补充:JS直接控制进度条
如果需要100%同步,可以移除CSS动画,直接通过JS计算进度更新圆形的stroke-dasharray和stroke-dashoffset:
// 在updateCountdown函数中添加: const progress = (elapsed % totalCycleMs) / totalCycleMs; if (circleRef.current) { const circumference = 2 * Math.PI * 18; // r=18的圆周长 circleRef.current.style.strokeDasharray = `${circumference}`; circleRef.current.style.strokeDashoffset = `${circumference * progress}`; }
此时可删除CSS中的.animation类,完全由JS控制进度,彻底消除同步误差。
内容的提问来源于stack exchange,提问作者ang
相关产品推荐
相关产品推荐

