精准控制文字闪动动画时长与帧率 —— Magic UI Hyper Text 深度优化

7/30/2025, 12:56:23 PM

在使用 Magic UI 的 Hyper Text 给文字添加闪动效果时,发现动画时长在字符串较长时会明显超出预期。本文深入分析了原实现中基于 setInterval 的缺陷,提出了通过 requestAnimationFrame 精准控制动画进度和帧率的优化方案,并分享了实现细节与改进思路,让动画在任意字符串长度下都能稳定、流畅地运行。

偶遇 bug

在使用 Magic UI 的 Hyper Text 给文字添加闪动效果的时候,发现有时动画时长会大于设定的时长。为了防止上线后出问题,我对它进行极端情况下的测试。在字符串较长的时候,就可以看出动画时长明显大于设定时长了。

image

这明显不是我们想要的,我们希望无论在什么情况下它的播放时长都要受控制,所以接下来就去源码看看有没有地方可以修复。

精准控制进度与帧率

源码中这 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]);

它的大致思路是,用一条分界线将字符串分成两块,在分界线左侧的字符不变,右侧的字符映射成随机字符。每隔一段时间就执行这个逻辑并把分界线右移。间隔时长由动画时长和字符串长度共同决定。初看逻辑清晰简单,但其实有两个比较大的缺陷:

  1. 间隔并非任务执行完的间隔,而是定时器将任务推入队列的间隔。如果间隔过短或 js 遇到长任务,任务被源源不断推入队列而 js 线程无力执行,任务执行间隔大于理想间隔,导致动画超时。
  2. 不仅短不行。这套逻辑导致动画帧数直接与间隔挂钩。如果间隔过长,就会出现帧数过低的情况,非常不美观。

也就能得出我们需要做的改进:

  1. 动画时长不该由不靠谱的间隔堆叠,而该直接用时间差值来确定动画到底结没结束。
  2. 帧数也不该由动画时长和字符串长度,而该由用户屏幕刷新率决定。只要动画还没结束,就要按照刷新率来映射字符串。

根据以上方向我们修改代码如下:

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,来对齐用户屏幕刷新率,确保动画不会帧数过低。

image

有待改进

虽然引入 requestAnimationFrame 解决了旧问题,但也同时引入了新的问题 —— 回调函数过复杂及刷新率过高的时候会出现掉帧问题。可以考虑到的改进方向是用回 setInterval 并设置 16 ms 的间隔让它稳住六十帧。

如果你有更好的想法,欢迎来更新代码或讨论!

项目地址:https://github.com/magicuidesign/magicui

pr 地址:https://github.com/magicuidesign/magicui/pull/597