preface

Recently, I encountered a similar scene of seconds killing in the countdown. I completed the task by using setTimeout recursively without much consideration. After the launch, users reported that the countdown error of multiple devices was several seconds. On second thought, the client time was different, so the server should be used for time calibration. I checked some materials and sorted out the following contents.

Countdown for the front end is a simple and simple, complex, but also a little complex east east.

Less precise, it’s easy to think of using setInterval;

If the timing accuracy per second is required, recursive setTimeout can be used to continuously modify the time to countdown;

For such scenarios, the client time is different, so it needs to request the server interface to constantly modify the countdown time to meet the requirements.

The body of the

SetInterval countdown

So this is pretty easy, so I’m just going to write a demo here

let t = 5
const timer = setInterval(() = > {
  if (--t < 0) clearInterval(timer)
}, 1000)
Copy the code

SetTimeout countdown

Using setTimeout recursion there are many implementations on the web, very accurate.

The principle is to record the current time T1 before countdown and the recursion times C1 during recursion. For each recursion, the current time T2 – (T1 + C1 * interval) is used to obtain the error time offset. Use interval-offset to get the time of the next setTimeout.

const t1 = Date.now()
let c1 = 0  // The number of recursions
let timer: any = null   / / timer
let t = 5  // Count down the seconds
let interval = 1000  / / interval

function countDown() {
  if (--t < 0) {
    clearTimeout(timer)
    return
  }
    
  // Calculate error
  const offset = Date.now() - (t1 + c1 * interval)
  const nextTime = interval - offset
  c1++

  timer = setTimeout(countDown, nextTime)
}
countDown()
Copy the code

Countdown with server correction time

In the second kill scenario, the server needs to modify the client countdown time.

The principle is to use one timer to time, and another timer to update the time variable, to correct the time.

The demo is as follows

const interval = 1000  // Time interval
const debounce = 3000  // Correct time, request interface interval
const endTime = Date.now() + 5 * 1000  // End of time
let now = Date.now()  // The initial time
let timer1: any = null  // Countdown timer
let updateNowTimer: any = null  // Request an interface timer

// Countdown timer
timer1 = setInterval(() = > {
  now = now + interval
  const leftT = Math.round((endTime - now) / 1000)

  if (leftT < 0) {
    clearInterval(timer1)
    clearInterval(updateNowTimer)
    return
  }
}, interval)

// Simulate the request interface, update the now value
updateNowTimer = setInterval(() = > {
  new Promise((resolve) = > {
    setTimeout(() = > {
      now = Date.now()
      resolve(void 0)},1000)
  })
}, debounce)

Copy the code

When there are multiple countdown instances, you simply need to update the now values of the multiple instances in the updateNowTimer.

In addition to poor code and timer management, the demo had some issues, such as not considering when to clear the updateNowTimer timer in multiple instances.

CountItDownTimer

Let’s use class notation to redesign the countdown, simple code is as follows:

interface CountDownOpt {
  interval: number
  endTime: numbermanager? : CountDownManager onStep? (value: CountDownDateMeta):voidonEnd? () :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? (1)}.this.opt.onStep? . (this.calculateTime())
    }, this.opt.interval)

    this.opt.manager.add(this)}clear() {
    clearInterval(this.timer)
    this.opt.manager.remove(this)}}Copy the code

CountDownManager (CountDownManager) {CountDownManager (CountDownManager) {CountDownManager (CountDownManager) {CountDownManager (CountDownManager); Uniformly update the now value of registered CountDown instances.

The code for CountDownManager is as follows:

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)
    }
  }
}

Copy the code

This way, all instances registered in CountDownManager can be updated at the same time.

The full code is here: CountItDownTimer.

Use demo as follows:

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,
  }),
});
Copy the code

When passed to the manager, the timing method modified by the server will be used. When not passed, the local time will be used, using setTimeout recursion.

In the case of multiple CountDownManager instances, you simply pass in the same CountDownManager instance in the manager configuration of multiple instances. When an interface is requested, the now time is changed uniformly for all instances. The library also takes into account the time taken to request the API.

Get an instance of countDown

First, multiple seckill countdowns should use the same CountDownManager instance, which will update the latest timing of all seckill countdowns after requesting the interface.

We can simply wrap it up and get an instance of 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('Time acquisition failed', e)
    }
    return Date.now()
  },
})

interface CountDownInstanceOpt extendsPartial<CountDownOpt> { server? :boolean
}

export const getCountDownInstance = (opt: CountDownInstanceOpt) = > {
  const{ server, ... countDownOpt } = optreturn new CountDown(Object.assign({}, server ? { manager: countDownManager } : {}, countDownOpt))
}
Copy the code

We can get an instance of CountDown using the getCountDownInstance method and pass in the server parameter to control whether or not the server update timing is performed by the default Manager. We can also pass in the Manager to CountDown different instances. Each instance individually requests the interface for updates.

useCountDown

After creating a new instance of countDown, when the page is unmounted, we need to manually clear the timers, and when we have N timers in the page, it becomes a bit annoying to write, so consider packing them as custom 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
}
Copy the code

Countdown module

Developers can easily settle the useCountDown hook in the wrong location if they do not have code performance to pursue. When timing, the useCountDown hook will run large chunks of code and calculate the difference between Vdom changes, resulting in large timing errors. So in addition to using the Memo optimization, we should also take care to let useCountDown into the smallest component unit.

interface CountDownProps {
  endTime: string // Termination date
  onEnd(): void // Countdown to the end of the callbackrender(date: CountDownDateMeta): JSX.Element server? :boolean // Use the server to calibrate the time
}

export const CountDown: FC<CountDownProps> = memo(({ endTime, onEnd, render, server }) = > {
    const time = useCountDown({ endTime: new Date(endTime).getTime(), onEnd, server })
    return <>{render(time)}</>},)Copy the code

Use:

<CountDown
  server
  endTime="The 2021-03-26 T11:00:00) 000 z"
  onEnd={() = > console.log('finished')}
  render={(t) = > <div>{JSON.stringify(t)}</div>} // There should be as little DOMELement as possible
/>
Copy the code