前言
重复定时器,JS有一个方法叫做setInterval专门为此而生,但是大家diss他的理由很多,比如跳帧,比如容易内存泄漏,是个没人爱的孩子。而且setTimeout完全可以通过自身迭代实现重复定时的效果,因此setIntervval更加无人问津,而且对他退避三舍,感觉用setInterval就很low。But!setInverval真的不如setTimeout吗?请大家跟着笔者一起来一步步探索吧!
大纲
-
重复定时器存在的问题
-
手写一个重复定时器
- setTimeout的问题与优化
- setInterval的问题与优化
-
那些年setInterval背的锅——容易造成内存泄漏
重复定时器的各类问题
无论是setTimeout还是setInterval都逃不过执行延迟,跳帧的问题。为什么呢?原因是事件环中JS Stack过于繁忙的原因,当排队轮到定时器的callback执行的时候,早已超时。还有一个原因是定时器本身的callback操作过于繁重,甚至有async的操作,以至于无法预估运行时间,从而设定时间。
setTimeout篇
setTimeout那些事
对于setTimeout通过自身迭代实现重复定时的效果这一方法的使用,笔者最早是通过自红宝书了解的。
setTimeout(function(){
var div = document.getElementById("myDiv");
left = parseInt(div.style.left) + 5;
div.style.left = left + "px";
if (left < 200){
setTimeout(arguments.callee, 50);
}
}, 50);
选自《JavaScript高级程序设计(第3版)》第611页
这应该是非常经典的一种写法了,但是setTimeout本身运行就需要额外的时间运行结束之后再激活下一次的运行。这样会导致一个问题就是时间不断延迟,原本是1000ms的间隔,再setTimeout无意识的延迟下也许会慢慢地跑到总时长2000ms的偏差。
修复setTimeout的局限性
说到想要修正时间偏差,大家会想到什么?没错!就是获取当前时间的操作,通过这个操作,我们就可以每次运行的时候修复间隔时间,让总时长不至于偏差太大。
/* id:定时器id,自定义 aminTime:执行间隔时间 callback:定时执行的函数,返回callback(id,runtime),id是定时器的时间,runtime是当前运行的时间 maxTime:定时器重复执行的最大时长 afterTimeUp:定时器超时之后的回调函数,返回afterTimeUp(id,usedTime,countTimes),id是定时器的时间,usedTime是定时器执行的总时间,countTimes是当前定时器运行的回调次数 */
function runTimer(id,aminTime,callback,maxTime,afterTimeUp){
//....
let startTime=0//记录开始时间
function getTime(){//获取当前时间
return new Date().getTime();
}
/* diffTime:需要扣除的时间 */
function timeout(diffTime){//主要函数,定时器本体
//....
let runtime=aminTime-diffTime//计算下一次的执行间隔
//....
timer=setTimeout(()=>{
//....
//计算需扣除的时间,并执行下一次的调用
let tmp=startTime
callback(id,runtime,countTimes);
startTime=getTime()
diffTime=(startTime-tmp)-aminTime
timeout(diffTime)
},runtime)
}
//...
}
启动与结束一个重复定时器
重复定时器的启动很简单,但是停止并没有这么简单。我们可以通过新建一个setTimeout结束当前的重复定时器,比如值执行20秒钟,超过20秒就结束。这个处理方案没有问题,只不过又多给了应用加了一个定时器,多一个定时器就多一个不确定因素。
因此,我们可以通过在每次执行setTimeout的是判断是否超时,如果超时则返回,并不执行下一次的回调。同理,如果想要通过执行次数来控制也可以通过这个方式。
function runTimer(id,aminTime,callback,maxTime,afterTimeUp){
//...
function timeout(diffTime){//主要函数,定时器本体
//....
if(getTime()-usedTime>=maxTime){ //超时清除定时器
cleartimer()
return
}
timer=setTimeout(()=>{
//
if(getTime()-usedTime>=maxTime){ //因为不知道那个时间段会超时,所以都加上判断
cleartimer()
return
}
//..
},runtime)
}
function cleartimer(){//清除定时器
//...
}
function starttimer(){
//...
timeout(0)//因为刚开始执行的时候没有时间差,所以是0
}
return {cleartimer,starttimer}//返回这两个方法,方便调用
}
按照次数停止,我们可以在每次的callback中判断。
let timer;
timer=runTimer("a",100,function(id,runtime,counts){
if(counts===2){//如果已经执行两次了,则停止继续执行
timer.cleartimer()
}
},1000,function(id,usedTime,counts){})
timer.starttimer()
通过上方按照次数停止定时器的思路,那么我们可以做一个手动停止的方式。创建一个参数,用于监控是否需要停止,如果为true,则停止定时器。
let timer;
let stop=false
setTimeout(()=>{
stop=true
},200)
timer=runTimer("a",100,function(id,runtime,counts){
if(stop){
timer.cleartimer()
}
},1000,function(id,usedTime,counts){})
timer.starttimer()
setInterval篇
setInterval那些事
大家一定认为setTimeout高效于setInterval,不过事实啪啪啪打脸,事实胜于雄辩,setInterval反而略胜一筹。不过要将setInterval打造成高性能的重复计时器,因为他之所以这么多毛病是没有用对。经过笔者改造后的Interval可以说和setTimeout不相上下。
将setInterval封装成和上述setTimeout一样的函数,包括用法,区别在于setInterval不需要重复调用自身。只需要在回调函数中控制时间即可。
timer=setInterval(()=>{
if(getTime()-usedTime>=maxTime){
cleartimer()
return
}
countTimes++
callback(id,getTime()-startTime,countTimes);
startTime=getTime();
},aminTime)
为了证明Interval的性能,以下是一波他们两的pk。
Nodejs中:
浏览器中:
在渲染或者计算没有什么压力的情况下,定时器的效率
在再渲染或者计算压力很大的情况下,定时器的效率
首先是毫无压力的情况下大家的性能,Interval完胜!
接下来是很有压力的情况下?。哈哈苍天饶过谁,在相同时间,相同压力的情况下,都出现了跳帧超时,不过两人的原因不一样setTimeout压根没有执行
,而setInterval是因为抛弃了相同队列下相同定时器的其他callback
也就是只保留了了队列中的第一个挤进来的callback,可以说两人表现旗鼓相当。
也就是说在同步的操作的情况下,这两者的性能并无多大区别,用哪个都可以。但是在异步的情况下,比如ajax轮循(websocket不在讨论范围内),我们只有一种选择就是setTimeout,原因只有一个——天晓得这次ajax要浪多久才肯回来,这种情况下只有setTimeout才能胜任。
居然setTimeout不比setInterval优秀,除了使用场景比setInterval广,从性能上来看,两者不分伯仲。那么为什么呢?在下一小节会从事件环,内存泄漏以及垃圾回收这几个方面诊断一下原因。
事件环(eventloop)
为了弄清楚为什么两者都无法精准地执行回调函数,我们要从事件环的特性开始入手。
JS是单线程的
在进入正题之前,我们先讨论下JS的特性。他和其他的编程语言区别在哪里?虽然笔者没有深入接触过其他语言,但是有一点可以肯定,JS是服务于浏览器的,浏览器可以直接读懂js。
对于JS还有一个高频词就是,单线程。那么什么是单线程呢?从字面上理解就是一次只能做一件事。比如,学习的时候无法做其他事情,只能专心看书,这就是单线程。再比如,有些妈妈很厉害,可以一边织毛衣一边看电视,这就是多线程,可以同一时间做两件事。
JS是非阻塞的
JS不仅是单线程,还是非阻塞的语言,也就是说JS并不会等待某一个异步加载完成,比如接口读取,网络资源加载如图片视频。直接掠过异步,执行下方代码。那么异步的函数岂不是永远无法执行了吗?
eventloop
因此,JS该如何处理异步的回调方法?于是eventloop出现了,通过一个无限的循环,寻找符合条件的函数,执行之。但是JS很忙的,如果一直不断的有task任务,那么JS永远无法进入下一个循环。JS说我好累,我不干活了,罢工了。
stack和queue
于是出现了stack和queue,stack是JS工作的堆,一直不断地完成工作,然后将task推出stack中。然后queue(队列)就是下一轮需要执行的task们,所有未执行而将执行的task都将推入这个队列之中。等待当前stack清空执行完毕,然后eventloop循环至queue,再将queue中的task一个个推到stack中。
正因为eventloop循环的时间按照stack的情况而定。就像公交车一样,一站一站之间的时间虽然可以预估,但是难免有意外发生,比如堵车,比如乘客太多导致上车时间过长,比如不小心每个路口都吃到了红灯等等意外情况,都会导致公交陈晚点。eventloop的stack就是一个不定因素,也许stack内的task都完成后远远超过了queue中的task推入的时间,导致每次的执行时间都有偏差。
诊断setTimeout和setInterval
那些年setInterval背的锅——容易造成内存泄漏(memory leak)
说到内存泄漏就不得不提及垃圾回收(garbage collection),这两个概念绑在一起解释比较好,可是说是一对好基友。什么是内存泄露?听上去特别牛逼的概念,其实就是我们创建的变量或者定义的对象,没有用了之后没有被系统回收,导致系统没有新的内存分配给之后需要创建的变量。简单的说就是借了没还,债台高筑。所以垃圾回收的算法就是来帮助回收这些内存的,不过有些内容应用不需要,然而开发者并没有释放他们,也就是我不需要了但是死活不放手,垃圾回收也没办法只能略过他们去收集已经被抛弃的垃圾。那么我们要怎样才能告诉垃圾回收算法,这些东西我不要了,你拿走吧?怎么样的辣鸡才能被回收给新辣鸡腾出空间呢?说到底这就是一个编程习惯的问题。
导致memory leak的最终原因只有一个,就是没有即使释放不需要的内存——也就是没有释放定义的参数,导致垃圾回收无法回收内存,导致内存泄露。
那么内存是怎么分配的呢?
比如我们定义了一个常量var a="apple"
,那么内存中就会分配出空间村粗apple这个字符串。大家也许会觉得不就是字符串嘛,能占多少内存。没错,字符串占不了多少内存,但是如果是一个成千上万的数组呢?那内存占的可就很多了,如果不及时释放,后续工作会很艰难。
但是内存的概念太过于抽象,该怎么才能feel到这个占了多少内存或者说内存被释放了呢?打开chrome的Memory神器,带你体验如何感觉内存。
这里我们创建一个demo用来测试内存是如何工作的:
let array=[]//创建数组
createArray()//push内容,增加内存
function createArray(){
for(let j=0;j<100000;j++){
array.push(j*3*5)
}
}
function clearArray(){
array=[]
}
let grow=document.getElementById("grow")
grow.addEventListener("click",clearArray)//点击清除数组内容,也就是清除了内存
实践是唯一获取真理的方式。通过chrome的测试工具,我们可以发现清除分配给变量的内容,可以释放内存,这也是为什么有许多代码结束之后会xxx=null
,也就是为了释放内存的原因。
既然我们知道了内存是如何释放的,那么什么情况,即使我们清空了变量也无法释放的内存的情况呢?
做了一组实验,array分别为函数内定义的变量,以及全局变量
let array=[]
createArray()
function createArray(){
for(let j=0;j<100000;j++){
array.push(j*3*5)
}
}
createArray()
function createArray(){
let array=[]
for(let j=0;j<100000;j++){
array.push(j*3*5)
}
}
结果惊喜不惊喜,函数运行完之后,内部的内存会自动释放,无需重置,然而全局变量却一直存在。也就是说变量的提升(hoist)而且不及时清除引用的情况下会导致内存无法释放。
还有一种情况与dom有关——创建以及删除dom。有一组很经典的情况就是游离状的dom无法被回收。以下的代码,root已经被删除了,那么root中的子元素是否可以被回收?
let root=document.getElementById("root")
for(let i=0;i<2000;i++){
let div=document.createElement("div")
root.appendChild(div)
}
document.body.removeChild(root)
答案是no,因为root的引用还存在着,虽然在dom中被删除了,但是引用还在,这个时候root的子元素就会以游离状态的dom存在,而且无法被回收。解决方案就是root=null
,清空引用,消除有力状态的dom。
如果setInterval中存在无法回收的内容,那么这一部分内存就永远无法释放,这样就导致内存泄漏。所以还是编程习惯的问题,内存泄漏?setInterval不背这个锅。
垃圾回收(garbage collection)机制
讨论完那些原因会造成内存泄漏,垃圾回收机制。主要分为两种:reference-counting和mark sweap。
reference-counting 引用计数
这个比较容易理解,就是当前对象是否被引用,如果被引用标记。最后没有被标记的则清除。这样有个问题就是程序中两个不需要的参数互相引用,这样两个都会被标记,然后都无法被删除,也就是锁死了。为了解决这个问题,所以出现了标记清除法(mark sweap)。
mark sweap
标记清除法(mark sweap),这个方法是从这个程序的global开始,被global引用到的参数则标记。最后清除所有没有被标记的对象,这样可以解决两对象互相引用,无法释放的问题。
因为是从global开始标记的,所以函数作用域内的变量,函数完成之后就会释放内存。
通过垃圾回收机制,我们也可以发现,global中定义的内容要谨慎,因为global相当于是主函数,浏览器不会随便清除这一部分的内容。所以要注意,变量提升问题。
总结
并没有找到石锤表明setInterval是造成内存泄漏的原因。内存泄漏的原因分明是编码习惯不好,setInterval不背这个锅。
今天的文章深度解密setTimeout和setInterval——为setInterval正名!分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/13808.html