有時我們並不想立即執行壹個函數,而是等待特定壹段時間之後再執行。這就是所謂的“計劃調用(scheduling a call)”。
目前有兩種方式可以實現:
setTimeout
允許我們將函數推遲到壹段時間間隔之後再執行。
setInterval
允許我們重複運行壹個函數,從壹段時間間隔之後開始運行,之後以該時間間隔連續重複運行該函數。
這兩個方法並不在 JavaScript 的規範中。但是大多數運行環境都有內建的調度程序,並且提供了這些方法。目前來講,所有浏覽器以及 Node.js 都支持這兩個方法。
語法:
let timerId = setTimeout(func|code, [delay], [arg1], [arg2], ...)
參數說明:
func|code
想要執行的函數或代碼字符串。 壹般傳入的都是函數。由于某些曆史原因,支持傳入代碼字符串,但是不建議這樣做。
delay
執行前的延時,以毫秒爲單位(1000 毫秒 = 1 秒),默認值是 0;
arg1
,arg2
…
要傳入被執行函數(或代碼字符串)的參數列表(IE9 以下不支持)
例如,在下面這個示例中,sayHi()
方法會在 1 秒後執行:
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);
傳入壹個函數,但不要執行它
新手開發者有時候會誤將壹對括號 ()
加在函數後面:
// 錯的! setTimeout(sayHi(), 1000);
這樣不行,因爲 setTimeout
期望得到壹個對函數的引用。而這裏的 sayHi()
很明顯是在執行函數,所以實際上傳入 setTimeout
的是 函數的執行結果。在這個例子中,sayHi()
的執行結果是 undefined
(也就是說函數沒有返回任何結果),所以實際上什麽也沒有調度。
setTimeout
在調用時會返回壹個“定時器標識符(timer identifier)”,在我們的例子中是 timerId
,我們可以使用它來取消執行。
取消調度的語法:
let timerId = setTimeout(...); clearTimeout(timerId);
在下面的代碼中,我們對壹個函數進行了調度,緊接著取消了這次調度(中途反悔了)。所以最後什麽也沒發生:
let timerId = setTimeout(() => alert("never happens"), 1000); alert(timerId); // 定時器標識符 clearTimeout(timerId); alert(timerId); // 還是這個標識符(並沒有因爲調度被取消了而變成 null)
從 alert
的輸出來看,在浏覽器中,定時器標識符是壹個數字。在其他環境中,可能是其他的東西。例如 Node.js 返回的是壹個定時器對象,這個對象包含壹系列方法。
我再重申壹遍,這些方法沒有統壹的規範定義,所以這沒什麽問題。
針對浏覽器環境,定時器在 HTML5 的標准中有詳細描述,詳見 timers section。
setInterval
方法和 setTimeout
的語法相同:
let timerId = setInterval(func|code, [delay], [arg1], [arg2], ...)
所有參數的意義也是相同的。不過與 setTimeout
只執行壹次不同,setInterval
是每間隔給定的時間周期性執行。
想要阻止後續調用,我們需要調用 clearInterval(timerId)
。
下面的例子將每間隔 2 秒就會輸出壹條消息。5 秒之後,輸出停止:
// 每 2 秒重複壹次 let timerId = setInterval(() => alert('tick'), 2000); // 5 秒之後停止 setTimeout(() => { clearInterval(timerId); alert('stop'); }, 5000);
alert 彈窗顯示的時候計時器依然在進行計時
在大多數浏覽器中,包括 Chrome 和 Firefox,在顯示 alert/confirm/prompt
彈窗時,內部的定時器仍舊會繼續“滴塔”。
所以,在運行上面的代碼時,如果在壹定時間內沒有關掉 alert
彈窗,那麽在妳關閉彈窗後,下壹個 alert
會立即顯示。兩次 alert
之間的時間間隔將小于 2 秒。
周期性調度有兩種方式。
壹種是使用 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
靈活得多。采用這種方式可以根據當前執行結果來調度下壹次調用,因此下壹次調用可以與當前這壹次不同。
例如,我們要實現壹個服務(server),每間隔 5 秒向服務器發送壹個數據請求,但如果服務器過載了,那麽就要降低請求頻率,比如將間隔增加到 10、20、40 秒等。
以下是僞代碼:
let delay = 5000; let timerId = setTimeout(function request() { ...發送請求... if (request failed due to server overload) { // 下壹次執行的間隔是當前的 2 倍 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++)
:
注意到了嗎?
使用 setInterval
時,func
函數的實際調用間隔要比代碼中設定的時間間隔要短!
這也是正常的,因爲 func
的執行所花費的時間“消耗”了壹部分間隔時間。
也可能出現這種情況,就是 func
的執行所花費的時間比我們預期的時間更長,並且超出了 100 毫秒。
在這種情況下,JavaScript 引擎會等待 func
執行完成,然後檢查調度程序,如果時間到了,則 立即 再次執行它。
極端情況下,如果函數每次執行時間都超過 delay
設置的時間,那麽每次調用之間將完全沒有停頓。
這是嵌套的 setTimeout
的示意圖:
嵌套的 setTimeout
就能確保延時的固定(這裏是 100 毫秒)。
這是因爲下壹次調用是在前壹次調用完成時再調度的。
垃圾回收和 setInterval/setTimeout 回調(callback)
當壹個函數傳入 setInterval/setTimeout
時,將爲其創建壹個內部引用,並保存在調度程序中。這樣,即使這個函數沒有其他引用,也能防止垃圾回收器(GC)將其回收。
// 在調度程序調用這個函數之前,這個函數將壹直存在于內存中 setTimeout(function() {...}, 100);
對于 setInterval
,傳入的函數也是壹直存在于內存中,直到 clearInterval
被調用。
這裏還要提到壹個副作用。如果函數引用了外部變量(譯注:閉包),那麽只要這個函數還存在,外部變量也會隨之存在。它們可能比函數本身占用更多的內存。因此,當我們不再需要調度函數時,最好取消它,即使這是個(占用內存)很小的函數。
這兒有壹種特殊的用法:setTimeout(func, 0)
,或者僅僅是 setTimeout(func)
。
這樣調度可以讓 func
盡快執行。但是只有在當前正在執行的腳本執行完成後,調度程序才會調用它。
也就是說,該函數被調度在當前腳本執行完成“之後”立即執行。
例如,下面這段代碼會先輸出 “Hello”,然後立即輸出 “World”:
setTimeout(() => alert("World")); alert("Hello");
第壹行代碼“將調用安排到日程(calendar)0 毫秒處”。但是調度程序只有在當前腳本執行完畢時才會去“檢查日程”,所以先輸出 "Hello"
,然後才輸出 "World"
。
此外,還有與浏覽器相關的 0 延時 timeout 的高級用例,我們將在 事件循環:微任務和宏任務 壹章中詳細講解。
零延時實際上不爲零(在浏覽器中)
在浏覽器環境下,嵌套定時器的運行頻率是受限制的。根據 HTML5 標准 所講:“經過 5 重嵌套定時器之後,時間間隔被強制設定爲至少 4 毫秒”。
讓我們用下面的示例來看看這到底是什麽意思。其中 setTimeout
調用會以零延時重新調度自身的調用。每次調用都會在 times
數組中記錄上壹次調用的實際時間。那麽真正的延遲是什麽樣的?讓我們來看看:
let start = Date.now(); let times = []; setTimeout(function run() { times.push(Date.now() - start); // 保存前壹個調用的延時 if (start + 100 < Date.now()) alert(times); // 100 毫秒之後,顯示延時信息 else setTimeout(run); // 否則重新調度 }); // 輸出示例: // 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 毫秒以上的強制延時。(譯注:這裏作者沒說清楚,timer 數組裏存放的是每次定時器運行的時刻與 start 的差值,所以數字只會越來越大,實際上前後調用的延時是數組值的差值。示例中前幾次都是 1,所以延時爲 0)
如果我們使用 setInterval
而不是 setTimeout
,也會發生類似的情況:setInterval(f)
會以零延時運行幾次 f
,然後以 4 毫秒以上的強制延時運行。
這個限制來自“遠古時代”,並且許多腳本都依賴于此,所以這個機制也就存在至今。
對于服務端的 JavaScript,就沒有這個限制,並且還有其他調度即時異步任務的方式。例如 Node.js 的 setImmediate。因此,這個提醒只是針對浏覽器環境的。
setTimeout(func, delay, ...args)
和 setInterval(func, delay, ...args)
方法允許我們在 delay
毫秒之後運行 func
壹次或以 delay
毫秒爲時間間隔周期性運行 func
。
要取消函數的執行,我們應該調用 clearInterval/clearTimeout
,並將 setInterval/setTimeout
返回的值作爲入參傳入。
嵌套的 setTimeout
比 setInterval
用起來更加靈活,允許我們更精確地設置兩次執行之間的時間。
零延時調度 setTimeout(func, 0)
(與 setTimeout(func)
相同)用來調度需要盡快執行的調用,但是會在當前腳本執行完成後進行調用。
浏覽器會將 setTimeout
或 setInterval
的五層或更多層嵌套調用(調用五次之後)的最小延時限制在 4ms。這是曆史遺留問題。
請注意,所有的調度方法都不能 保證 確切的延時。
例如,浏覽器內的計時器可能由于許多原因而變慢:
CPU 過載。
浏覽器頁簽處于後台模式。
筆記本電腦用的是省電模式。
所有這些因素,可能會將定時器的最小計時器分辨率(最小延遲)增加到 300ms 甚至 1000ms,具體以浏覽器及其設置爲准。
重要程度: 5
編寫壹個函數 printNumbers(from, to)
,使其每秒輸出壹個數字,數字從 from
開始,到 to
結束。
使用以下兩種方法來實現。
使用 setInterval
。
使用嵌套的 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);
請注意,在這兩種解決方案中,在第壹個輸出之前都有壹個初始延遲。函數在 1000ms
之後才被第壹次調用。
如果我們還希望函數立即運行,那麽我們可以在單獨的壹行上添加壹個額外的調用,像這樣:
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
調度了壹個調用,然後需要運行壹個計算量很大的 for
循環,這段運算耗時超過 100 毫秒。
調度的函數會在何時運行?
循環執行完成後。
循環執行前。
循環剛開始時。
alert
會顯示什麽?
let i = 0; setTimeout(() => alert(i), 100); // ? // 假設這段代碼的運行時間 >100ms for(let j = 0; j < 100000000; j++) { i++; }
任何 setTimeout
都只會在當前代碼執行完畢之後才會執行。
所以 i
的取值爲:100000000
。
let i = 0; setTimeout(() => alert(i), 100); // 100000000 // 假設這段代碼的運行時間 >100ms for(let j = 0; j < 100000000; j++) { i++; }