前言

最近遇到一个倒计时的相似秒杀的场景,没多思考洋洋洒洒的应用 setTimeout 递归模式实现了工作。上线后,用户反馈说多个设施的倒计时误差好几秒,认真一想,客户端工夫有差别,该当应用服务端来进行工夫校准。查了一些材料,整顿了以下内容。

倒计时对于前端来说是一个说简略也简略,说简单,也有点简单的东东。

对精确度要求没那么严格的,咱们很容易想到应用 setInterval 去实现;

对每秒计时精确度有要求的,能够应用递归 setTimeout,一直修改工夫去倒计时;

而对于秒杀这类场景,因为客户端工夫有差别,所以须要申请服务端接口,一直修改倒计时工夫来满足需要。

注释

setInterval倒计时

这个很简略,这里就简略写个 demo 了

let t = 5const 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...