JavaScript定時器解密

單線程JavaScript這篇文章中,在介紹JavaScript單線程的同時,也介紹了setTimeout是如何工作的。但是對於定時器的一些內容,並沒有做深入的討論。這篇文章,會詳細說說JS的兩種定時器,setTimeout和setInterval,以及它們的工作方式。同時,會談談有關setTimeout的面試題。

setInterval

setInterval,也稱爲間歇調用定時器,是指允許設置間歇時間來調用定時器代碼在特定的時刻執行。也就是說,setInterval會在每隔指定的時間就執行一次代碼。

setInterval屬於window對象上的私有方法,它可以接收多個參數,

第一個參數可以是一個函數,也可以是一個字符串。

第二個參數是每次執行之前需要等待的毫秒數,這裏有一個很大的誤區就是,當設定時間之後,很多人認爲會立即執行定時器,其實不是。設定一個 150ms 後執行的定時器不代表到了 150ms 代碼就立刻執行,它表示代碼會在 150ms 後被加入到任務隊列中。如果在這個時間點上,主線程上的所有同步任務都執行完畢,並且任務隊列上沒有其他任務,那麼這個任務會被執行;如果主線程上的同步任務未執行完畢,且任務隊列上還存在其他異步任務(包括時間更短的定時器),這時候就要等待以上同步任務和異步任務執行完畢之後,這個150ms的任務纔會開始執行。

第三個參數以後是指傳入函數的一些參數。其中,只有第一個參數是必須的,其他都是可選的。在默認情況下,第二個參數默認值爲0。但是0毫秒實際上也是達不到的。根據HTML 5標準,setTimeout推遲執行的時間,最少是5毫秒。如果小於這個值,會被自動增加到5ms。

//let timer = setInterval(func[, delay, param1, param2, ...]);
let timer = setInterval(function(a, b) {
    console.log(a, b);
}, 1000, 1, 2);
//在執行棧爲空時,每隔一秒鐘就會輸出 1, 2

//不建議這樣使用!傳遞字符串會導致性能損失
let timer = setInterval("alert('Hello world')", 1000);

調用完setInterval之後,該方法會返回一個定時器ID,主要用於取消超時調用。

關於setInterval間歇調用定時器,在MDN和《JavaScript高級程序設計(第三版)》上都是不推薦使用的,因爲setInterval會帶來一些問題。所以,一般情況下,我們會使用setTimeout來代替setInterval。但作爲學習,還是要理解其中的原理。

setInterval問題在於(1)某些間隔會被跳過;(2)多個定時器代碼之間的間隔可能會比預期的小。

假設,某個 onclick 事件處理程序使用 setInterval() 設置了一個 200ms 間隔的重複定時器。如果事件處理程序花了 300ms 的時間完成,同時定時器代碼也花了差不多的時間,就會同時出現跳過間隔且連續運行定時器代碼的情況。

這個例子中的第 1 個定時器是在 205ms 處添加到隊列中的(即使任務隊列爲空,0ms實際上是達不到的,因此至少爲5ms),但是直到過了 300ms 處才能夠執行。當執行這個定時器代碼時,在 405ms 處又給任務隊列添加了另外一個副本。在下一個間隔,即 605ms 處,第一個定時器代碼仍在運行,同時在任務隊列中已經有了一個定時器代碼的實例。結果是,在這個時間點上的定時器代碼不會被添加到隊列中。結果在 5ms 處添加的定時器代碼結束之後,405ms 處添加的定時器代碼就立刻執行。因此,《JavaScript高級程序設計(第三版)》建議,使用超時調用(setTimeout)來模擬間歇調用(setInterval)的是一種最佳模式,原因是後一個間歇調用可能會在前一個間歇調用結束之前啓動。

setTimeout

關於setTimeout,它的語法同setInterval。

由於setInterval間歇調用定時器存在一些問題,所以一般會使用setTimeout代替setInterval,至少我本人在開發中是不會使用setInterval的..替換代碼如下。

setTimeout(function timer() {
    //需要執行的代碼
    //setTimeout會等到定時器代碼執行完畢之後纔會重新調用自身(遞歸),要注意的是要給匿名函數添加一個函數名,以便調用自身。
    setTimeout(timer, 1000);
}, 1000)

這樣做的好處是,在前一個定時器執行完畢之前,不會向任務隊列中插入新的定時器代碼,因此確保不會有任何缺失的間隔。而且,它可以保證在下一次定時器代碼執行之前,至少要等待指定的間隔,避免了連續執行。這個模式主要用於重複定時器。再看看一些實例。

let num = 0;
let max = 10;

setTimeout(function timer() {
    num++;
    console.log(num);
    if (num === max) {return}
    setTimeout(timer, 500)
}, 500);
//或者是
setTimeout(function timer() {
    num++;
    console.log(num);
    if (num < max) {setTimeout(timer, 500)}
}, 500);

綜上,由於setInterval間歇調用定時器會因爲在定時器代碼未執行完畢時又向任務隊列中添加定時器代碼,導致某些間隔被跳過等問題,所以應使用setTimeout代替setInterval

有關setTimeout的面試題

