如何让React组件的useEffect在元素每次进入视口时重新触发文本Scramble动画?
如何让React组件的useEffect在元素每次进入视口时重新触发文本Scramble动画?
我仔细看了你的组件代码,问题核心在于IntersectionObserver的回调逻辑没有处理元素离开视口后的状态重置,以及动画触发的条件不够严谨——导致第一次进入视口触发动画后,后续再进入时无法重新启动。下面是一步步的修复方案,直接对应你的代码修改:
一、修复IntersectionObserver的回调逻辑
原来的Observer只在元素第一次进入时触发动画,但没有处理离开时的状态重置,也没判断当前是否正在动画。修改后,每次元素进入视口且动画未在运行时,都会触发动画;离开时重置状态,确保下次进入能正常触发:
// 替换第一个useEffect中的Observer部分 useEffect(() => { if (!startOnView) { const headingStartTimeout = setTimeout(() => { setIsHeadingAnimating(true); }, delay); return () => clearTimeout(headingStartTimeout); } const headingObserver = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { // 仅当动画未运行时,才启动新动画 if (!isHeadingAnimating) { setTimeout(() => { setIsHeadingAnimating(true); }, delay); } } else { // 元素离开视口时,强制重置动画状态 setIsHeadingAnimating(false); // 可选:如果希望离开时立即恢复原始文本,取消下面的注释 // setHeadingDisplayText(children.split("")); } }, { threshold: 0.1, rootMargin: "-30% 0px -30% 0px" }, ); if (headingElementRef.current) { headingObserver.observe(headingElementRef.current); } // 保留disconnect,避免组件卸载时内存泄漏 return () => headingObserver.disconnect(); }, [delay, startOnView, isHeadingAnimating]); // 注意这里要添加isHeadingAnimating到依赖
⚠️ 关键提醒:必须把isHeadingAnimating加入到第一个useEffect的依赖数组中,这样Observer的回调才能获取到最新的状态值,避免逻辑判断失效。
二、确保动画每次从头开始
原来的动画逻辑中,headingIterationCount.current可能会保留上一次动画的数值,导致后续动画不从头启动。在第二个useEffect开头重置这个计数:
// 替换第二个useEffect的内容 useEffect(() => { if (!isHeadingAnimating) return; // 重置迭代计数,保证每次动画都从第一个字符开始 headingIterationCount.current = 0; const headingMaxIterations = children.length; const headingStartTime = performance.now(); let headingAnimationFrameId: number; const animateHeading = (currentTime: number) => { const headingElapsed = currentTime - headingStartTime; const headingProgress = Math.min(headingElapsed / duration, 1); headingIterationCount.current = headingProgress * headingMaxIterations; setHeadingDisplayText((currentText) => currentText.map((letter, index) => letter === " " ? letter : index <= headingIterationCount.current ? children[index] : characterSet[getRandomInt(characterSet.length)], ), ); if (headingProgress < 1) { headingAnimationFrameId = requestAnimationFrame(animateHeading); } else { setIsHeadingAnimating(false); } }; headingAnimationFrameId = requestAnimationFrame(animateHeading); return () => cancelAnimationFrame(headingAnimationFrameId); }, [children, duration, isHeadingAnimating, characterSet]);
三、修改后的完整组件代码
"use client"; import { AnimatePresence, motion, MotionProps } from "motion/react"; import { useEffect, useRef, useState } from "react"; import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" type CharacterSet = string[] | readonly string[]; interface HyperTextProps extends MotionProps { /** The text content to be animated */ children: string; /** Optional className for styling */ className?: string; /** Duration of the animation in milliseconds */ duration?: number; /** Delay before animation starts in milliseconds */ delay?: number; /** Component to render as - defaults to div */ as?: React.ElementType; /** Whether to start animation when element comes into view */ startOnView?: boolean; /** Whether to trigger animation on hover */ animateOnHover?: boolean; /** Custom character set for scramble effect. Defaults to uppercase alphabet */ characterSet?: CharacterSet; } const DEFAULT_CHARACTER_SET = Object.freeze( "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split(""), ) as readonly string[]; const getRandomInt = (max: number): number => Math.floor(Math.random() * max); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } export function HyperTextHeading({ children, className, duration = 800, delay = 0, as: Component = "div", startOnView = false, animateOnHover = true, characterSet = DEFAULT_CHARACTER_SET, ...props }: HyperTextProps) { const HeadingMotionComponent = motion.create(Component, { forwardMotionProps: true }); const [headingDisplayText, setHeadingDisplayText] = useState<string[]>(() => children.split(""), ); const [isHeadingAnimating, setIsHeadingAnimating] = useState(false); const headingIterationCount = useRef(0); const headingElementRef = useRef<HTMLElement>(null); const handleHeadingAnimationTrigger = () => { if (animateOnHover && !isHeadingAnimating) { headingIterationCount.current = 0; setIsHeadingAnimating(true); } }; // Handle animation start based on view or delay useEffect(() => { if (!startOnView) { const headingStartTimeout = setTimeout(() => { setIsHeadingAnimating(true); }, delay); return () => clearTimeout(headingStartTimeout); } const headingObserver = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { if (!isHeadingAnimating) { setTimeout(() => { setIsHeadingAnimating(true); }, delay); } } else { setIsHeadingAnimating(false); // 可选:离开时立即恢复原始文本 // setHeadingDisplayText(children.split("")); } }, { threshold: 0.1, rootMargin: "-30% 0px -30% 0px" }, ); if (headingElementRef.current) { headingObserver.observe(headingElementRef.current); } return () => headingObserver.disconnect(); }, [delay, startOnView, isHeadingAnimating]); // Handle scramble animation useEffect(() => { if (!isHeadingAnimating) return; headingIterationCount.current = 0; const headingMaxIterations = children.length; const headingStartTime = performance.now(); let headingAnimationFrameId: number; const animateHeading = (currentTime: number) => { const headingElapsed = currentTime - headingStartTime; const headingProgress = Math.min(headingElapsed / duration, 1); headingIterationCount.current = headingProgress * headingMaxIterations; setHeadingDisplayText((currentText) => currentText.map((letter, index) => letter === " " ? letter : index <= headingIterationCount.current ? children[index] : characterSet[getRandomInt(characterSet.length)], ), ); if (headingProgress < 1) { headingAnimationFrameId = requestAnimationFrame(animateHeading); } else { setIsHeadingAnimating(false); } }; headingAnimationFrameId = requestAnimationFrame(animateHeading); return () => cancelAnimationFrame(headingAnimationFrameId); }, [children, duration, isHeadingAnimating, characterSet]); return ( <HeadingMotionComponent ref={headingElementRef} className={cn("overflow-hidden py-2 text-4xl font-bold", className)} onMouseEnter={handleHeadingAnimationTrigger} {...props} > <AnimatePresence> {headingDisplayText.map((letter, index) => ( <motion.span key={index} className={cn("font-mono", letter === " " ? "w-3" : "")} > {letter.toUpperCase()} </motion.span> ))} </AnimatePresence> </HeadingMotionComponent> ); }
这样修改后,你的组件就能在元素每次进入视口时重新触发Scramble动画了,同时保留了hover触发的功能,也不会有内存泄漏的问题。
内容来源于stack exchange
相关产品推荐
相关产品推荐

