首页 / 文章

React 中无漂移的倒计时计时器:从 setTimeout 到纯 CSS

比较用于 React 计时器的 setTimeout、requestAnimationFrame 以及一种无需 JavaScript 的 CSS 方法,还包括让数字保持同步的单一延迟技巧。

发布者:REACTAPP.TOP阅读约 17 分钟4011 词

来源: How I Build a Countdown Timer in React

带有有效期的优惠卡需要一个可见的计时器:显示小时、分钟和秒数,逐秒递减直至优惠失效。虽然这个元素很小,但却对产品的整体体验影响极大。如果计时器落后于实际时间、一次性跳过两秒,或是让笔记本电脑风扇持续安静运转,都会削弱用户对相关功能的信任。本指南将介绍在 React 中实现倒计时的三种方法,详细解释第一种方法出现偏差的原因,并最后提供一种仅使用 CSS 的技术,无需为每个计时器编写 JavaScript 代码即可同时控制数百个计时器。

一句话概括问题

每张优惠卡都有一个claim_end_time。界面应以HH:MM:SS的格式显示距离该时间点还剩多少时间,且每秒更新一次;当优惠到期时,计时器应显示为零。

这些计算非常简单。难点在于决定如何以及何时重新计算,而这一选择将决定计算的准确性、CPU消耗,以及当时间用尽时还能采取什么措施。

方法1:setTimeout链及其出现偏差的原因

人们通常会采用这样的实现方式:编写一个函数来更新显示内容,然后在一秒后再次调度该函数执行。

function tick() {
  updateDisplay();
  setTimeout(tick, 1000);
}
tick();

代码结构清晰,快速测试中也似乎能正常运行。问题出在计时器的处理方式上。页面中的 JavaScript 在单个主线程上执行,事件循环会一次只将一个任务交给该线程处理。计时器回调只是另一个任务,会被排在点击处理、网络请求回调、渲染工作以及 React 需要同步处理的各项任务之后。你传入的 1000 只是最低时间阈值,并非确定的执行时间。如果两秒过后主线程仍处于繁忙状态,回调函数就会等待,实际执行时间可能会在 1,050 毫秒或 1,200 毫秒之后。如果你想进一步了解主线程为何会如此运作,可阅读我们关于JavaScript 的单线程模型与浏览器多进程引擎的对比的文章。

让偶尔的延迟演变成明显的时间偏差的是这种连锁反应。每次计时都会在自身内部安排下一次计时,因此任何延迟都会影响到后续的所有计时。没有任何机制能将这一序列拉回标准时间。让这样的循环运行十分钟,再与 Date.now() 进行比较,就会发现它明显落后。

那种每次计时都减少计数器的定时器实际上测量的是回调次数而非实际经过的时间,而后台标签页的节流机制还会使时间差距进一步增大。

你可以通过测量每次回调的执行延迟,并相应缩短下一次的等待时间来加以弥补。虽然这种方法可行,但最终你还是要维护一套与平台机制相抗衡的修正逻辑,而非充分利用它。无论如何,代码仍然会每秒在主线程中运行。

方法二:使用 requestAnimationFrame 并根据时钟重新计算

requestAnimationFrame(rAF)会要求浏览器在下次重绘之前调用你的函数,而在大多数显示设备上,重绘频率约为每秒60次。不过频率并非关键所在。真正重要的变化在于思维方式:你不再询问“是否又过了一秒?”,而是改为询问“当前确切时刻还剩多少时间?”

function updateTime() {
  const now = Date.now();
  const remaining = endTime - now;
  if (remaining <= 0) {
    display('00:00:00');
    return;
  }
  display(format(remaining));
  requestAnimationFrame(updateTime);
}
requestAnimationFrame(updateTime);

