如何写一个高性能、易扩展、易用的计时器?
某日,接到了产品需求,说要加一个抢购列表页面,列表中每一项要加一个抢购倒计时,没多想,使用 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)
或者再改造一下,实现类似 setTimeout
和 setInterval
的调用方式
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