2022 年 10 月 3 日

排程:setTimeout 和 setInterval

我們可以決定現在不執行函式,而是在稍後的特定時間執行。這稱為「排定呼叫」。

有兩種方法可以做到這一點

  • setTimeout 允許我們在時間間隔後執行函式一次。
  • setInterval 允許我們重複執行函式,從時間間隔後開始,然後在該間隔內持續重複執行。

這些方法並非 JavaScript 規格的一部分。但大多數環境都有內部排程器,並提供這些方法。特別是,它們在所有瀏覽器和 Node.js 中都受支援。

setTimeout

語法

let timerId = setTimeout(func|code, [delay], [arg1], [arg2], ...)

參數

func|code
要執行的函式或程式碼字串。通常是函式。基於歷史原因,可以傳遞程式碼字串,但建議不要這麼做。
delay
執行前的延遲時間,以毫秒為單位(1000 毫秒 = 1 秒),預設為 0。
arg1arg2
函式的引數

例如,這段程式碼會在 1 秒後呼叫 sayHi()

function sayHi() {
  alert('Hello');
}

setTimeout(sayHi, 1000);

帶有引數

function sayHi(phrase, who) {
  alert( phrase + ', ' + who );
}

setTimeout(sayHi, 1000, "Hello", "John"); // Hello, John

如果第一個引數是字串,JavaScript 會從中建立函式。

因此,這段程式碼也會執行

setTimeout("alert('Hello')", 1000);

但建議不要使用字串,改用箭頭函式,如下所示

setTimeout(() => alert('Hello'), 1000);
傳遞函式,但不要執行

新手開發人員有時會在函式後加上括號 (),這是一個錯誤

// wrong!
setTimeout(sayHi(), 1000);

這不會執行,因為 setTimeout 預期的是函式參考。而這裡的 sayHi() 會執行函式,而其執行結果會傳遞給 setTimeout。在我們的案例中,sayHi() 的結果是 undefined(函式沒有傳回任何東西),因此不會排程任何事情。

使用 clearTimeout 取消

呼叫 setTimeout 會傳回「計時器識別碼」timerId,我們可以使用它來取消執行。

取消語法

let timerId = setTimeout(...);
clearTimeout(timerId);

在以下程式碼中,我們排程函式,然後取消它(改變心意)。因此,不會發生任何事情

let timerId = setTimeout(() => alert("never happens"), 1000);
alert(timerId); // timer identifier

clearTimeout(timerId);
alert(timerId); // same identifier (doesn't become null after canceling)

alert 輸出中可以看到,在瀏覽器中,計時器識別碼是一個數字。在其他環境中,這可能是其他東西。例如,Node.js 會傳回具有其他方法的計時器物件。

同樣地,這些方法沒有通用規格,因此沒關係。

對於瀏覽器,計時器在 HTML Living Standard 的計時器區段中說明。

setInterval

setInterval 方法的語法與 setTimeout 相同

let timerId = setInterval(func|code, [delay], [arg1], [arg2], ...)

所有引數的意義都相同。但與 setTimeout 不同的是,它不僅會執行函式一次,還會在給定的時間間隔後定期執行。

若要停止後續呼叫,我們應該呼叫 clearInterval(timerId)

以下範例會每 2 秒顯示訊息。5 秒後,輸出會停止

// repeat with the interval of 2 seconds
let timerId = setInterval(() => alert('tick'), 2000);

// after 5 seconds stop
setTimeout(() => { clearInterval(timerId); alert('stop'); }, 5000);
顯示 alert 時,時間會繼續進行

在包括 Chrome 和 Firefox 在內的大多數瀏覽器中,內部計時器在顯示 alert/confirm/prompt 時會持續「滴答作響」。

因此,如果您執行上述程式碼,並且一段時間內不關閉 alert 視窗,那麼當您關閉時,下一個 alert 會立即顯示。警示之間的實際間隔將短於 2 秒。

巢狀 setTimeout

有兩種方式可以定期執行某件事。

一種是 setInterval。另一種是巢狀 setTimeout,如下所示

/** instead of:
let timerId = setInterval(() => alert('tick'), 2000);
*/

let timerId = setTimeout(function tick() {
  alert('tick');
  timerId = setTimeout(tick, 2000); // (*)
}, 2000);