在每一帧中,剩余时间都是通过Date.now()重新计算的,该函数是唯一的真实时间来源。没有计时器,也没有累计的时间间隔。如果某一帧延迟出现,下一帧只需再次读取时间即可显示正确数值。由于除了结束时间本身外没有其他数据会从一帧传递到下一帧,因此错误不会累积。同样的原理也适用于setTimeout版本:如果每个回调都是基于Date.now()重新计算而非减少计数器,那么即使回调延迟执行,显示内容也会自动修正。

一个合理的质疑:可见值每秒只变化一次,那为何还要每秒执行60次操作呢?在React中,耗时的并非减法运算,而是由setState触发的重新渲染。因此该循环会在每一帧进行比较,只有当整秒的数值真正发生变化时才会更新状态。

仅在秒数变化时更新状态

以下是在组件中实现该逻辑的代码。注意endTime是以秒为单位的Unix时间戳,因此在与Date.now()比较之前需要先乘以1000。

const updateTime = (): void => {
  const now = Date.now();
  const timeTillStop = endTime * 1000 - now;
  if (timeTillStop <= 0) {
    setTimeLeft({ hours: 0, minutes: 0, seconds: 0 });
    onNoCountdownTimeLeft?.();
    return;
  }
  const totalSeconds = Math.floor(timeTillStop / 1000);
  if (totalSeconds !== lastSecondsRef.current) {
    lastSecondsRef.current = totalSeconds;
    const hours = Math.floor(totalSeconds / 3600);
    const minutes = Math.floor((totalSeconds % 3600) / 60);
    const seconds = totalSeconds % 60;
    setTimeLeft({ hours, minutes, seconds });
  }
  animationRef.current = requestAnimationFrame(updateTime);
};

两个引用承担了核心功能。lastSecondsRef用于存储上一次渲染的秒数。由于它是引用,对其写入操作不会触发重新渲染,它仅仅是一个在帧与帧之间持续存在的内存位置。因此该循环以帧率运行,而React每秒最多只渲染一次。可选的onNoCountdownTimeLeft回调在时间耗尽时会被调用,让父组件有机会隐藏卡片、重新获取数据或显示“已过期”状态。

第二个引用animationRef则保存着requestAnimationFrame返回的ID。当组件卸载或endTime发生变化时,该效应的清理函数会利用这个ID来取消待处理的帧渲染:

return () => {
  if (animationRef.current) {
    cancelAnimationFrame(animationRef.current);
  }
};

如果没有进行清理,当用户离开页面后循环仍会继续运行,从而对已卸载的组件调用setState,浪费CPU资源。有了清理机制后,组件能够准确运行、高效渲染且具备自我修正能力。

这里有几点实际注意事项。浏览器会在隐藏的标签页中暂停rAF回调,而这实际上很有帮助:循环不会再在后台消耗资源,而当标签页重新显示后的第一帧就会读取时间并直接跳转到正确的数值。对于单个计时器而言,每帧的比较几乎可以忽略不计,但若涉及数十个卡片,则影响会逐渐累积。

方法3:纯CSS实现的倒计时

rAF版本仍会在每一帧执行JavaScript代码。而第三种方法则完全避免了这一点:组件在加载时由JavaScript计算出一个数值,之后便由浏览器的动画引擎负责处理所有的时间更新。

倒计时会变成一个 CSS 动画,逐个显示数字,通过设置负值的延迟,使其在正确的时间开始播放。

在继续之前需要先澄清一下性能问题。你可能会看到相关说明称 CSS 动画总是在合成线程上运行,因此永远不会被阻塞。但这仅适用于少数属性,主要是 transform、opacity 以及某些滤镜。而像本技术中所做的那样,对已注册的自定义属性及生成的 content 进行动画处理时,是由浏览器的主线程上的样式引擎来处理的。真正的优势虽然不同,但同样显著:无需计时器、无需 React 状态,也无需每次更新都进行渲染,而且浏览器自身的调度器会将动画进度与文档时间线绑定,从而确保数值始终准确。

要实现这一功能,需要四个 CSS 特性协同工作。我们将逐一介绍它们。

