小小倒计时,没那么简单
倒计时是一个非常常见的 UI:
-
活动上线倒计时
-
发布会倒计时
-
秒杀系统倒计时
看起来只是简单地显示:
00:10 → 00:09 → 00:08
但当你真正实现一个体验不错的倒计时页面时,其实有不少细节需要考虑。
1. 数字要等宽显示(tabular-nums)
这是一个我最近才注意到的小细节。很多字体里,数字宽度其实是不同的。
如果你给数字加了动画,倒计时变化时,容器的宽度会变化,导致 抖动感。
CSS 其实提供了一个专门的属性:
font-variant-numeric: tabular-nums;
作用是:
让所有数字使用等宽排列。
也就是:
0 1 2 3 4 5 6 7 8 9
每个数字占用的宽度一致。
如果你使用 Tailwind,使用
<span class="tabular-nums">
这个属性需要字体的支持,并不是所有字体都支持这个属性
2. 时区问题
倒计时最容易出问题的其实是 时区。
比如活动时间是:
2026-05-01 12:00
这其实是一个 不完整的时间信息,因为缺少时区。
如果你直接写:
new Date("2026-05-01 12:00")
浏览器会默认按 用户本地时区解析。
如果你的用户分布在不同地区:
-
北京
-
东京
-
纽约
倒计时就可能全部不一样。
更安全的方式
使用带时区的时间(ISO 8601 格式)
ISO 8601 时间格式是国际标准化组织制定的日期和时间表示法,标准格式为 YYYY-MM-DDThh:mm:ssTZD。它通过 T 分隔日期与时间,强制使用 24 小时制,支持 UTC 时间 Z 或时区偏移量,确保时间在各系统间传递时的精确性和无歧义性。
例如 2026-05-01T12:00:00+08:00 代表 北京时间2026年5月1日中午12点整。
前端只需要:
const targetTimeStamp = new Date("2026-05-01T12:00:00+08:00").getTime();
即可获取到正确的时间戳,这样所有用户看到的倒计时都是一致的。
3. 如何准确更新倒计时?
常见写法
import React, { useEffect, useState } from "react"
const targetTimestamp = new Date("2026-05-01T12:00:00+08:00").getTime()
export default function Countdown() {
const [remain, setRemain] = useState(targetTimestamp - Date.now())
useEffect(() => {
const timer = setInterval(() => {
setRemain(targetTimestamp - Date.now())
}, 1000)
return () => clearInterval(timer)
}, [])
const seconds = Math.floor(remain / 1000) % 60
const minutes = Math.floor(remain / 1000 / 60) % 60
const hours = Math.floor(remain / 1000 / 60 / 60) % 24
const days = Math.floor(remain / 1000 / 60 / 60 / 24)
if (remain <= 0) return <div>Time's up!</div>
return (
<div>
<span>{days.toString().padStart(2, "0")}d </span>
<span>{hours.toString().padStart(2, "0")}h </span>
<span>{minutes.toString().padStart(2, "0")}m </span>
<span>{seconds.toString().padStart(2, "0")}s</span>
</div>
)
}
这种写法有适合页面只有一个或少量倒计时的简单页面,但是有三个问题:
- 只适合秒级倒计时,如果是更小单位,或者需要丝滑的动画过渡效果,仍然需要使用
requestAnimationFrame实现。
-
setInterval本质上是 宏任务(macrotask),如果JS 单线程阻塞,或浏览器标签页后台或省电模式触发节流 (暂停),它 无法保证严格每 1000ms 执行一次。setInterval和requestAnimationFrame在后台或者省电模式下都无法避免被节流或者暂停,导致切换过来的时候倒计时突然会跳一下,要想完全解决,最终方案是使用webworker开启新线程,但是这种还是太复杂,而且性能消耗也比较大。 -
Date.now()依赖本地系统时间,如果系统时间有误差,倒计时也就不准确了。要解决这个问题,需要从服务端获取当前时间,这种一般是电商平台大型活动秒杀前端才会有,参考:秒杀系统中的前端倒计时设计
毫秒倒计时版本
import React, { useEffect, useState, useRef } from "react"
const targetTimestamp = new Date("2026-05-01T12:00:00+08:00").getTime()
export default function Countdown() {
const [remain, setRemain] = useState(targetTimestamp - Date.now())
const rafRef = useRef<number | null>(null)
useEffect(() => {
const tick = () => {
const _remain = targetTimestamp - Date.now()
setRemain(_remain > 0 ? _remain : 0)
if (_remain > 0) rafRef.current = requestAnimationFrame(tick)
}
rafRef.current = requestAnimationFrame(tick)
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current)
}
}, [])
const milliseconds = remain % 1000
const seconds = Math.floor(remain / 1000) % 60
const minutes = Math.floor(remain / 1000 / 60) % 60
const hours = Math.floor(remain / 1000 / 60 / 60) % 24
const days = Math.floor(remain / 1000 / 60 / 60 / 24)
if (remain <= 0) return <div>Time's up!</div>
return (
<div>
<span>{days.toString().padStart(2, "0")}d </span>
<span>{hours.toString().padStart(2, "0")}h </span>
<span>{minutes.toString().padStart(2, "0")}m </span>
<span>{seconds.toString().padStart(2, "0")}s </span>
<span>{milliseconds.toString().padStart(3, "0")}ms</span>
</div>
)
}
webworker 版本
// countdown.worker.ts
let targetTimestamp: number
let timer: number | undefined
self.onmessage = (e: MessageEvent) => {
if (e.data.type === "start") {
targetTimestamp = e.data.targetTimestamp
startInterval()
}
}
function startInterval() {
if (timer) clearInterval(timer) // 防止重复启动
timer = setInterval(() => {
const remain = targetTimestamp - Date.now()
if (remain > 0) {
postMessage(remain)
} else {
postMessage(0)
clearInterval(timer)
}
}, 16) // 每16ms ≈ 60fps,可根据需求调整
}
import React, { useEffect, useState } from "react"
import CountdownWorker from "./countdown.worker.ts?worker"
const targetTimestamp = new Date("2026-05-01T12:00:00+08:00").getTime()
export default function Countdown() {
const [remain, setRemain] = useState(targetTimestamp - Date.now())
useEffect(() => {
const worker = new CountdownWorker()
worker.postMessage({ type: "start", targetTimestamp })
worker.onmessage = (e: MessageEvent) => {
setRemain(e.data)
}
return () => {
worker.terminate()
}
}, [])
const milliseconds = remain % 1000
const seconds = Math.floor(remain / 1000) % 60
const minutes = Math.floor(remain / 1000 / 60) % 60
const hours = Math.floor(remain / 1000 / 60 / 60) % 24
const days = Math.floor(remain / 1000 / 60 / 60 / 24)
if (remain <= 0) return <div>Time's up!</div>
return (
<div>
<span>{days.toString().padStart(2, "0")}d </span>
<span>{hours.toString().padStart(2, "0")}h </span>
<span>{minutes.toString().padStart(2, "0")}m </span>
<span>{seconds.toString().padStart(2, "0")}s </span>
<span>{milliseconds.toString().padStart(3, "0")}ms</span>
</div>
)
}