談談 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 的執行效能會好上許多。



