深度解密setTimeout和setInterval——爲setInterval正名!

前言

重複定時器,JS有一個方法叫作setInterval專門爲此而生,可是你們diss他的理由不少,好比跳幀,好比容易內存泄漏,是個沒人愛的孩子。並且setTimeout徹底能夠經過自身迭代實現重複定時的效果,所以setIntervval更加無人問津,並且對他遠而避之,感受用setInterval就很low。But!setInverval真的不如setTimeout嗎?請你們跟着筆者一塊兒來一步步探索吧!web

大綱

  • 重複定時器存在的問題ajax

  • 手寫一個重複定時器算法

    • setTimeout的問題與優化
    • setInterval的問題與優化
  • 那些年setInterval背的鍋——容易形成內存泄漏chrome

重複定時器的各種問題

不管是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的誤差。bash

修復setTimeout的侷限性

說到想要修正時間誤差,你們會想到什麼?沒錯!就是獲取當前時間的操做,經過這個操做,咱們就能夠每次運行的時候修復間隔時間,讓總時長不至於誤差太大。websocket

/*
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不背這個鍋。

相關文章
相關標籤/搜索