步骤 1:使用 @property 声明可动画的整数

最直接的尝试是直接对 content 属性进行动画处理,但这行不通,因为 content 不支持插值:浏览器无法理解字符串 “59” 如何变成 “58”。你需要的是一个浏览器能够进行动画处理的数字,而已注册的自定义属性正好能满足这一需求。

@property --s {
  syntax: '<integer>';
  inherits: true;
  initial-value: 59;
}

@property用于为--s指定类型。声明可告知浏览器该值是真正的整数而非不可见的标记,这样它就可以被插入到模板中并实现动画效果。设置inherits: true后,包括::after伪元素在内的后代元素能够从父元素读取动画值。这意味着在计时器容器上设置的单一动画即可控制其内部的每个数字。如需更详细地了解已注册的属性以及它们与普通自定义属性的区别,请参阅我们的指南关于运行时CSS自定义属性与@property。

每个时间单位都有自己对应的已注册属性。小时数从99开始,这也是该计时器所能显示的最大数值。

@property --s {
  syntax: '<integer>';
  inherits: true;
  initial-value: 59;
} /* seconds */
@property --m {
  syntax: '<integer>';
  inherits: true;
  initial-value: 59;
} /* minutes */
@property --h {
  syntax: '<integer>';
  inherits: true;
  initial-value: 99;
} /* hours   */

步骤2:使用counter()将整数渲染为带补位的文本

该整数仍需转换为带前导零的文本(05而非5)。counter()函数会将计数器的值显示在content中,其内置的decimal-leading-zero样式可自动实现两位数的补位功能。

将自定义属性与计数器关联起来的方法是counter-reset:它会将命名的计数器设置为该属性的当前值,然后counter()会读取这个值。尽管源代码中另有标注,但这段代码实际上属于CSS。

.seconds-digit::after {
  counter-reset: sc var(--s); /* set the "sc" counter = current --s value */
  content: counter(sc, decimal-leading-zero); /* render as "00"–"59" */
}
.minutes-digit::after {
  counter-reset: mc var(--m);
  content: counter(mc, decimal-leading-zero);
}
.hours-digit::after {
  counter-reset: hc var(--h);
  content: counter(hc, decimal-leading-zero);
}

每当--s的值变为新的整数时,样式引擎会重新计算counter-reset,进而使伪元素的content内容随之更新。此更新过程无需任何脚本参与。

步骤3:使用steps()让数值按步递增

默认情况下,动画会在关键帧之间进行平滑过渡。但时钟的显示不能是渐变的,而必须从一个个数字直接跳到下一个数字。steps(N, end) 这一时间函数会将总时长划分为 N 个相等且离散的跳跃阶段。

@keyframes tick-s {
  from {
    --s: 59;
  }
  to {
    --s: -1;
  }
}
animation: tick-s 60s steps(60, end) infinite;

在60秒的时长内划分60个步骤,意味着每秒有一个跳跃:开始时为59,1秒后变为58,59秒后变为0,下次循环开始时又回到59。

end 关键字(相当于 jump-end)会将每个跳跃安排在其间隔的末尾。在 t = 0 的时刻,动画仍会显示其 from 值,因为第一次跳跃尚未发生。当出现负延迟时这一细节就很重要了:恰好在 t = 0 时刻生效的延迟会显示59而非58。

将关键帧设置到 -1 看起来有些奇怪,但这是有意为之。动画会在60个整数值之间进行插值,由于 jump-end 的存在,在最终值出现之前就会回到 from 值。因此,可见的序列恰好是从59开始一直递减到0,每一步均为1。

每个时间单位都有其自身的关键帧数量和步长。分钟需要60步,每步为60秒;小时则需要100步,每步为1小时:

