Javascript 調(diào)度:setTimeout 和 setInterval

2023-02-17 10:50 更新

有時(shí)我們并不想立即執(zhí)行一個(gè)函數(shù),而是等待特定一段時(shí)間之后再執(zhí)行。這就是所謂的“計(jì)劃調(diào)用(scheduling a call)”。

目前有兩種方式可以實(shí)現(xiàn):

  • ?setTimeout? 允許我們將函數(shù)推遲到一段時(shí)間間隔之后再執(zhí)行。
  • ?setInterval? 允許我們重復(fù)運(yùn)行一個(gè)函數(shù),從一段時(shí)間間隔之后開始運(yùn)行,之后以該時(shí)間間隔連續(xù)重復(fù)運(yùn)行該函數(shù)。

這兩個(gè)方法并不在 JavaScript 的規(guī)范中。但是大多數(shù)運(yùn)行環(huán)境都有內(nèi)建的調(diào)度程序,并且提供了這些方法。目前來講,所有瀏覽器以及 Node.js 都支持這兩個(gè)方法。

setTimeout

語法:

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

參數(shù)說明:

?func|code ?

想要執(zhí)行的函數(shù)或代碼字符串。 一般傳入的都是函數(shù)。由于某些歷史原因,支持傳入代碼字符串,但是不建議這樣做。

?delay ?

執(zhí)行前的延時(shí),以毫秒為單位(1000 毫秒 = 1 秒),默認(rèn)值是 0;

?arg1,arg2… ?

要傳入被執(zhí)行函數(shù)(或代碼字符串)的參數(shù)列表(IE9 以下不支持)

例如,在下面這個(gè)示例中,?sayHi()? 方法會(huì)在 1 秒后執(zhí)行:

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

setTimeout(sayHi, 1000);

帶參數(shù)的情況:

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

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

如果第一個(gè)參數(shù)位傳入的是字符串,JavaScript 會(huì)自動(dòng)為其創(chuàng)建一個(gè)函數(shù)。

所以這么寫也是可以的:

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

但是,不建議使用字符串,我們可以使用箭頭函數(shù)代替它們,如下所示:

setTimeout(() => alert('Hello'), 1000);

傳入一個(gè)函數(shù),但不要執(zhí)行它

新手開發(fā)者有時(shí)候會(huì)誤將一對括號 () 加在函數(shù)后面:

// 錯(cuò)的!
setTimeout(sayHi(), 1000);

這樣不行,因?yàn)?nbsp;setTimeout 期望得到一個(gè)對函數(shù)的引用。而這里的 sayHi() 很明顯是在執(zhí)行函數(shù),所以實(shí)際上傳入 setTimeout 的是 函數(shù)的執(zhí)行結(jié)果。在這個(gè)例子中,sayHi() 的執(zhí)行結(jié)果是 undefined(也就是說函數(shù)沒有返回任何結(jié)果),所以實(shí)際上什么也沒有調(diào)度。

用 clearTimeout 來取消調(diào)度

setTimeout 在調(diào)用時(shí)會(huì)返回一個(gè)“定時(shí)器標(biāo)識符(timer identifier)”,在我們的例子中是 timerId,我們可以使用它來取消執(zhí)行。

取消調(diào)度的語法:

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

在下面的代碼中,我們對一個(gè)函數(shù)進(jìn)行了調(diào)度,緊接著取消了這次調(diào)度(中途反悔了)。所以最后什么也沒發(fā)生:

let timerId = setTimeout(() => alert("never happens"), 1000);
alert(timerId); // 定時(shí)器標(biāo)識符

clearTimeout(timerId);
alert(timerId); // 還是這個(gè)標(biāo)識符(并沒有因?yàn)檎{(diào)度被取消了而變成 null)

從 alert 的輸出來看,在瀏覽器中,定時(shí)器標(biāo)識符是一個(gè)數(shù)字。在其他環(huán)境中,可能是其他的東西。例如 Node.js 返回的是一個(gè)定時(shí)器對象,這個(gè)對象包含一系列方法。

我再重申一遍,這些方法沒有統(tǒng)一的規(guī)范定義,所以這沒什么問題。

針對瀏覽器環(huán)境,定時(shí)器在 HTML5 的標(biāo)準(zhǔn)中有詳細(xì)描述,詳見 timers section。

setInterval

