談談 JavaScript 的 setTimeout 與 setInterval
相信接觸過 JavaScript 的朋友對於要透過 JavaScript 來控制時間或是實作一個計時器,一定都會想到 setTimeout()
/ setInterval()
。
但你知道透過 setTimeout()
或 setInterval()
來計算時間,不僅每個人執行的結果不同,而且誤差還可能相當大嗎?
在正式進入主題前,先來簡單介紹一下 setTimeout()
與 setInterval()
。
setTimeout()
與 setInterval()
根據 MDN 定義 setTimeout()
的作用
是在延遲了某段時間 (單位為毫秒) 之後,才去執行「一次」指定的程式碼,並且會回傳一個獨立的 timer ID:
var timeoutID = scope.setTimeout(function[, delay, param1, param2, ...]);
var timeoutID = scope.setTimeout(function[, delay]);
var timeoutID = scope.setTimeout(code[, delay]);
如:
var timeoutID = window.setTimeout(( () => console.log("Hello!") ), 1000);
而 setInterval()
則是固定延遲了某段時間之後,才去執行對應的程式碼,然後「不斷循環」。 當然也會回傳一個獨立的 timer ID:
var intervalID = scope.setInterval(func, delay[, param1, param2, ...]);
var intervalID = scope.setInterval(code, delay);
如:
var timeoutID = window.setInterval(( () => console.log("Hello!") ), 1000);
兩者的不同之處是 setTimeout()
只會執行一次就結束,而 setInterval()
則是會在間隔固定的時間不斷重複。
由於在瀏覽器的環境宿主物件為 window
,所以 setTimeout()
與 setInterval()
的完整語法自然就是我們所熟悉的 window.setTimeout()
與 window.setInterval
了。
值得注意的是,雖然 setTimeout()
這些 timer 方法不在 ECMAScript 的規格內,而是屬於 wndow 物件的一部份,不過多數瀏覽器與 Node.js 也都有實作這些 timer 方法,根據環境不同實作也多少有些差異,像是瀏覽器的 setTimeout
/ setInterval
回傳的是 Number,而 Node.js 回傳的是 Object。
如何取消 setTimeout()
與 setInterval()
先說 setInterval()
,根據前面的範例:
var timeoutID = window.setInterval(( () => console.log("Hello!") ), 1000);
setInterval()
一啟動之後就會在固定的間隔時間不斷執行,那麼如果想要停下來,該怎麼處理呢?
這時候就需要用到 clearInterval()
來取消 setInterval()
。
上面說過,當我們呼叫 setTimeout()
與 setInterval()
的時候,它們會回傳一個獨立的 timer ID,
這個 ID 就是當我們想要取消setTimeout()
與 setInterval()
的時候作為識別的數字:
var timeoutID = window.setInterval(( () => console.log("Hello!") ), 1000);
window.clearInterval(timeoutID);
當程式執行到 clearInterval()
就會取消 setInterval()
了。
另外,與 setTimeout()
對應的就是 clearTimeout()
,用法完全一樣:
var timeoutID = window.setTimeout(( () => console.log("Hello!") ), 1000);
window.clearTimeout(timeoutID);
兩者不同的是,因為 setTimeout()
只會執行一次,所以 clearTimeout()
只會在 setTimeout()
指定的時間未到時才會有效果,
若 setTimeout()
的 callback function 已經被執行,那就等同是多餘的了。
上面是多數人熟知的 setTimeout()
與 setInterval()
。
可能有些人不知道 setTimeout()
的第一個參數是可以指定「字串」的,如:
window.setTimeout('console.log("Hello!")', 1000);
上面這段程式的執行結果與前面的範例是一樣的,但由於內部隱含 eval
的轉換,所以執行上效能會比非字串的版本差一些。
但要是不小心打成這樣:
window.setTimeout(console.log("Hello!"), 1000);
這時雖然 console 主控台仍會印出 2 ,但並不會等待 1000ms,而是會馬上執行。
這是為什麼呢?
因為 setTimeout()
會先判斷第一個參數是否為 「function」,如果不是,則會嘗試將它當作字串處理。 換句話說,會將 console.log("Hello!")
執行後的回傳值轉為字串,......欸,沒有回傳值,那就是 undefined
,
於是
window.setTimeout(console.log("Hello!"), 1000);
實際上是
window.setTimeout("undefined", 1000);
於是 1000ms 到了就什麼事都沒發生。
而 "Hello" 則是在呼叫 console.log()
的當下就會被印出。
當 setTimeout 遇到迴圈: IIFE
通常談到 setTimeout()
的時候,就不可避免會提到這個經典的範例, 題目是這樣的:
「假設想透過迴圈 +
setTimeout()
來實作,在五秒鐘之內,每秒鐘依序透過console.log()
印出: 0 1 2 3 4 。」
很多 JavaScript 的初學者可能會很直覺地寫下這樣的程式碼:
// 假設想透過迴圈 + setTimeout 來做到
// 每秒鐘將 i 的值 console 出來
for( var i = 0; i < 5; i++ ) {
window.setTimeout(function() {
console.log(i);
}, 1000);
}
但是上面這段 code 執行的結果是, console.log()
會在「一秒鐘之後」同時印出「五次 5」。
這是為什麼呢? 由於 JavaScript 切分變數有效範圍 (scope) 的最小單位為 function,所以當 1000ms 過去,
變數 i
的值早已是 for
迴圈更新完畢的 i
,而不是迴圈內當下的那個 i
。
所以我們會改用這樣的寫法來隔離變數作用域:
for( var i = 0; i < 5; i++ ) {
// 為了凸顯差異,我們將傳入後的參數改名為 x
// 當然由於 scope 的不同,要繼續在內部沿用 i 這個變數名也是可以的。
(function(x){
window.setTimeout(function() {
console.log(x);
}, 1000 * x);
})(i);
}
像這樣的做法,通常稱它為 IIFE (Immediately Invoked Function Expression), 一用就丟 立刻被呼叫、執行的 function 表達式。
或者,也可以改用 let
提供的 Block Scope 特性:
for( let i = 0; i < 5; i++ ) {
window.setTimeout(function() {
console.log(i);
}, 1000 * i);
}
就可以避開這個問題。
從 setTimeout 看同步、非同步 與 Event Loop
大多數的朋友可能都知道,JavaScript 是單一執行緒的程式語言,這代表著 JavaScript 在同一個時間點只能做一件事。 這時你可能會有疑問,既然是單一執行緒,那為什麼又有「同步」(Synchronous) 與「非同步」 (Asynchronous) 之分呢? 這兩者又有什麼區別? 而 JavaScript 這門語言究竟是「同步」還是「非同步」呢?
兩者容易搞混我覺得很大的原因出在 Synchronous 被翻譯成「同步」,光看字面上「同步」二字就可能把它想成是「所有動作同時進行」,但實際上卻剛好相反。
不囉唆,先來個範例:
console.log('start');
(function () {
console.log('call function')
window.setTimeout(function () {
console.log('setTimeout');
}, 1000);
})();
console.log('end');
各位可以猜猜看,上面這段 code 執行的結果為何?
相信聰明如你一定知道, console 主控台輸出的結果為:
start
call function
end
setTimeout
先印出 start
、 call function
然後因為 setTimeout()
等待一秒的關係,所以印了 end
後,最後才是 setTimeout
。
那麼,如果我們將 setTimeout()
的時間參數設定為 0
是否就代表會立刻執行呢:
console.log('start');
(function () {
console.log('call function')
window.setTimeout(function () {
console.log('setTimeout');
}, 0);
})();
console.log('end');
.
.
.
.
.
.
答案仍然是
start
call function
end
setTimeout
這時候我想應該開始有人會感到疑惑了。
為什麼 setTimeout()
的時間參數明明已經設定為 0
,卻還是最後才執行呢?
這正是因為 JavaScript 是單一執行緒所帶來的特性造成。
如果我們將程式要做的所有事情當作是一件一件的任務來看,那麼「一次只能做一件事」的單一執行緒就代表著任務與任務之間需要排隊, 前一個任務完成才會接著執行下一個任務。 如果目前執行的動作需要很長一段時間,那麼勢必會卡住下一個任務的執行。
另外,如果這門語言強烈遵守「一次只能做一件事」的原則,那麼在前面所提到的:
console.log('start');
(function () {
console.log('call function')
window.setTimeout(function () {
console.log('setTimeout');
}, 0);
})();
console.log('end');
裡頭的 console.log('end')
就應該要等待 setTimeout()
印出後才會執行,但是看結果顯然沒有。 而且,要是因為加上了 setTimeout()
就得延遲後面的所有任務, 在等待 timer 的過程中並沒有做任何事,也白白浪費了時間,這門程式語言的效率肯定極差無比。
幸好當初 JavaScript 的設計者沒有這麼做,而是將所有等待被執行的任務分成了兩種:「同步」(Synchronous) 與「非同步」 (Asynchronous) 。
主要執行緒指的是正在排隊等待要執行的任務,也就是圖中的 Stack:
Stack 裡面的任務有同步、也有些非同步事件,像是本文不斷提到的 setTimeout()
或者 Ajax
等等非同步的事件。
JavaScript 的執行緒會逐一執行 Stack 內的任務,當碰上了非同步事件時,為了不讓程式被這些需要等待的任務卡著,就會繼續執行後續的動作。 而當這些非同步事件的 callback function 被呼叫時,就會將 callback function 的任務丟到 Event Queue 當中,並等待目前 Stack 的任務都已經完成後,再繼續逐一執行 Event Queue 的任務。
所以再次回到範例:
console.log('start');
(function () {
console.log('call function')
window.setTimeout(function () {
console.log('setTimeout');
}, 0);
})();
console.log('end');
即便我們在 setTimeout()
的等待時間設定為 0
, 因為 JavaScript 會先將其擱置到 Queue 當中,
等待 Stack 的任務完成後,再回來執行 setTimeout()
內的 callback function。
這也就是為什麼範例中 setTimeout
總是會比 end
還要晚印出的原因了。
不僅貌似忠良的男子不可相信, setInterval 提供的時間也不可信
跟著文章看到這裡,我想你應該已經知道用 setTimeout()
與 setInterval()
拿來計算時間會有什麼問題了。
雖然 JavaScript 有著非同步事件的特色,但仍是依循單一執行緒的規則運作。 換句話說,當我們在主要執行緒內工作的時間太久,就勢必會延遲 Queue Callback 的執行。
首先要建立一個概念,就是任何操作都會有時間成本:
var startTime = new Date().getTime();
window.setTimeout(function(){
var endTime = new Date().getTime();
console.log('Time elapsed: ' + (endTime - startTime) + ' ms');
}, 1000);
像上面這段程式,雖然 setTimeout()
被指定在 1000ms 後執行,但這段 code 的執行結果為
Time elapsed: 1005 ms (依環境可能會有些微誤差)
實際執行可以發現仍然大約有 2 ~ 5 ms 的時間誤差,雖然人體感受不明顯。
但如果中間再加上一些操作
var startTime = new Date().getTime();
var count = 0;
// 用來模擬延遲 Queue 的動作
window.setInterval(function(){
var i = 0;
while(i++ < 999999) { };
}, 0);
window.setInterval(function(){
var endTime = new Date().getTime();
count++;
console.log('Time elapsed: ' + (endTime - (startTime + count * 1000)) + ' ms');
}, 1000);
實際執行的輸出為 (依執行環境有所不同) :
"Time elapsed: 108 ms"
"Time elapsed: 198 ms"
"Time elapsed: 123 ms"
"Time elapsed: 145 ms"
"Time elapsed: 179 ms"
"Time elapsed: 121 ms"
"Time elapsed: 160 ms"
(下略)
像上面這樣,即便每次執行都只有 100 ~ 200 ms 誤差,但久而久之 setInterval()
帶來的誤差就會漸漸擴大。
精準校時?
先說結論,別傻了孩子,不管你怎麼對 setTimeout()
或 setInterval()
做優化,誤差永遠都會存在。
但好消息是我們可以透過一些手法來降低誤差,像是一開始就先計算 client 的時間與 server 端的時間差,並定期與 server 端做時間同步修正,使誤差維持在最小可接受範圍內等。
如果是要執行動畫,則建議用 requestAnimationFrame 來取代 setTimeout()
或 setInterval()
。 比起傳統的 setTimeout()
或 setInterval()
, requestAnimationFrame
的執行效能會好上許多。