超高精度的秒杀倒计时

前言

最近遇到一个倒计时的类似秒杀的场景,没多考虑洋洋洒洒的使用 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...

你可能感兴趣的:(超高精度的秒杀倒计时)