在 "單線程JavaScript" 這篇文章中,在介紹JavaScript單線程的同時,也介紹了setTimeout是如何工作的。但是對於定時器的一些內容,並沒有做深入的討論。這篇文章,會詳細說說JS的兩種定時器,setTimeout和setInterval,以及它們的工作方式。同時,會談談有關se ...
在單線程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關鍵字
閉包的一些特點:
1. 基於詞法作用域的查找規則
2. 將函數作為值傳遞(將函數作為參數傳入另一個函數,或者將函數作為另一個函數的結果返回)
3. 閉包擁有更長的生命周期
4. 閉包中的this預設指向全局作用域,閉包中的this會指向全局的原因在於閉包都是在當前詞法作用域之外被調用的(在ES6之前,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之後,主線程才會執行任務隊列上的任務(定時器),幾乎同時開始計時(for迴圈完畢的時間極短,時間可以忽略不計,因此可以將5個定時器看做是同時創建的,理解這個非常重要),但是會等到其他非同步任務完畢才會執行定時器代碼//。並且,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累加相同。
這裡,為什麼會等待for執行完畢才開始計時,給出下麵一段代碼。
for (var i = 0; i < 5; i++) {
console.log(i);
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}
//依次輸出:0, 1, 2, 3, 4 接著輸出5個5
如果有不同意見的博友,請給我留言,共同學習。
問題二:問題一的代碼如何讓其輸出0, 1, 2, 3, 4呢?
回答:這裡有兩種解決方法,不過其中的原理都相同,即給setTimeout定時器外層創建一個塊作用域,或者是創建函數作用域以形成閉包。
關於閉包,我們知道,閉包的一個特點就是基於詞法作用域的查找規則,由於此時的回調函數引用的是迴圈結束後i的值(即,此時已經查找到了全局作用域下),因此,當我們在定時器外添加函數作用域並且傳入一個記錄迴圈變數的值,就意味著我們在函數作用域就擁有了此變數i,而不用到全局作用域下查找。此時的定時器仍然是迴圈創建,並且幾乎同時開始計時,不過唯一不同的是i的引用不再指向全局作用域。
在迭代內使用 IIFE 會為每個迭代都生成一個新的作用域,使得延遲函數的回調可以將新的作用域封閉在每個迭代內部,每個迭代中都會含有一個具有正確值的變數供我們訪問。
//方法一: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);
}
回答:立即輸出0, 1, 2, 3, 4。因為是setTimeout的第一個參數是函數或者字元串,而此時函數又立即執行了。因此,此時的定時器無效了,直接輸出0, 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的相關文章中回答。
總結:
最後,就此題做出一個關於在for迴圈中創建setTimeout定時器的總結:
1. 根據事件迴圈和任務隊列的原理,定時器會在迴圈結束後才會加入到任務隊列執行。
2. 定時器是迴圈創建的。
3. 定時器幾乎是同時開始計時的。
4. 定時器中的回調函數屬於閉包,包含著對迴圈後全局變數i的引用。在塊作用域和定時器外創建一個函數作用域時,此時不會查找全局作用域。
5. 定時器的第二個參數不屬於閉包的一部分,其值與迴圈i的值相同。
參考連接