@keyframes tick-s {
  from {
    --s: 59;
  }
  to {
    --s: -1;
  }
} /* 60 steps × 1s  = 60s  */
@keyframes tick-m {
  from {
    --m: 59;
  }
  to {
    --m: -1;
  }
} /* 60 steps × 60s = 1hr  */
@keyframes tick-h {
  from {
    --h: 99;
  }
  to {
    --h: -1;
  }
} /* 100 steps × 1hr = 100hr */

步骤4:使用负延迟在序列中间开始播放

这就是核心技巧。当 animation-delay 为负值时,浏览器会认为动画在过去的相应秒数处已经开始播放,因此它会从动画本应到达的当前位置开始播放。

假设当前分钟还剩23秒。秒数动画从59开始,要显示23就需要已经递减36次,因此使用-36s的延迟即可让动画显示正确数值。一般来说:

delay = -(59 - s)      where s = remaining seconds within the current minute

要将此方法应用到三种时间单位,首先需要根据结束时间戳计算出总剩余秒数:

const remaining = endTime - Math.floor(Date.now() / 1000);

随后将这个总数分配到各个时间单位中。小时数会被限制在99以内,因为小时数动画仅覆盖100个刻度:

const s = remaining % 60;
const m = Math.floor(remaining / 60) % 60;
const h = Math.min(Math.floor(remaining / 3600), 99);

请将这种限制视为一种产品约束条件。有效期为五天的优惠券会显示99:59:59,并且在剩余时间少于100小时之前都会保持不变。如果您的优惠活动可以持续这么长时间,建议添加天数单位或更改显示格式。

通过单一延迟保持数字同步

显而易见的下一步是为每个单位计算独立的延迟时间。但这会引发一个严重问题:秒数会循环回到59,而分钟数则长时间保持不变。

为何独立的延迟时间会出现偏差

以remaining = 125为例,它表示0小时2分钟5秒。分别计算秒数和分钟数的延迟后会得到:

delayS = -(59 - 5)       = -54s     → seconds virtual start: now - 54s
delayM = -(59 - 2) × 60  = -3420s   → minutes virtual start: now - 3420s

每个动画都会以一个私有的虚拟起始时间作为基准来衡量其跳动次数。秒数计数从now - 54s开始,每秒触发一次;分钟数计数从now - 3420s开始,每60秒触发一次。由于起始点不同,这些时间节点会落在不同的实际时刻。秒数在这里就会达到下一次循环:

now - 54 + 60 = now + 6s

然而,分钟数则要等到以下时刻才会发生变化:

now - 3420 + (58 × 60) = now - 3420 + 3480 = now + 60s

因此,从现在起六秒后秒数会循环,而分钟则会在整分钟后改变。在54秒的时间里,显示值仍为01:59,尽管实际时间已低于01:00。几乎每分钟的时间显示都是错误的。

统一的起始点

解决方法是为这三个动画设定一个统一的起始点,即将各个偏移量合并为一个总的延迟时间:

const delay = -((99 - h) * 3600 + (59 - m) * 60 + (59 - s));
//           = -(  99×3600      +   57×60       +   54   )
//           = -(356400 + 3420 + 54)
//           = -359874s

现在,每个动画的计时都从 now - 359874s 开始。它们的时间节点都是以该起始点为基准,按各自步长长度的整数倍来确定的。请看示例:在60秒的时间范围内,秒级动画的位置为54,因此下一个时间节点会在6秒后出现;而在3,600秒的时间范围内,分钟级动画的位置为3,474,其下一个时间节点也在3,480处,同样是6秒之后。这两个数值会在 now + 6s 时同时发生变化,之后每60秒保持同步。

如何用一个数字表示三位数

这种组合延迟机制类似于混合进制数,就像 HH:MM:SS 将三个时间字段整合到一条字符串中一样。编码过程就是根据这三个组成部分来构建该数值的:

|delay| = (99 - h) × 3600    ← hours field,   each unit = 3600s
        + (59 - m) × 60      ← minutes field, each unit = 60s
        + (59 - s)           ← seconds field, each unit = 1s