上述的 setTimeout 會在目前呼叫的最後 (*) 安排下一次呼叫。

巢狀 setTimeout 是一種比 setInterval 更靈活的方法。這樣一來,下一次呼叫可以根據目前呼叫的結果安排成不同時間。

例如,我們需要寫一個服務,每 5 秒向伺服器傳送一個請求以取得資料,但如果伺服器過載,則應將間隔時間增加到 10、20、40 秒……

以下是偽程式碼

let delay = 5000;

let timerId = setTimeout(function request() {
  ...send request...

  if (request failed due to server overload) {
    // increase the interval to the next run
    delay *= 2;
  }

  timerId = setTimeout(request, delay);

}, delay);

如果我們安排的函式會大量使用 CPU,則可以測量執行時間,並提早或延後安排下一次呼叫。

巢狀 setTimeout 允許將執行之間的延遲設定得比 setInterval 更精確。

我們來比較兩個程式碼片段。第一個使用 setInterval

let i = 1;
setInterval(function() {
  func(i++);
}, 100);

第二個使用巢狀 setTimeout

let i = 1;
setTimeout(function run() {
  func(i++);
  setTimeout(run, 100);
}, 100);

對於 setInterval,內部排程器會每 100 毫秒執行 func(i++)

你注意到了嗎?

對於 setIntervalfunc 呼叫之間的實際延遲小於程式碼中的延遲!

這是正常的,因為 func 執行的時間會「消耗」一部分的間隔時間。

有可能 func 的執行時間比我們預期的長,並超過 100 毫秒。

在這種情況下,引擎會等到 func 完成,然後檢查排程器,如果時間到了,就會立即再次執行。

在極端情況下,如果函式總是執行超過 delay 毫秒,則呼叫將會完全沒有暫停地發生。

以下是巢狀 setTimeout 的圖示

巢狀 setTimeout 保證固定的延遲(在此為 100 毫秒)。

這是因為會在前一次呼叫結束時安排新的呼叫。

垃圾回收和 setInterval/setTimeout 回呼

當函式傳遞到 setInterval/setTimeout 時,會建立一個內部參考並儲存在排程器中。即使沒有其他參考,它也會防止函式被垃圾回收。

// the function stays in memory until the scheduler calls it
setTimeout(function() {...}, 100);

對於 setInterval,函式會保留在記憶體中,直到呼叫 clearInterval 為止。

有一個副作用。函式會參照外部詞彙環境,因此,只要函式存在,外部變數也會存在。它們可能比函式本身佔用更多記憶體。因此,當我們不再需要排程函式時,最好取消它,即使它很小。

零延遲 setTimeout

有一個特殊的使用案例:setTimeout(func, 0),或僅為 setTimeout(func)

這會盡快排程執行 func。但排程器只會在目前執行的指令碼完成後呼叫它。

因此,函式會排程在目前指令碼「之後」執行。

例如,這會輸出「Hello」,然後立即輸出「World」

setTimeout(() => alert("World"));

alert("Hello");

第一行「在 0 毫秒後將呼叫放入行事曆」。但排程器只會在目前指令碼完成後「檢查行事曆」,因此 "Hello" 會先輸出,"World" 會在它之後輸出。

還有進階的與瀏覽器相關的零延遲逾時使用案例,我們會在章節 事件迴圈:微任務和巨集任務 中討論。

零延遲實際上不是零(在瀏覽器中)

在瀏覽器中,巢狀計時器可以執行的頻率有限制。HTML Living Standard 指出:「在五個巢狀計時器之後,間隔會強制設定為至少 4 毫秒。」

讓我們透過以下範例來說明它的意思。其中的 setTimeout 呼叫會以零延遲重新排程它自己。每個呼叫會在 times 陣列中記住前一個呼叫的實際時間。實際延遲是什麼?讓我們看看

let start = Date.now();
let times = [];

setTimeout(function run() {
  times.push(Date.now() - start); // remember delay from the previous call

  if (start + 100 < Date.now()) alert(times); // show the delays after 100ms
  else setTimeout(run); // else re-schedule
});

// an example of the output:
// 1,1,1,1,9,15,20,24,30,35,40,45,50,55,59,64,70,75,80,85,90,95,100

第一個計時器會立即執行(正如規範中所寫),然後我們會看到 9, 15, 20, 24...。呼叫之間強制性的 4+ 毫秒延遲會發揮作用。

