前言
最近遇到一个倒计时的相似秒杀的场景,没多思考洋洋洒洒的应用 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<number>}
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<CountDownOpt> {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<CountDownDateMeta>({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<CountDownProps> = memo(({endTime, onEnd, render, server}) => {const time = useCountDown({ endTime: new Date(endTime).getTime(), onEnd, server})
return <>{render(time)}</>
},
)
应用:
<CountDown
server
endTime="2021-03-26T11:00:00.000Z"
onEnd={() => console.log('finished')}
render={(t) => <div>{JSON.stringify(t)}</div>} // 这里该当尽量少写 DOMELement
/>
参考
TaroUI-countdown: https://github.com/NervJS/tar…
能够具体的讲一下平时网页上做流动时的倒计时是怎么实现的吗?: https://www.zhihu.com/questio…