解码则是每个动画通过仅查看自身时间范围来隐式完成的操作:

seconds position = |delay| mod 60                 → display 59 - position
minutes position = floor((|delay| mod 3600) / 60) → display 59 - position
hours   position = floor(|delay| / 3600)          → display 99 - position

通过解码器运行该示例可以验证这一点。359,874 对 60 取模的结果是 54,因此秒数显示为 59 − 54 = 5。359,874 对 3,600 取模的结果是 3,474,将其除以 60 后取整为 57,因此分钟数显示为 59 − 57 = 2。359,874 除以 3,600 后取整为 99,因此小时数显示为 99 − 99 = 0。最终显示的数值为 00:02:05,这是正确的。

这些动画不会互相干扰,因为每个数值都是下一个数值周期的整数倍:小时部分始终是 3,600 的倍数,分钟部分始终是 60 的倍数,因此它们不会影响较低位数的显示。

在 00:00:00 时干净地停止

这些计时动画使用 无限循环,因此它们本身永远不会停止。当秒数循环结束时,它会回到 59,而计时器在归零后会继续开始倒计时。

解决办法是再添加一个动画,在恰好到截止时间时播放一次,并将这三个属性的值都设为零。尽管标签不同,但这段代码实际上也是CSS:

@keyframes freeze {
  to {
    --s: 0;
    --m: 0;
    --h: 0;
  }
}
.timer {
  animation-name: tick-s, tick-m, tick-h, freeze;
  animation-duration: 60s, 3600s, 360000s, 1ms;
  animation-timing-function: steps(60, end), steps(60, end), steps(100, end),
    step-start;
  animation-iteration-count: infinite, infinite, infinite, 1;
  animation-fill-mode: none, none, none, forwards;
}

JavaScript将相同的延迟值分配给三个逐帧动画,而将剩余的纯时间值作为正数延迟值分配给冻结动画:

el.style.animationDelay = `${delay}s, ${delay}s, ${delay}s, ${remaining}s`;