setInterval 方法和 setTimeout 的語法相同:

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

所有參數(shù)的意義也是相同的。不過與 setTimeout 只執(zhí)行一次不同,setInterval 是每間隔給定的時(shí)間周期性執(zhí)行。

想要阻止后續(xù)調(diào)用,我們需要調(diào)用 clearInterval(timerId)。

下面的例子將每間隔 2 秒就會(huì)輸出一條消息。5 秒之后,輸出停止:

// 每 2 秒重復(fù)一次
let timerId = setInterval(() => alert('tick'), 2000);

// 5 秒之后停止
setTimeout(() => { clearInterval(timerId); alert('stop'); }, 5000);

alert 彈窗顯示的時(shí)候計(jì)時(shí)器依然在進(jìn)行計(jì)時(shí)

在大多數(shù)瀏覽器中,包括 Chrome 和 Firefox,在顯示 alert/confirm/prompt 彈窗時(shí),內(nèi)部的定時(shí)器仍舊會(huì)繼續(xù)“嘀嗒”。

所以,在運(yùn)行上面的代碼時(shí),如果在一定時(shí)間內(nèi)沒有關(guān)掉 alert 彈窗,那么在你關(guān)閉彈窗后,下一個(gè) alert 會(huì)立即顯示。兩次 alert 之間的時(shí)間間隔將小于 2 秒。

嵌套的 setTimeout

周期性調(diào)度有兩種方式。

一種是使用 ?setInterval?,另外一種就是嵌套的 ?setTimeout?,就像這樣:

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

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

上面這個(gè) setTimeout 在當(dāng)前這一次函數(shù)執(zhí)行完時(shí) (*) 立即調(diào)度下一次調(diào)用。

嵌套的 setTimeout 要比 setInterval 靈活得多。采用這種方式可以根據(jù)當(dāng)前執(zhí)行結(jié)果來調(diào)度下一次調(diào)用,因此下一次調(diào)用可以與當(dāng)前這一次不同。

例如,我們要實(shí)現(xiàn)一個(gè)服務(wù)(server),每間隔 5 秒向服務(wù)器發(fā)送一個(gè)數(shù)據(jù)請求,但如果服務(wù)器過載了,那么就要降低請求頻率,比如將間隔增加到 10、20、40 秒等。

以下是偽代碼:

let delay = 5000;

let timerId = setTimeout(function request() {
  ...發(fā)送請求...

  if (request failed due to server overload) {
    // 下一次執(zhí)行的間隔是當(dāng)前的 2 倍
    delay *= 2;
  }

  timerId = setTimeout(request, delay);

}, delay);

并且,如果我們調(diào)度的函數(shù)占用大量的 CPU,那么我們可以測量執(zhí)行所需要花費(fèi)的時(shí)間,并安排下次調(diào)用是應(yīng)該提前還是推遲。

嵌套的 setTimeout 相較于 setInterval 能夠更精確地設(shè)置兩次執(zhí)行之間的延時(shí)。

下面來比較這兩個(gè)代碼片段。第一個(gè)使用的是 setInterval

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

第二個(gè)使用的是嵌套的 setTimeout

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

對 setInterval 而言,內(nèi)部的調(diào)度程序會(huì)每間隔 100 毫秒執(zhí)行一次 func(i++)


注意到了嗎?

使用 setInterval 時(shí),func 函數(shù)的實(shí)際調(diào)用間隔要比代碼中設(shè)定的時(shí)間間隔要短!

這也是正常的,因?yàn)?nbsp;func 的執(zhí)行所花費(fèi)的時(shí)間“消耗”了一部分間隔時(shí)間。

也可能出現(xiàn)這種情況,就是 func 的執(zhí)行所花費(fèi)的時(shí)間比我們預(yù)期的時(shí)間更長,并且超出了 100 毫秒。

在這種情況下,JavaScript 引擎會(huì)等待 func 執(zhí)行完成,然后檢查調(diào)度程序,如果時(shí)間到了,則 立即 再次執(zhí)行它。

極端情況下,如果函數(shù)每次執(zhí)行時(shí)間都超過 delay 設(shè)置的時(shí)間,那么每次調(diào)用之間將完全沒有停頓。

這是嵌套的 setTimeout 的示意圖:


嵌套的 setTimeout 就能確保延時(shí)的固定(這里是 100 毫秒)。

