相信接觸過 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

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