相信接觸過 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, heap, queue 圖片來源: MDN - Event Loop

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