這是因?yàn)橄乱淮握{(diào)用是在前一次調(diào)用完成時(shí)再調(diào)度的。

垃圾回收和 setInterval/setTimeout 回調(diào)(callback)

當(dāng)一個(gè)函數(shù)傳入 setInterval/setTimeout 時(shí),將為其創(chuàng)建一個(gè)內(nèi)部引用,并保存在調(diào)度程序中。這樣,即使這個(gè)函數(shù)沒有其他引用,也能防止垃圾回收器(GC)將其回收。

// 在調(diào)度程序調(diào)用這個(gè)函數(shù)之前,這個(gè)函數(shù)將一直存在于內(nèi)存中
setTimeout(function() {...}, 100);

對于 setInterval,傳入的函數(shù)也是一直存在于內(nèi)存中,直到 clearInterval 被調(diào)用。

這里還要提到一個(gè)副作用。如果函數(shù)引用了外部變量(譯注:閉包),那么只要這個(gè)函數(shù)還存在,外部變量也會(huì)隨之存在。它們可能比函數(shù)本身占用更多的內(nèi)存。因此,當(dāng)我們不再需要調(diào)度函數(shù)時(shí),最好取消它,即使這是個(gè)(占用內(nèi)存)很小的函數(shù)。

零延時(shí)的 setTimeout

這兒有一種特殊的用法:setTimeout(func, 0),或者僅僅是 setTimeout(func)。

這樣調(diào)度可以讓 func 盡快執(zhí)行。但是只有在當(dāng)前正在執(zhí)行的腳本執(zhí)行完成后,調(diào)度程序才會(huì)調(diào)用它。

也就是說,該函數(shù)被調(diào)度在當(dāng)前腳本執(zhí)行完成“之后”立即執(zhí)行。

例如,下面這段代碼會(huì)先輸出 “Hello”,然后立即輸出 “World”:

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

alert("Hello");

第一行代碼“將調(diào)用安排到日程(calendar)0 毫秒處”。但是調(diào)度程序只有在當(dāng)前腳本執(zhí)行完畢時(shí)才會(huì)去“檢查日程”,所以先輸出 "Hello",然后才輸出 "World"。

此外,還有與瀏覽器相關(guān)的 0 延時(shí) timeout 的高級用例,我們將在 事件循環(huán):微任務(wù)和宏任務(wù) 一章中詳細(xì)講解。

零延時(shí)實(shí)際上不為零(在瀏覽器中)

在瀏覽器環(huán)境下,嵌套定時(shí)器的運(yùn)行頻率是受限制的。根據(jù) HTML5 標(biāo)準(zhǔn) 所講:“經(jīng)過 5 重嵌套定時(shí)器之后,時(shí)間間隔被強(qiáng)制設(shè)定為至少 4 毫秒”。

讓我們用下面的示例來看看這到底是什么意思。其中 setTimeout 調(diào)用會(huì)以零延時(shí)重新調(diào)度自身的調(diào)用。每次調(diào)用都會(huì)在 times 數(shù)組中記錄上一次調(diào)用的實(shí)際時(shí)間。那么真正的延遲是什么樣的?讓我們來看看:

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

setTimeout(function run() {
  times.push(Date.now() - start); // 保存前一個(gè)調(diào)用的延時(shí)

  if (start + 100 < Date.now()) alert(times); // 100 毫秒之后,顯示延時(shí)信息
  else setTimeout(run); // 否則重新調(diào)度
});

// 輸出示例:
// 1,1,1,1,9,15,20,24,30,35,40,45,50,55,59,64,70,75,80,85,90,95,100

第一次,定時(shí)器是立即執(zhí)行的(正如規(guī)范里所描述的那樣),接下來我們可以看到 9, 15, 20, 24...。兩次調(diào)用之間必須經(jīng)過 4 毫秒以上的強(qiáng)制延時(shí)。(譯注:這里作者沒說清楚,timer 數(shù)組里存放的是每次定時(shí)器運(yùn)行的時(shí)刻與 start 的差值,所以數(shù)字只會(huì)越來越大,實(shí)際上前后調(diào)用的延時(shí)是數(shù)組值的差值。示例中前幾次都是 1,所以延時(shí)為 0)

如果我們使用 setInterval 而不是 setTimeout,也會(huì)發(fā)生類似的情況:setInterval(f) 會(huì)以零延時(shí)運(yùn)行幾次 f,然后以 4 毫秒以上的強(qiáng)制延時(shí)運(yùn)行。

這個(gè)限制來自“遠(yuǎn)古時(shí)代”,并且許多腳本都依賴于此,所以這個(gè)機(jī)制也就存在至今。

對于服務(wù)端的 JavaScript,就沒有這個(gè)限制,并且還有其他調(diào)度即時(shí)異步任務(wù)的方式。例如 Node.js 的 setImmediate。因此,這個(gè)提醒只是針對瀏覽器環(huán)境的。

總結(jié)

  • ?setTimeout(func, delay, ...args)? 和 ?setInterval(func, delay, ...args)? 方法允許我們在 ?delay? 毫秒之后運(yùn)行 ?func? 一次或以 ?delay? 毫秒為時(shí)間間隔周期性運(yùn)行 ?func?。
  • 要取消函數(shù)的執(zhí)行,我們應(yīng)該調(diào)用 ?clearInterval/clearTimeout?,并將 ?setInterval/setTimeout? 返回的值作為入?yún)魅搿?/li>
  • 嵌套的 ?setTimeout? 比 ?setInterval? 用起來更加靈活,允許我們更精確地設(shè)置兩次執(zhí)行之間的時(shí)間。
  • 零延時(shí)調(diào)度 ?setTimeout(func, 0)?(與 ?setTimeout(func)? 相同)用來調(diào)度需要盡快執(zhí)行的調(diào)用,但是會(huì)在當(dāng)前腳本執(zhí)行完成后進(jìn)行調(diào)用。
  • 瀏覽器會(huì)將 ?setTimeout? 或 ?setInterval? 的五層或更多層嵌套調(diào)用(調(diào)用五次之后)的最小延時(shí)限制在 4ms。這是歷史遺留問題。

