如何写一个高性能、易扩展、易用的计时器?

某日,接到了产品需求,说要加一个抢购列表页面,列表中每一项要加一个抢购倒计时,没多想,使用 setInterval 快速实现了。

随着列表中项目越来越多,各个项目的倒计时越来越不准,且页面变的越来越卡。

其实很容易想到,n 多个 setInterval 实例同时运行,阻塞了 JS 线程,导致页面越来越卡,计时器计时出现了偏差。有没有办法用一个 setInterval 实例,进行多个倒计时呢?

首先看一下 setInterval 用法:

setInterval(() => {
    // callback
}, 1000)

如果在 setInterval 中执行多个回调函数,那么就可以实现我们的需求。

简单实现一下:

class Timer {
    private timerId
    private cbs = []
    private cbId = 0

    constructor(delay) {
        this.delay = delay
    }

    private start() {
        this.timerId = setInterval(() => {
            this.cbs.forEach(item => {
                item.cb()
            })
        }, this.delay)
    }

    private stop() {
        clearInterval(this.timerId)
    }

    add(cb) {
        const id = this.cbId++
        this.cbs.push({ cb, id })
        if(!this.timerId) this.start()
        return id
    }

    remove(cbId) {
        const cbIdx = this.cbs.findIndex(({ id }) => id === cbId)
        this.cbs.splice(cbIdx, 1)
        if(!this.cbs.length) this.stop()
    }
}

问题又来了,不同的回调函数,可能需要不同的计时间隔,这怎么处理呢?

可以通过一个计数器,计算 setInterval 的执行次数,执行次数 * 间隔时间就是执行总时间,有了执行总时间就好办了,只需要进行余运算即可。

首先设计 cb 对象数据结构

interface CallBack {
    id: number   // 回调 id
    cb: () => any   // 回调函数
    interval: number    // 执行回调间隔
}

再来实现demo:

// some code
let count = 0
let delay = 1000
let cbs: CallBack[] = []
 setInterval(() => {
    cbs.forEach(({ cb, interval }) => {
        if(!(count * delay % interval)) {
            cb()
        }
    })
}, delay)
// some code

上面 demo 中写死了执行间隔为 1000ms,那对于注册了 500ms 执行的回调函数来讲,会延迟 500ms 后才执行。我们可以遍历所有 cbs,从中获取最小的 interval 当做 delay 即可。

// some code
const min = this.cbs[0].interval
const delay = this.cbs.reduce((min, cur) =>  cur.interval < min ? cur.interval : min,min)
// some code

使用时,只需要 new 一个 Timer 实例,在需要倒计时的地方,通过 add 添加回调函数即可自动启动计时器,删除时,调用 remove 方法,删除完所有注册的回调函数,计时器自动停止。

const timer = new Timer()

const timerId = timer.add(() => {}, 1000)
timer.remove(timerId)

或者再改造一下,实现类似 setTimeoutsetInterval 的调用方式

const setTimeoutInterval = timer.add.bind(timer)
const clearTimeoutInterval = timer.remove.bind(timer)

调用

const timer = setTimeoutInterval(() => {
    // some code
})

clearTimeoutInterval(timerId)

改造后的倒计时性能无疑好了许多,页面不再卡顿。且无论添加多少个计时回调,它运行的都是同一个计时实例。

使用 setInterval 的计时还是越来越不准,setInterval 会将回调函数间隔插入 JS 线程中,但线程如果正在执行耗时任务,插入的回调函数将偏移其应当在的位置,滞后执行,下一次插入的位置,参照了滞后插入的位置,所以导致运行时间越长,偏差越大。

使用递归 setTimeout ,不断修正将回调函数插入线程的时间,即可获得相对准确的倒计时。

简单实现一下:

let count = 0 // 递归次数
let now = Date.now() // 初始执行时间

function countdown() {
    const offset = Date.now() - (now + count * 1000)
    const nextTime = 1000 - offset
    count++

    setTimeout(() => {
        countdown()
    }, nextTime)
}
countdown()

这里我们记录了初始执行时间,和 countdown 递归执行的次数,根据这两者,我们可以计算出偏移时间和下次 setTimeout 的时间。

改造后,虽然倒计时准确了许多。但,又回到了上面的问题,列表项越多, setTimeout 实例越多,页面也会越来越卡。

我们可以使用 setTimeout 模拟 setinterval,并将其替换到上面我们的 Timer 类中。即可解决问题。

function timeoutInterval(cb, interval = 1000, getTimerId) {
  let count = 0
  let now = Date.now()
  let timerId = null

  function countdown() {
    const offset = Date.now() - (now + count * interval)
    const nextTime = interval - offset
    count++

    timerId = setTimeout(() => {
      countdown()
      cb()
    }, nextTime)

    getTimerId(timerId)
  }

  countdown()
}

这里值得注意的是,由于我们这里使用递归 setTimeout, 所以每次生成的 timeId 都是不一样的,所以设计将其通过 cb 回调函数的参数传出。

用法如下:

let i = 0
timeoutInterval(
    () => {
    // do something
    }, 
    1000,
    (timerId) => {
        this.timerId = timerId
    }
)

将其替换到 Timer 类中

