用 JavaScript 写个定时器,规定某段代码隔一段时间执行一次,第一反应肯定是用 setInterval
.
例如规定每隔两百毫秒打印一次 hello
, 就可以像下面这样写:
setInterval(() => {
console.log('hello')
}, 200)
上面的代码,每两百毫秒,将 console.log('hello')
推到事件队列。
setIntreavl
的第一个参数是要重复执行的函数,第二个参数是重复执行的时间间隔,以毫秒为单位;返回值是 Number
类型,一个唯一标志这个定时器的 ID,通过 clearInterval(intervalID)
可以清除这个定时器。
setInterval 的缺陷
考虑极端情况,假如定时器里面的代码需要进行大量的计算,或者是 DOM 操作。
这样一来,花的时间就比较长,有可能前一次代码还没有执行完,后一次代码就被添加到队列了。
假如时间间隔为 100 毫秒,要执行的代码需要 300 毫秒,如下图所示:
图中只演示了定时器的前三次执行状况。
一开始执行 setInterval
, 100 毫秒后将要执行的代码添加到队列。
100 毫秒时,执行代码进入队列,队列空闲,定时器内的代码执行。
200 毫秒时,第一次的定时器代码还在执行当中。第二次的定时器代码被推入事件队列,等待队列空闲,然后执行。
300 毫秒时,第一次的定时器代码还在执行中,第二次的定时器代码在事件队列末端等待执行。因为该定时器已经有第二次的代码在队列中等待了,所以这一次的代码不会被推入队列,被忽略了。
400 毫秒时,第一次的定时器代码执行完毕,队列空闲,下一个等待的代码执行,第二次的定时器代码开始执行。
捋一捋,这里的第一次的代码和第二次代码的间隔并没有预期的 100 毫米,而是第一次的执行完,第二次的立马执行了。因为第一的代码还没执行完,第二次的代码就已经在队列中等待了。
关于被忽略的第三次定时器代码,因为 300 毫秒时,这个定时器已经有第二次的代码在等待了,而只有当没有该定时器的代码在队列中时,该定时器新的代码才能去排队,所以第三次不会被添加到队列中。
由上可见,在这种极端情况下,setIntreval
实现不了需求。
用 setTimeout 实现 setInterval
而 setTimeout
可以实现 setInterval
的功能,并且不会出现上面的情况。
const repeat = (func, ms) => {
setTimeout(() => {
func()
repeat(func, ms)
}, ms)
}
上面的 repeat
接受两个参数,func
是要以间隔时间执行的函数,ms
代表间隔的毫秒数。
repeat
内部用 setTimeout
在指定的毫秒数 ms
之后,将匿名函数推入事件队列,匿名函数中包含要执行的 func
, 以及 repeat(func, ms)
。匿名函数执行时,先执行 func
, 然后递归调用 repeat
来模拟 setInterval
,递归调用的 repeat
中,又将执行 setTimeout
, 在 ms
毫秒后,将下一次的定时器代码推入队列末端。
这里可以注意到,将下一次定时器代码推入队列时,上一次的代码无论如何都已经执行完了,所以不会重蹈 setInterval
的覆辙。
但是这种实现方式,不能清除定时器,还需要改造一下。
setTimeout 方式的改良
function Timer() {
this.timeID = null
this.func = null
}
Timer.prototype.repeat = function(func, ms) {
if (this.func === null) {
this.func = func
}
// 确保一个 Timer 实例只能重复一个 func
if (this.func !== func) {
return
}
this.timeID = setTimeout(() => {
func()
this.repeat(func, ms)
}, ms)
}
Timer.prototype.clear = function() {
clearTimeout(this.timeID)
}
const a = () => console.log('a')
const b = () => console.log('b')
const timer = new Timer()
timer.repeat(a, 1000)
timer.repeat(b, 1000) // 不会定时执行 b
上面的代码定义了构造函数 Timer
, 其产生的实例有两个属性,timeID
用来存储 setTimeout
返回的值,也就是定时器的 ID; func
则用来存储需要被定时执行的函数。
Timer
的 prototype
上定义了 repeat
方法,和之前的 repeat
函数大致一样。只是在开头,将传入的 func
存到了 this.func
. repeat
递归调用时,每执行一次 setTimeout
, this.timeID
就会取得最新的定时器的 ID.
Timer
的 prototype
上还定义了 clear
方法,用来清除定时器,。
注意到 repeat
和 clear
两个方法,定义时用的是函数声明,而不是匿名函数。之所以这样做,是为了保证方法内 this
指向 Timer
实例。如果用的是箭头函数,这两个方法里面的 this
将指向全局对象 Window
, 更多和 this
有关的,可参考这篇博客。
接下来定义了两个简单的函数 a
和 b
.
再 new
一个 Timer
赋值给变量 timer
, timer
执行 repeat
方法定时运行 a
和 b
.
可以发现,每隔大约一秒,就会执行一次 a
, 控制台就会打印一次 a
. 而 b
则不会定时重复执行。因为一个 Timer
实例只能重复执行一个函数。假如不作限制,a
和 b
重复,那么在 clear
的时候,只能清除掉最后添加进来的重复代码的定时器 ID.
假如要重复执行 b
, 只能再实例化一个 Timer
.
总结
因为 setInterval
在一些情况下,会导致前后两次定时器代码的执行间隔产生不可预料的变化,甚至会跳过某次定时器代码的执行,所以可用 setTimeout
实现 setInterval
的功能。