精准控制文字闪动动画时长与帧率 —— Magic UI Hyper Text 深度优化
7/30/2025, 12:56:23 PM
在使用 Magic UI 的 Hyper Text 给文字添加闪动效果时,发现动画时长在字符串较长时会明显超出预期。本文深入分析了原实现中基于 setInterval 的缺陷,提出了通过 requestAnimationFrame 精准控制动画进度和帧率的优化方案,并分享了实现细节与改进思路,让动画在任意字符串长度下都能稳定、流畅地运行。
偶遇 bug
在使用 Magic UI 的 Hyper Text 给文字添加闪动效果的时候,发现有时动画时长会大于设定的时长。为了防止上线后出问题,我对它进行极端情况下的测试。在字符串较长的时候,就可以看出动画时长明显大于设定时长了。

这明显不是我们想要的,我们希望无论在什么情况下它的播放时长都要受控制,所以接下来就去源码看看有没有地方可以修复。
精准控制进度与帧率
源码中这 20 来行是用于实现动画效果的:
useEffect(() => {
if (!isAnimating) return;
const intervalDuration = duration / (children.length * 10);
const maxIterations = children.length;
const interval = setInterval(() => {
if (iterationCount.current < maxIterations) {
setDisplayText((currentText) =>
currentText.map((letter, index) =>
letter === " "
? letter
: index <= iterationCount.current
? children[index]
: characterSet[getRandomInt(characterSet.length)],
),
);
iterationCount.current = iterationCount.current + 0.1;
} else {
setIsAnimating(false);
clearInterval(interval);
}
}, intervalDuration);
return () => clearInterval(interval);
}, [children, duration, isAnimating, characterSet]);
它的大致思路是,用一条分界线将字符串分成两块,在分界线左侧的字符不变,右侧的字符映射成随机字符。每隔一段时间就执行这个逻辑并把分界线右移。间隔时长由动画时长和字符串长度共同决定。初看逻辑清晰简单,但其实有两个比较大的缺陷:
- 间隔并非任务执行完的间隔,而是定时器将任务推入队列的间隔。如果间隔过短或 js 遇到长任务,任务被源源不断推入队列而 js 线程无力执行,任务执行间隔大于理想间隔,导致动画超时。
- 不仅短不行。这套逻辑导致动画帧数直接与间隔挂钩。如果间隔过长,就会出现帧数过低的情况,非常不美观。
也就能得出我们需要做的改进:
- 动画时长不该由不靠谱的间隔堆叠,而该直接用时间差值来确定动画到底结没结束。
- 帧数也不该由动画时长和字符串长度,而该由用户屏幕刷新率决定。只要动画还没结束,就要按照刷新率来映射字符串。
根据以上方向我们修改代码如下:
useEffect(() => {
if (!isAnimating) return;
const maxIterations = children.length;
const startTime = performance.now();
let animationFrameId;
const animate = (currentTime) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
iterationCount.current = progress * maxIterations;
setDisplayText((currentText) =>
currentText.map((letter, index) =>
letter === " "
? letter
: index <= iterationCount.current
? children[index]
: characterSet[getRandomInt(characterSet.length)],
),
);
if (progress < 1) {
animationFrameId = requestAnimationFrame(animate);
} else {
setIsAnimating(false);
}
};
animationFrameId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationFrameId);
}, [children, duration, isAnimating, characterSet]);
首先记录下启动时间,在未来每次需要进度的时候,就用差值除以设定时长,确保动画不超时;接着用 requestAnimationFrame(此 api 在 caniuse 的统计中支持率高达98.85%)替代 setInterval,来对齐用户屏幕刷新率,确保动画不会帧数过低。

有待改进
虽然引入 requestAnimationFrame 解决了旧问题,但也同时引入了新的问题 —— 回调函数过复杂及刷新率过高的时候会出现掉帧问题。可以考虑到的改进方向是用回 setInterval 并设置 16 ms 的间隔让它稳住六十帧。
如果你有更好的想法,欢迎来更新代码或讨论!