前言
最近遇到一个倒计时的类似秒杀的场景,没多考虑洋洋洒洒的使用 setTimeout
递归形式完成了任务。上线后,用户反馈说多个设备的倒计时误差好几秒,仔细一想,客户端时间有差异,应当使用服务端来进行时间校准。查了一些资料,整理了以下内容。
倒计时对于前端来说是一个说简单也简单,说复杂,也有点复杂的东东。
对精确度要求没那么严格的,我们很容易想到使用 setInterval
去实现;
对每秒计时精确度有要求的,可以使用递归 setTimeout
,不断修正时间去倒计时;
而对于秒杀这类场景,由于客户端时间有差异,所以需要请求服务端接口,不断修正倒计时时间来满足需求。
正文
setInterval倒计时
这个很简单,这里就简单写个 demo 了
let t = 5
const timer = setInterval(() => {
if (--t < 0) clearInterval(timer)
}, 1000)
setTimeout倒计时
使用 setTimeout
递归网上也有很多实现,精确度很高。
原理是倒计时前记录当前时间 t1
,递归时记录递归次数 c1
,每一次递归时,用当前时间 t2
- (t1
+ c1
* interval
) 得到是误差时间 offset
,使用 interval
- offset
即可得到下一个 setTimeout 的时间。
const t1 = Date.now()
let c1 = 0 // 递归次数
let timer: any = null // 计时器
let t = 5 // 倒计时秒数
let interval = 1000 // 间隔
function countDown() {
if (--t < 0) {
clearTimeout(timer)
return
}
// 计算误差
const offset = Date.now() - (t1 + c1 * interval)
const nextTime = interval - offset
c1++
timer = setTimeout(countDown, nextTime)
}
countDown()
利用服务端修正时间进行倒计时
秒杀场景下,需要服务端修正客户端倒计时时间。
原理是利用一个计时器计时,另一个计时器更新时间变量,对时间进行修正。
demo 如下
const interval = 1000 // 计时间隔
const debounce = 3000 // 修正时间,请求接口间隔
const endTime = Date.now() + 5 * 1000 // 计时终点
let now = Date.now() // 初始时间
let timer1: any = null // 倒计时计时器
let updateNowTimer: any = null // 请求接口计时器
// 倒计时计时器
timer1 = setInterval(() => {
now = now + interval
const leftT = Math.round((endTime - now) / 1000)
if (leftT < 0) {
clearInterval(timer1)
clearInterval(updateNowTimer)
return
}
}, interval)
// 模拟请求接口,更新 now 值
updateNowTimer = setInterval(() => {
new Promise((resolve) => {
setTimeout(() => {
now = Date.now()
resolve(void 0)
}, 1000)
})
}, debounce)
当有多个倒计时实例时,只需要在 updateNowTimer
中更新多个实例的 now 值即可。
demo 中除了代码不优雅,不好管理计时器外,还存在着一些问题,比如没有考虑多个实例下,何时清除updateNowTimer
计时器等问题。
CountItDownTimer
让我们用类写法重新设计一下倒计时,简易代码如下:
interface CountDownOpt {
interval: number
endTime: number
manager?: CountDownManager
onStep?(value: CountDownDateMeta): void
onEnd?(): void
}
class CountDown {
constructor(opt: CountDownOpt) {
this.timer = null
this.opt = opt
this.now = Date.now()
this.init()
}
init() {
this.timer = setInterval(() => {
this.now = this.now + this.opt.interval
if (this.now >= this.opt.endTime) {
this.clear()
return this.opt.onEnd?.()
}
this.opt.onStep?.(this.calculateTime())
}, this.opt.interval)
this.opt.manager.add(this)
}
clear() {
clearInterval(this.timer)
this.opt.manager.remove(this)
}
}
上面的代码中,我们传入了一个 manager 参数,这是一个 CountDownManager
实例,在CountDownManager
中,我们要实现 CountDown
实例的注册与删除,还要实现请求服务端接口,统一更新注册的 CountDown
实例的 now 值。
CountDownManager
简易代码如下:
interface CountDownManagerOpt {
debounce: number
getRemoteDate(): Promise
}
class CountManager {
constructor(opt: CountDownManagerOpt) {
this.queue = []
this.timer = null
this.opt = opt
}
add(countDown) {
this.queue.push(countDown)
!this.timer && this.init()
}
remove(countDown) {
const idx = this.queue.findIndex((ins) => ins === countDown)
idx !== -1 && this.queue.splice(idx, 1)
if (!this.queue.length && this.timer) {
clearInterval(this.timer as any)
this.timer = null
}
}
init() {
this.timer = setInterval(() => this.getNow(), this.opt.debounce || 3000)
}
async getNow() {
try {
const start = Date.now()
const nowStr = await this.opt.getRemoteDate()
const end = Date.now()
this.queue.forEach((instance) => (instance.now = new Date(nowStr).getTime() + end - start))
} catch (e) {
console.log('fix time fail', e)
}
}
}
这样的话,对于所有在 CountDownManager
中注册的实例,都可以统一更新时间。
完整代码在这里: CountItDownTimer。
使用 demo 如下:
async function getRemoteDate() {
return new Promise((resolve) => {
setTimeout(() => {
resolve(Date.now())
}, 1000)
})
}
const countDown = new CountDown({
endTime: Date.now() + 1000 * 100,
onStep({d, h, m, s}) {
console.log(d, h, m, s)
},
onStop() {
console.log('finished');
},
manager: new CountDownManager({
debounce: 1000 * 3,
getRemoteDate,
}),
});
传入 manager 的情况下,将使用服务端修正的计时方式,不传的情况下,使用本地时间,利用 setTimeout 递归进行计时。
多个倒计时实例的情况下,只需要在多个实例的 manager 配置中传入同一个 CountDownManager 实例即可。当请求完一个接口后,会统一修改所有实例的 now 时间。库中同时也考虑了请求 API 的耗时。
获取 countDown 实例
首先多个秒杀倒计时应当使用同一个 CountDownManager
实例,该实例请求完接口后,统一更新所有秒杀倒计时的最新计时时间。
我们可以简单封装一下,获取 CountDown
实例
const countDownManager = new CountDownManager({
debounce: 1000 * 3,
async getRemoteDate() {
try {
const d = await apiService.timeStamp()
return new Date(d).getTime()
} catch (e) {
console.log('时间获取失败', e)
}
return Date.now()
},
})
interface CountDownInstanceOpt extends Partial {
server?: boolean
}
export const getCountDownInstance = (opt: CountDownInstanceOpt) => {
const { server, ...countDownOpt } = opt
return new CountDown(Object.assign({}, server ? { manager: countDownManager } : {}, countDownOpt))
}
我们可以通过 getCountDownInstance
方法得到 CountDown
实例,通过传入 server
参数来控制该实例是否通过默认的 manager
进行服务端更新计时时间,当然也可以传入 manager
来对不同的实例,每一个实例独自请求接口进行更新。
useCountDown
新建完一个 countDown
实例后,页面卸载时,我们要手动清除计时器,当我们秒杀页面中有 N 个计时器时,写起来就很烦了,可以考虑封装成一个自定义 Hooks。
function useCountDown({ endTime, onEnd, server = false }: CountDownHookOpt) {
const [dateMeta, setDateMeta] = useState({ d: 0, h: 0, m: 0, s: 0 })
useEffect(() => {
const countDown = getCountDownInstance({endTime, server, onEnd, onStep: setDateMeta })
return () => {
countDown.clear()
}
}, [])
return dateMeta
}
倒计时组件
开发者如果没有代码性能没有追求,会很容易将 useCountDown
hook用错位置,进行计时时,会运行大段代码,计算 Vdom 变更差异,会造成计时误差变大。因而我们除了使用 memo 优化方案外,还应当注意将 useCountDown
用到最小的组件单元中。
interface CountDownProps {
endTime: string // 终止日期
onEnd(): void // 倒计时结束回调
render(date: CountDownDateMeta): JSX.Element
server?: boolean // 使用服务端校准时间
}
export const CountDown: FC = memo(({ endTime, onEnd, render, server }) => {
const time = useCountDown({ endTime: new Date(endTime).getTime(), onEnd, server })
return <>{render(time)}>
},
)
使用:
console.log('finished')}
render={(t) => {JSON.stringify(t)}} // 这里应当尽量少写 DOMELement
/>
参考
TaroUI-countdown: https://github.com/NervJS/tar...
可以详细的讲一下平时网页上做活动时的倒计时是怎么实现的吗?: https://www.zhihu.com/questio...