class Timer {
    // some code
    private start() {
        timeoutInterval(
            () => {
                this.cbs.forEach(item => {
                    item.cb()
                })
            }, 
            1000,
            (timerId) => {
                this.timerId = timerId
            }
        )
    }
    // some code
}

改造后的倒计时性能良好,且因为只有一个计时实例,页面也不会卡顿。

具体实现请查阅代码: TimeoutInterval

对于一些秒杀抢购场景,这种倒计时是有问题的,因为本地时间与服务器时间有偏差,如果抢购单纯由前端倒计时来控制,那么很容易出现用户修改本机时间,页面就出现了购买按钮可以直接购买的 bug。由本地计时引起的 bug,在目前市面上的 APP 上很常见,除了抢购场景外,接口防重放机制中会校验客户端请求携带的时间戳,通常约定,如果客户端请求的时间戳与服务端时间偏差在 60s 之外,则该请求无效,所以在修改本机时间后,打开某些 APP,会看到空白页面。

解决办法很简单。

打开应用后,首先将客户端与服务端的偏移时间存到本地,秒杀倒计时的时候,将偏移时间加上即可。这样的话,无论客户端时间是提前还是之后,都对应用没有影响。

简单写个 demo:

// 首先获取偏移时间
prequest('/api').then(res => {
    // nginx 服务器,可以从响应头拿到时间
    const date = res.headers.Date
    const offsetTime = Date.now() - new Date(date).getTime()
    localStorage.setItem('offsetTime', offsetTime)
})

// 封装获取时间方法
function getNow() {
    const offset = localStorage.getItem('offsetTime')
    return Date.now() + Number.parseInt(offset)
}

回到我们的计时器代码,只需要将其中的 Date.now() 方法替换成 这里的 getNow() 即可。

到目前为止,上面的代码已经可以应对大部分计时场景,但对于秒杀场景来说,本地运行的倒计时可能还是不够可靠,可以设计间歇性请求接口获取服务端时间,更新倒计时,来获得更高计时精确度。

首先大致设计获取服务端时间方法

async function getServerTime() {
    const start = Date.now()    // 开启请求时间
    const serverTime = await prequest('/api').then(res => ...)
    const endTime = Date.now()
    return serverTime + (endTime - startTime) / 2
}

这里考虑了请求网络消耗的时间。

其次考虑我们倒计时,当每次拿到服务端时间后,加上 interval 时间,判断是否和目标时间相等即可。

demo如下

let now = Date.now()
let interval = 1000
setInterval(() => {
    if (now + interval >=  endTime) {
        // some code
    }
}, interval)

setInterval(() => {
    getServerTime(res).then(res => now = res)
}, 5000)

这里我们维护了两个计时器,一个负责请求接口更新 now 数据,一个进行正常倒计时,写我们的业务逻辑。

当有 n 多个这样的倒计时实例,代码将不可维护。可以改造一下代码,使用类似事件发布订阅的模式来解决这个问题。

首先实现一个 manager 来实现事件发布订阅的逻辑

class CountDowmManager {
    queue = []
    tiemrId = null

    constructor({ getRemoteDate, interval }) {
        this.getRemoteDate = getRemoteDate
        this.interval = interval
    }

    private start() {
        this.timerId = timer.add(() => {
            this.getNow()
        }, this.interval)
    }

    private stop() {
        timer.remove(this.timerId)
    }

    on (countdown) {
        this.queue.push(countdown)
        if(!this.timerId) this.start()
    }

    off(countdown) {
        this.queue.splice(this.queue.findIndex(i => i === countdown), 1)
        if(!this.queue.length) this.stop()
    }

    private 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) / 2)
        } catch (e) {
            console.log('fix time fail', e)
        }
    }
}

CountDownManager 类中,维护了一个 countdown 实例的队列,每隔 interval 个时间,会请求接口,更新所有实例的 now 值。同时设计将获取服务器时间的函数由参数传入,已满足不同场景的不同需求。

接着,设计倒计时

class CountDown {
    now = Date.now()
    timerId = null

   // ... some code
    start() {
        this.timerId = timer.add(() => {
            this.now += interval

            if (this.now >= endTime) {
                // some code
                return
            }
        })
    }

    // some code
}

用法如下:

const manager = new CountDownManager()

const instance1 = new CountDown()
const instance2 = new CountDown()

manager.on(instance1) 
manager.on(instance2)

上面的 CounDown 代码中,只考虑了使用 server 更新时间的场景,其实我们也可以将上面使用本地时间进行的倒计时,整合到 countDown 类中。其次,可以设计将 manager 作为参数,传入到 countdown 实例,这样做的好处在于,我们不需要手动的注册和移除 countdown 实例,将 managr 当参数传入,在初始化实例时,就可以自动将当前实例注册;当倒计时结束,自动将当前实例移除。我们还可以根据是否传入 manager 来判断是否需要使用服务端来更新时间。

改造一下代码

class CountDown() {

    constructor({ manager, ...opt }) {
        this.manager = manager
        manager ? this.useServerToCountDown(...opt) : this.useLocalToCountDown(...opt)
    }

    useServerToCountDown() {
        // ...some code
        this.manager.on(this)
    }

    // ...some code

    clear() {
        timer.clear(this.timer)
        if(this.manager) {
            this.manager.off(this)
        }
    }
}

至此,我们就完成了一个高性能,好扩展,易用的计时器了。

完整代码请查阅 CountDown