如果我們使用 setInterval 取代 setTimeout,也會發生類似的事情:setInterval(f) 會以零延遲執行 f 幾次,然後再以 4+ 毫秒延遲執行。

這種限制來自於遠古時代,許多指令碼依賴於它,因此它存在於歷史原因。

對於伺服器端 JavaScript,這種限制不存在,而且還有其他方法可以排程立即非同步工作,例如 Node.js 的 setImmediate。因此,此註解是瀏覽器專用的。

摘要

  • 方法 setTimeout(func, delay, ...args)setInterval(func, delay, ...args) 讓我們可以在 delay 毫秒後執行 func 一次/定期。
  • 若要取消執行,我們應該使用 setTimeout/setInterval 傳回的值呼叫 clearTimeout/clearInterval
  • 巢狀 setTimeout 呼叫是 setInterval 更靈活的替代方案,讓我們可以更精確地設定執行之間的時間。
  • 使用 setTimeout(func, 0)(與 setTimeout(func) 相同)進行零延遲排程,用於排程「盡快呼叫,但在目前指令碼完成後」。
  • 瀏覽器會將五次或更多次巢狀呼叫 setTimeoutsetInterval(在第五次呼叫後)的最小延遲限制為 4 毫秒。這是基於歷史原因。

請注意,所有排程方法都不會保證確切的延遲。

例如,瀏覽器計時器可能會因為很多原因而變慢

  • CPU 過載。
  • 瀏覽器標籤處於背景模式。
  • 筆電處於省電模式。

所有這些都可能會將最小計時器解析度(最小延遲)增加到 300 毫秒,甚至 1000 毫秒,具體取決於瀏覽器和作業系統層級效能設定。

工作

重要性:5

撰寫一個函式 printNumbers(from, to),從 from 開始並以 to 結束,每秒輸出一個數字。

製作兩個解決方案變體。

  1. 使用 setInterval
  2. 使用巢狀 setTimeout

使用 setInterval

function printNumbers(from, to) {
  let current = from;

  let timerId = setInterval(function() {
    alert(current);
    if (current == to) {
      clearInterval(timerId);
    }
    current++;
  }, 1000);
}

// usage:
printNumbers(5, 10);

使用巢狀 setTimeout

function printNumbers(from, to) {
  let current = from;

  setTimeout(function go() {
    alert(current);
    if (current < to) {
      setTimeout(go, 1000);
    }
    current++;
  }, 1000);
}

// usage:
printNumbers(5, 10);

請注意,在兩個解決方案中,在第一次輸出之前都有初始延遲。函式在第一次呼叫後 1000 毫秒 才會呼叫。

如果我們也希望函式立即執行,則可以在獨立的行中新增一個額外的呼叫,如下所示

function printNumbers(from, to) {
  let current = from;

  function go() {
    alert(current);
    if (current == to) {
      clearInterval(timerId);
    }
    current++;
  }

  go();
  let timerId = setInterval(go, 1000);
}

printNumbers(5, 10);
重要性:5

在以下程式碼中,排程了 setTimeout 呼叫,然後執行一個繁重的計算,完成需要超過 100 毫秒的時間。

排定的函式何時會執行?

  1. 迴圈之後。
  2. 迴圈之前。
  3. 迴圈開始時。

alert 會顯示什麼?

let i = 0;

setTimeout(() => alert(i), 100); // ?

// assume that the time to execute this function is >100ms
for(let j = 0; j < 100000000; j++) {
  i++;
}

任何 setTimeout 都會在目前程式碼執行完畢後才執行。

i 會是最後一個:100000000

let i = 0;

setTimeout(() => alert(i), 100); // 100000000

// assume that the time to execute this function is >100ms
for(let j = 0; j < 100000000; j++) {
  i++;
}
教學課程地圖

留言

留言前請先閱讀…
  • 如果您有改善建議,請 提交 GitHub 問題 或提交 pull request,不要留言。
  • 如果您看不懂文章中的某個部分,請詳細說明。
  • 要插入幾行程式碼,請使用 <code> 標籤,要插入多行程式碼,請將它們包在 <pre> 標籤中,要插入 10 行以上的程式碼,請使用沙盒 (plnkrjsbincodepen…)