关于javascript:超高精度的秒杀倒计时

47次阅读

共计 5193 个字符,预计需要花费 13 分钟才能阅读完成。

前言

最近遇到一个倒计时的相似秒杀的场景,没多思考洋洋洒洒的应用 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,应用 intervaloffset 即可失去下一个 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…

正文完
 0