關於setTimeout的面試題,主要是循環中使用定時器以及定時器中this的指向性問題。在setTimeout內部,this綁定採用默認綁定規則,也就是說,在非嚴格模式下,this會指向window;而在嚴格模式下,this指向undefined。詳細可參考此答案如何理解JavaScript中的this關鍵字

對於循環中使用定時器,問題如下,然後各種問題慢慢開拓…

for (var i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(i);
    }, 1000 * i)
}
//以上代碼輸入什麼?

回答:以上代碼輸出5個5,並且每隔1s輸出一個,一共用時4s。這裏我想解釋一下爲什麼會這樣子輸出。以下解釋爲個人想法,僅供參考。

我們給代碼做一些調整。

for (var i = 0; i < 5; i++) {
    let timer = setTimeout(function() {}, 1000 * i)
    console.log(timer);
    //輸出1, 2, 3, 4, 5
}

控制檯輸出了5個不同的定時器ID,說明在for循環當中,創建了5個setTimeout定時器。(此部分由博友指出,已修改,加粗字體)//定時器會循環創建,但是會等到同步任務(for循環)執行完畢,輸出0, 1, 2, 3, 4之後,主線程纔會執行任務隊列上的任務(定時器),同時開始計時,但是會等到其他異步任務完畢纔會執行定時器代碼//。並且,setTimeout的第二個參數(指定多少ms將定時器推入任務隊列中),並非引用的是全局作用域的i(即循環結束退出時的),而是正常情況,即按照循環變量i的累加。因此,可以將以上代碼改寫。

setTimeout(function() {
    console.log(5);
}, 0);
setTimeout(function() {
    console.log(5);
}, 1000);
setTimeout(function() {
    console.log(5);
}, 2000);
setTimeout(function() {
    console.log(5);
}, 3000);
setTimeout(function() {
    console.log(5);
}, 4000);

這裏需要注意的是,setTimeout回調函數中的i引用的是全局作用域下的i(即循環結束時的i),而設定時間的i與for循環的變量i累加相同。

如果有不同意見的博友,請給我留言,共同學習。

問題二:問題一的代碼如何讓其輸出0, 1, 2, 3, 4呢?

回答:這裏有兩種解決方法,不過其中的原理都相同,即給setTimeout定時器外層創建一個塊作用域,或者是創建函數作用域以形成閉包。

//方法一:ES6 let關鍵字,創建塊作用域
for (let i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(i);
    }, 1000 * i)
}
//以上代碼實際上是這樣的
for (var i = 0; i < 5; i++) {
    let j = i;  //閉包的塊作用域
    setTimeout(function() {
        console.log(j);
    }, 1000 * j);
}

//方法二:IIFE
for (var i = 0; i < 5; i++) {
    (function iife(j) {     //閉包的函數作用域
        setTimeout(function() {
            console.log(j);
        }, 1000 * i);   //這裏將i換爲j, 可以證明以上的想法。
    })(i);
}
//實際上,函數參數,就相當於函數內部定義的局部變量,因此下面的寫法是相同的。
for (var i = 0; i < 5; i++) {
    (function iife() {
        var j = i;
        setTimeout(function() {
            console.log(j);
        }, 1000 * i);   //如果這裏將i換爲j, 可以證明以上的想法。
    })();
}

這裏簡單說明方法二使用立即執行的函數表達式的原因。

給定時器外層創建了一個IIFE,並且傳入變量i。此時,setTimeout會形成一個閉包,記住並且可以訪問所在的詞法作用域。因此,就會正常輸出1, 2, 3, 4。

問題三: 如果原問題改爲如下,會輸出什麼?

for (var i = 0; i < 5; i++) {
    setTimeout((function() {
        console.log(i);
    })(), 1000 * i);
}

回答:立即輸出1, 2, 3, 4。因爲是setTimeout的第一個參數是函數或者字符串,而此時函數又立即執行了。因此,此時的定時器無效了,直接輸出1, 2, 3, 4。上面的代碼等同於如下

for (var i = 0; i < 5; i++) {
    (function() {
        console.log(i); //0, 1, 2, 3, 4
    })();
}

問題四,代碼如下,輸出順序是什麼?

console.log(1);

setTimeout(function() {
  console.log(2);
}, 0);

$.ajax({
    url: "../index.php",  //假如上一級目錄下有php文件,並且echo '3';
    data: 'GET',
    success: function(data) {
        console.log(data);
    },      
})

new Promise(function(resolve, reject) {
    console.log(4);
    resolve();
}).then(function() {
    console.log(5);
}).then(function() {
    console.log(6);
})
console.log(7);

回答:此時的輸出順序是1, 4, 7, 5, 6, 3, 2。這裏涉及Promise對象,這道題的解釋先留着,等到介紹Promise對象時再在Pormise的相關文章中回答。

不過如果有博友知道原因,也可以給我留言,共同學習。

參考連接

定時器

window.setTimeout

window.setInterval

單線程JavaScript

如何理解 JavaScript 中的 this 關鍵字?

深入理解javascript函數參數與閉包(一)

深入理解javascript閉包(二)

什麼是閉包?

發佈了26 篇原創文章 · 獲贊 62 · 訪問量 13萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章