先來回答一下下面這個問題:對於 setTimeout(function() { console.log('timeout') }, 1000)
這一行代碼,你從哪裏可以找到 setTimeout
的源代碼(同樣的問題還會是你從哪裏可以看到 setInterval
的源代碼)?
很多時候,可以我們腦子裏面閃過的第一個答案肯定是 V8 引擎或者其它 VM們,但是要知道的一點是,所有我們所見過的 Javascript 計時函數,都沒有出現在 ECMAScript 標準中,也沒有被任何 Javascript 引擎實現,計時函數,其實都是由瀏覽器(或者其它運行時,比如 Node.js)實現的,並且,在不同的運行時下,其表現形式有可能都不一致。
在瀏覽器中,主計時器函數是 Window
接口的一部分,這保證了包括如 setTimeout
、setInterval
等計時器函數以及其它函數和對象能被全局訪問,這纔是你可以隨時隨地使用 setTimeout
的原因。同樣的,在 Node.js 中,setTimeout
是 global
對象的一部分,這拿得你也可以像在瀏覽器裏面一樣,隨時隨地的使用它。
到現在可能會有一些人感覺這個問題其實並沒有實際的價值,但是作爲一個 Javascript 開發者,如果不知道本質,那麼就有可能不能完全的理解 V8 (或者其它VM)是到底是如何與瀏覽器或者 Node.js 相互作用的。
暫緩一個函數的執行
計時器函數都是更高階的函數,它們可以用於暫緩一個函數的執行,或者讓一個函數重複執行(由他們的第一個參數執行需要執行的函數)。
下面這是一個暫緩執行的示例:
setTimeout(() => {
console.log('距離函數的調用,已經過去 4 秒了')
}, 4 * 1000)
在上面的示例中, setTimeout
將 console.log
的執行暫緩了 4 * 1000
毫秒,也就是 4 秒鐘, setTimeout
的第一個函數,就是需要暫緩執行的函數,它是一個函數的引用,下面這個示例是我們更加常見到的寫法:
const fn = () => {
console.log('距離函數的調用,已經過去 4 秒了')
}
setTimeout(fn, 4 * 1000)
傳遞參數
如果被 setTimeout
暫緩的函數需要接收參數,我們可以從第三個參數開始添加需要傳遞給被暫緩函數的參數:
const fn = (name, gender) => {
console.log(`I'm ${name}, I'm a ${gender}`)
}
setTimeout(fn, 4 * 1000, 'Tao Pan', 'male')
上面的 setTimeout
調用,其結果與下面這樣調用類似:
setTimeout(() => {
fn('Tao Pan', 'male')
}, 4 * 1000)
但是記住,只是結果類似,本質上是不一樣的,我們可以用僞代碼來表示 setTimeout
的函數實現:
const setTimeout = (fn, delay, ...args) => {
wait(delay) // 這裏表示等待 delay 指定的毫秒數
fn(...args)
}
挑戰一下
編寫一個函數:
- 當
delay
爲 4 秒的時候,打印出:距離函數的調用,已經過去 4 秒了 - 當
delay
爲 8 秒的時候,打印出:距離函數的調用,已經過去 8 秒了 - 當
delay
爲 N 秒的時候,打印出:距離函數的調用,已經過去 N 秒了
下面這個是我的一個實現:
const delayLog = delay => {
setTimeout(console.log, delay * 1000, `距離函數的調用,已經過去 ${delay} 秒了`)
}
delayLog(4) // 輸出:距離函數的調用,已經過去 4 秒了
delayLog(8) // 輸出:距離函數的調用,已經過去 8 秒了
我們來理一下 delayLog(4)
的整個執行過程:
delay = 4
-
setTimeout
執行 -
4 * 1000
毫秒後,setTimeout
調用console.log
方法 -
setTimeout
計算其第三個參數距離函數的調用,已經過去 ${delay} 秒了
得到距離函數的調用,已經過去 4 秒了
-
setTimeout
將計算得到的字符串當作console.log
的第一個參數 -
console.log('距離函數的調用,已經過去 4 秒了')
執行,輸出結果
規律性重複一個函數的執行以及停止重複調用
如果我們現在要每 4 秒第印一次呢?這裏面就有很多種實現方式了,假如我們還是使用 setTimeout
來實現,我們可以這樣做:
const loopMessage = delay => {
setTimeout(() => {
console.log('這裏是由 loopMessage 打印出來的消息')
loopMessage(delay)
}, delay * 1000)
}
loopMessage(1) // 此時,每過 1 秒鐘,就會打印出一段消息:*這裏是由 loopMessage 打印出來的消息*
但是這樣有一個問題,就是開始之後,我們就沒有辦法停止,怎麼辦?可以稍稍改改實現:
let loopMessageTimer
const loopMessage = delay => {
loopMessageTimer = setTimeout(() => {
console.log('這裏是由 loopMessage 打印出來的消息')
loopMessage(delay)
}, delay * 1000)
}
loopMessage(1)
clearTimeout(loopMessageTimer) // 我們隨時都可以使用 `clearTimeout` 清除這個循環
但是這樣還是有問題的,如果 loopMessage
被調用多次,那麼他們將共用一個 loopMessageTimer
,清除一個,將清除所有,這是肯定不行的,所以,還得再改造一下:
const loopMessage = delay => {
let timer
const log = () => {
timer = setTimeout(() => {
console.log(`每 ${delay} 秒打印一次`)
log()
}, delay * 1000)
}
log()
return () => clearTimeout(timer)
}
const clearLoopMessage = loopMessage(1)
const clearLoopMessage2 = loopMessage(1.5)
clearLoopMessage() // 我們在任何時候都可以取消任何一個重複調用,而不影響其它的
這…… 實現是實現了,但是其它有更好的解決辦法:
const timer = setInterval(console.log, 1000, '每 1 秒鐘打印一次')
clearInterval(timer) // 隨時可以 `clearInterval` 清除
更加深入了認識取消計時器(Cancel Timers)
上面的示例只是簡單的給我們展現了 setTimeout
以及 setInterval
,也看到了,我們可以通過 clearTimeout
或者 clearInterval
取消計時器,但是關於計時器,遠遠不止這點知識,請看下面的代碼(請):
const cancelImmediate = () => {
const timerId = setTimeout(console.log, 0, '暫緩了 0 秒執行')
clearTimeout(timerId)
}
cancelImmediate() // 這裏並不會有任何輸出
或者看下面這樣的代碼:
const cancelImmediate2 = () => setTimeout(console.log, 0, '暫緩了 0 秒執行')
const timerId = cancelImmediate2()
clearTimeout(timerId)
請將上面的的任一代碼片段同時複製到瀏覽器的控制檯中(有多行復制多行)執行,你會發現,兩個代碼片段都沒有任何輸出,這是爲什麼?
這是因爲,Javascript 的運行機制導致,任何時刻都只能存在一個任務在進行,雖然我們調用的是暫緩 0 秒,但是,由於當前的任務還沒有執行完成,所以,setTimeout
中被暫緩的函數即使時間到了也不會被執行,必須等到當前的任務完全執行完成,那麼,再試着,上面的代碼分行復制到控制檯,看看結果是不是會打印出 暫緩了 0 秒執行 了?答案是肯定的。
當你一行一行復制執行的時候, cancelImmediate2
執行完成之後,當前任務就已經全部執行完成了,所以開始執行下一個任務(console.log
開始執行)。
從上面的示例中,我們可以看出,setTimeout
其實是將一個任務安排進一個 Javascript 的任務隊列裏面去,當前面的所有任務都執行完成之後,如果這個任務時間到了,那麼就立即執行,否則,繼續等待計時結束。
此時,你應該發現,只要是 setTimeout
所暫緩的函數沒有被執行(任務還沒有完成),那麼,我們就可以隨時使用 clearTimeout
清除掉這個暫緩(將這條任務從隊列裏面移除)
計時器是沒有任何保證的
通過前面的例子,我們知道了 setTimeout
的 delay
爲 0
時,並不表示立馬就會執行了,它必須等到所有的當前任務(對於一個 JS 文件來講,就是需要執行完當前腳本中的所有調用)執行完成之後都會執行,而這裏面就包括我們調用的 clearTimeout
。
下面用一個示例來更清楚了說明這個問題:
setTimeout(console.log, 1000, '1 秒後執行的')
// 開始時間
const startTime = new Date()
// 距離開始時間已經過去幾秒
let secondsPassed = 0
while (true) {
// 距離開始時間的毫秒數
const duration = new Date() - startTime
// 如果距離開始時間超過 5000 毫秒了, 則終止循環
if (duration > 5000) {
break
} else {
// 如果距離開始時間增長一秒,更新 secondsPassed
if (Math.floor(duration / 1000) > secondsPassed) {
secondsPassed = Math.floor(duration / 1000)
console.log(`已經過去 ${secondsPassed} 秒了。`)
}
}
}
你們猜上面這段代碼會有什麼樣的輸出?是下面這樣的嗎?
1 秒後執行的
已經過去 1 秒了。
已經過去 2 秒了。
已經過去 3 秒了。
已經過去 4 秒了。
已經過去 5 秒了。
並不是這樣的,而是下面這樣的:
已經過去 1 秒了。
已經過去 2 秒了。
已經過去 3 秒了。
已經過去 4 秒了。
已經過去 5 秒了。
1 秒後執行的
怎麼會這樣?這是因爲 while(true)
這個循環必須要執行超過 5 秒鐘的時間之後,纔算當前所有任務完成,在它 break
之前,其它所有的操作都是沒有用的,當然,我們不會在開發的過程中去寫這樣的代碼,但是並不表示就不存在這樣的情況,想象以下下面這樣的場景:
setTimeout(somethingMustDoAfter1Seconds, 1000)
openFileSync('file more then 1gb')
這裏面的 openFileSync
只是一個僞代碼,它表示我們需要同步進行一個特別費時的操作,這個操作很有可能會超過 1 秒,甚至更長的時間,但是上面那個 somethingMustDoAfter1Seconds
將一直處於掛起狀態,只要這個操作完成,它纔有可能執行,爲什麼叫有可能?那是因爲,有可能還有別的任務又會佔用資源。所以,我們可以將 setTimeout
理解爲:計時結束是執行任務的必要條件,但是不是任務是否執行的決定性因素。
setTimeout(somethingMustDoAfter1Seconds, 1000)
的意思是,必須超過 1000 毫秒後,somethingMustDoAfter1Seconds
才允許執行。
再來一個小挑戰
那如果我需要每一秒鐘都打印一句話怎麼辦?從上面的示例中,已經很明顯的看到了,setTimeout
是肯定解決不了這個問題了,不信我們可以試試下面這個代碼片段:
const log = (delay) => {
timer = setTimeout(() => {
console.log(`每 ${delay} 秒打印一次`)
log(delay)
}, delay * 1000)
}
log(1)
上面的代碼是沒有任何問題的,在瀏覽器的控制檯觀察,你會發現確實每一秒鐘都打印了一行,但是再試試下面這樣的代碼:
const log = (delay) => {
timer = setTimeout(() => {
console.log(`每 ${delay} 秒打印一次`)
log(delay)
}, delay * 1000)
}
const readLargeFileSync = () => {
// 開始時間
const startTime = new Date()
// 距離開始時間已經過去幾秒
let secondsPassed = 0
while (true) {
// 距離開始時間的毫秒數
const duration = new Date() - startTime
// 如果距離開始時間超過 5000 毫秒了, 則終止循環
if (duration > 5000) {
break
} else {
// 如果距離開始時間增長一秒,更新 secondsPassed
if (Math.floor(duration / 1000) > secondsPassed) {
secondsPassed = Math.floor(duration / 1000)
console.log(`已經過去 ${secondsPassed} 秒了。`)
}
}
}
}
log(1)
setTimeout(readLargeFileSync, 1300)
輸出結果是:
每 1 秒打印一次
已經過去 1 秒了。
已經過去 2 秒了。
已經過去 3 秒了。
已經過去 4 秒了。
已經過去 5 秒了。
每 1 秒打印一次
- 第一秒的時候,
log
執行 - 第 1300 毫秒時,開始執行
readLargeFileSync
這會需要整整 5 秒鐘的時間 - 第 2 秒的時候,
log
執行時間到了,但是當前任務並沒有完成,所以,它不會打印 - 第 5 秒的時候,
readLargeFileSync
執行完成了,所以log
繼續執行
關於這個具體怎麼實現,就不在本文討論了
最終,到底是誰在調用那個被暫緩的函數?
當我們在一個 function
中調用 this
時,this
關鍵字會指向當前函數的 caller
:
function whoCallsMe() {
console.log('My caller is: ', this)
}
當我們在瀏覽器的控制檯中調用 whoCallsMe
時,會打印出 Window
,當在 Node.js 的 REPL 中執行時,會執行出 global
,如果我們將 whoCallsMe
設置爲一個對象的屬性:
function whoCallsMe() {
console.log('My caller is: ', this)
}
const person = {
name: 'Tao Pan',
whoCallsMe
}
person.whoCallsMe()
這會打印出:My caller is: Object { name: "Tao Pan", whoCallsMe: whoCallsMe() }
那麼?
function whoCallsMe() {
console.log('My caller is: ', this)
}
const person = {
name: 'Tao Pan',
whoCallsMe
}
setTimeout(person.whoCallsMe, 0)
這會打印出什麼?這個很容易被忽視的問題,其實真的值得我們去思考。
請直接將上面這個代碼片段複製進瀏覽器的控制檯,看執行的結果:
My caller is: Window https://pantao.parcmg.com/admin/write-post.php?cid=2952
再打開系統終端,進入 Node.js REPL 中,執行同樣的代碼,看執行結果:
My caller is: Timeout {
_idleTimeout: 1,
_idlePrev: null,
_idleNext: null,
_idleStart: 7052,
_onTimeout: [Function: whoCallsMe],
_timerArgs: undefined,
_repeat: null,
_destroyed: false,
[Symbol(refed)]: true,
[Symbol(asyncId)]: 221,
[Symbol(triggerId)]: 5
}
回到這句話:當我們在一個 function
中調用 this
時,this
關鍵字會指向當前函數的 caller
,當我們使用 setTimeout
時,這個 caller
是跟當前的運行時有關係的,如果我想 this
總是指向 person
對象呢?
function whoCallsMe() {
console.log('My caller is: ', this)
}
const person = {
name: 'Tao Pan'
}
person.whoCallsMe = whoCallsMe.bind(person)
setTimeout(person.whoCallsMe, 0)
結語
標題是寫上了 你需要知道的一切都在這裏,但是如果有什麼沒有考慮到了,歡迎大家指出。