請注意,所有的調(diào)度方法都不能 保證 確切的延時(shí)。

例如,瀏覽器內(nèi)的計(jì)時(shí)器可能由于許多原因而變慢:

  • CPU 過載。
  • 瀏覽器頁簽處于后臺(tái)模式。
  • 筆記本電腦用的是省電模式。

所有這些因素,可能會(huì)將定時(shí)器的最小計(jì)時(shí)器分辨率(最小延遲)增加到 300ms 甚至 1000ms,具體以瀏覽器及其設(shè)置為準(zhǔn)。

任務(wù)


每秒輸出一次

重要程度: 5

編寫一個(gè)函數(shù) ?printNumbers(from, to)?,使其每秒輸出一個(gè)數(shù)字,數(shù)字從 ?from? 開始,到 ?to? 結(jié)束。

使用以下兩種方法來實(shí)現(xiàn)。

  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);
}

// 用例:
printNumbers(5, 10);

使用嵌套的 setTimeout

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

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

// 用例:
printNumbers(5, 10);

請注意,在這兩種解決方案中,在第一個(gè)輸出之前都有一個(gè)初始延遲。函數(shù)在 1000ms 之后才被第一次調(diào)用。

如果我們還希望函數(shù)立即運(yùn)行,那么我們可以在單獨(dú)的一行上添加一個(gè)額外的調(diào)用,像這樣:

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);

setTimeout 會(huì)顯示什么?

重要程度: 5

下面代碼中使用 setTimeout 調(diào)度了一個(gè)調(diào)用,然后需要運(yùn)行一個(gè)計(jì)算量很大的 for 循環(huán),這段運(yùn)算耗時(shí)超過 100 毫秒。

調(diào)度的函數(shù)會(huì)在何時(shí)運(yùn)行?

  1. 循環(huán)執(zhí)行完成后。
  2. 循環(huán)執(zhí)行前。
  3. 循環(huán)剛開始時(shí)。

?alert? 會(huì)顯示什么?

let i = 0;

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

// 假設(shè)這段代碼的運(yùn)行時(shí)間 >100ms
for(let j = 0; j < 100000000; j++) {
  i++;
}

解決方案

任何 ?setTimeout? 都只會(huì)在當(dāng)前代碼執(zhí)行完畢之后才會(huì)執(zhí)行。

所以 ?i? 的取值為:?100000000?。

let i = 0;

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

// 假設(shè)這段代碼的運(yùn)行時(shí)間 >100ms
for(let j = 0; j < 100000000; j++) {
  i++;
}


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號