这里有三个要素共同起作用:

  • step-start(与steps(1, jump-start)相同)会在动画开始后立即跳转到最终关键帧,而非在1毫秒的持续时间结束后才跳转,这样在延迟结束时这三个值就会立即变为0。
  • animation-fill-mode: forwards会确保动画结束后仍保持最终关键帧的效果,从而使这些零值持续存在。
  • animation-name 的顺序很重要:当多个动画设置相同的属性时,后面列出的那个会生效。由于 freeze 被放在最后,它的值会覆盖那些持续循环但不可见的动画。
  • 如果页面加载时优惠券已过期,剩余时间将为0,冻结延迟为 0s,计时器会立即显示零。

    CSS 方案带来的弊端

    这种无需 JavaScript 的设计存在实际局限,这些局限应成为你做决策的依据:

    • 没有过期提示机制。 当计时器归零时,不会触发任何回调、事件或状态变化。如果需要通过过期来触发 API 调用、隐藏卡片或显示其他内容,就必须另外使用一次 setTimeout,这又会削弱原有的简洁性。
    • 受限的样式设置。 counter() 返回的是纯文本。字体、颜色和布局完全由您控制,但任何需要依赖 JavaScript 中当前值的操作,比如在时间少于60秒时变为红色或达到阈值时添加类名,都需要额外处理。
    • 整秒精度。 在初始化时,剩余时间会被向下取整为整秒,因此可见的刻度可能与实际秒数存在最多一秒的偏差。这对于促销倒计时来说影响不大,但它并非精确时钟。
    • 浏览器兼容性。 此方法依赖于 @property 以及已注册的动画属性;在依赖它之前,请先根据当前的兼容性数据确认您的浏览器是否支持。

    作为交换,这种仅需要计时并停止的只读倒计时方式效率极高:每张卡片对应一组动画,每次计时无需JavaScript,不会出现时间偏差,卸载时也无需清理任何内容。当页面需要同时渲染数百张优惠券卡片时,这种方式表现尤为出色。

    CSS计时器的React组件

    CSS版本改变了组件的形态。它没有状态、没有动画循环,也不需要清理操作。该组件只需读取一次时间,设置一个样式属性并渲染标记即可。它仅需使用React的两个钩子:

    import { useEffect, useRef } from 'react';
    

    下面的组件会计算剩余秒数,将其拆分为各个单位,生成统一的延迟时间,然后将这四个延迟值写入元素中。type属性仅用于切换数字的两种显示样式。

    interface CSSCountdownTimerProps {
      endTime: number; // Unix timestamp in seconds, same as existing prop
      type?: 'solid' | 'regular';
    }export function CSSCountdownTimer({
      endTime,
      type = 'solid',
    }: CSSCountdownTimerProps) {
      const timerRef = useRef<HTMLDivElement>(null);  useEffect(() => {
        const el = timerRef.current;
        if (!el) return;    const now = Math.floor(Date.now() / 1000);
        const remaining = Math.max(0, endTime - now);    const s = remaining % 60;
        const m = Math.floor(remaining / 60) % 60;
        const h = Math.min(Math.floor(remaining / 3600), 99);    const delay = -((99 - h) * 3600 + (59 - m) * 60 + (59 - s));    el.style.animationDelay = `${delay}s, ${delay}s, ${delay}s, ${remaining}s`;    // No cleanup needed.
        // There are no JS timers, no rAF loops, no subscriptions.
        // CSS animations stop automatically when the element is removed from the DOM.
      }, [endTime]);  return (
        <div ref={timerRef} className={`timer ${type}`}>
          <span
            className={type === 'solid' ? 'digit hours-digit' : 'hours-digit'}
          />
          <span className="sep">:</span>
          <span
            className={type === 'solid' ? 'digit minutes-digit' : 'minutes-digit'}
          />
          <span className="sep">:</span>
          <span
            className={type === 'solid' ? 'digit seconds-digit' : 'seconds-digit'}
          />
        </div>
      );
    }
    

    如注释所述,无需进行任何清理操作:当元素离开 DOM 时,其动画也会随之消失。有两点改进值得考虑。首先,由于 useEffect 会在浏览器绘制页面之后执行,因此在应用延迟之前,第一帧可能会短暂显示处于默认位置的动画;而使用 useLayoutEffect,或在渲染时计算延迟并将其作为内联样式传递,就可以避免这种闪现现象。其次,该效果依赖于 endTime,因此如果该属性发生变化,延迟值会重新计算,动画也会随之正确调整位置。

    三种方法的选型

    一个简单的决策规则即可应对大多数情况:

    • 如果计时器到期时必须执行某些操作,比如状态更新、重新获取数据或调用 API,请使用基于时钟的 requestAnimationFrame 进行重新计算。这样你能完全掌控流程,同时也有合适的位置放置到期回调函数。
    • 如果页面上存在大量计时器但都没有触发任何逻辑,可以使用 CSS 技术,让浏览器来处理这些任务。
    • 如果你正在使用单纯的 setTimeout 递归来减少计数器值,建议更换方案。这种方式不够精确,而且每秒仍会占用主线程资源,因此兼具了上述两种方法的缺点。实际上也可以将两者结合使用:用 CSS 负责显示,再用一个共享的计时器处理到期逻辑。

    核心要点

    • 计时器延迟只是最小值;应在每次更新时通过 Date.now() 获取剩余时间,而非逐次计数,这样就不会出现偏差。
  • 使用 rAF 可以将最后渲染的一秒保存在引用变量中,然后在组件卸载时取消该帧的更新。
  • CSS 版本依赖于四个功能:用于整数的 @property、用于填充文本的 counter()、用于实现离散跳转的 steps(),以及用于在序列中间开始播放的负延迟值。
  • 让所有单元共享同一个延迟值,这样数字就能同步进位,并通过添加 freeze 动画来保持零值不变。