深度解密setTimeout和setInterval——为setInterval正名!
栏目: JavaScript · 发布时间: 5年前
内容简介:重复定时器,JS有一个方法叫做setInterval专门为此而生,但是大家diss他的理由很多,比如跳帧,比如容易内存泄漏,是个没人爱的孩子。而且setTimeout完全可以通过自身迭代实现重复定时的效果,因此setIntervval更加无人问津,而且对他退避三舍,感觉用setInterval就很low。But!setInverval真的不如setTimeout吗?请大家跟着笔者一起来一步步探索吧!无论是setTimeout还是setInterval都逃不过执行延迟,跳帧的问题。为什么呢?原因是事件环中JS
重复定时器,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正名!》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。