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關鍵字

閉包的一些特點:

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	

稍稍的總結一下:javascript是單線程語言,只有主線程上的所有同步任務執行完畢,主線程才會讀取任務隊列上的異步任務。for循環屬於同步任務,而定時器屬於異步任務。所以會在for循環結束之后才開始執行定時器的代碼。因此會輸出5個5。同理。如果在for循環中創建點擊事件也是如此。因為異步任務包括IO操作(ajax)和與用戶交互的事件(click, mouseover等)。如下

// 注意:使用addEventListener可以為同一個元素綁定多個相同事件,而onclick則只能綁定一個相同事件
// 解決方法1:使用let關鍵字創建塊級作用域
const node = document.querySelector('.button')
for (let i = 0; i < 5; i++) {
  node.addEventListener('click', () => {
    console.log(i)
  }, false)
}

// 解決方法2:為事件創建閉包
for (let i = 0; i < 5; i++) {
  ((j) => {
    node.addEventListener('click', () => {
      console.log(j)
    }, false)
  })(i)
}

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

問題二:問題一的代碼如何讓其輸出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的值相同。

參考連接

定時器

window.setTimeout

window.setInterval

單線程JavaScript

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

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

深入理解javascript閉包(二)

什么是閉包?


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM