setTimeout 或者 setInterval,關於 Javascript 計時器:你須要知道的一切都在這裏

先來回答一下下面這個問題:對於 setTimeout(function() { console.log('timeout') }, 1000) 這一行代碼,你從哪裏能夠找到 setTimeout 的源代碼(一樣的問題還會是你從哪裏能夠看到 setInterval 的源代碼)?php

不少時候,能夠咱們腦子裏面閃過的第一個答案確定是 V8 引擎或者其它 VM們,可是要知道的一點是,全部咱們所見過的 Javascript 計時函數,都沒有出如今 ECMAScript 標準中,也沒有被任何 Javascript 引擎實現,計時函數,其實都是由瀏覽器(或者其它運行時,好比 Node.js)實現的,而且,在不一樣的運行時下,其表現形式有可能都不一致瀏覽器

在瀏覽器中,主計時器函數是 Window 接口的一部分,這保證了包括如 setTimeoutsetInterval 等計時器函數以及其它函數和對象能被全局訪問,這纔是你能夠隨時隨地使用 setTimeout 的緣由。一樣的,在 Node.js 中,setTimeoutglobal 對象的一部分,這拿得你也能夠像在瀏覽器裏面同樣,隨時隨地的使用它。async

到如今可能會有一些人感受這個問題其實並無實際的價值,可是做爲一個 Javascript 開發者,若是不知道本質,那麼就有可能不能徹底的理解 V8 (或者其它VM)是究竟是如何與瀏覽器或者 Node.js 相互做用的。函數

暫緩一個函數的執行

計時器函數都是更高階的函數,它們能夠用於暫緩一個函數的執行,或者讓一個函數重複執行(由他們的第一個參數執行須要執行的函數)。oop

下面這是一個暫緩執行的示例:post

setTimeout(() => {
  console.log('距離函數的調用,已通過去 4 秒了')
}, 4 * 1000)

在上面的示例中, setTimeoutconsole.log 的執行暫緩了 4 * 1000 毫秒,也就是 4 秒鐘, setTimeout 的第一個函數,就是須要暫緩執行的函數,它是一個函數的引用,下面這個示例是咱們更加常見到的寫法:this

const fn = () => {
  console.log('距離函數的調用,已通過去 4 秒了')
}

setTimeout(fn, 4 * 1000)

傳遞參數

若是被 setTimeout 暫緩的函數須要接收參數,咱們能夠從第三個參數開始添加須要傳遞給被暫緩函數的參數:code

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) 的整個執行過程:

  1. delay = 4
  2. setTimeout 執行
  3. 4 * 1000 毫秒後, setTimeout 調用 console.log 方法
  4. setTimeout 計算其第三個參數 距離函數的調用,已通過去 ${delay} 秒了 獲得 距離函數的調用,已通過去 4 秒了
  5. setTimeout 將計算獲得的字符串看成 console.log 的第一個參數
  6. 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 清除掉這個暫緩(將這條任務從隊列裏面移除)

計時器是沒有任何保證的

經過前面的例子,咱們知道了 setTimeoutdelay0 時,並不表示立馬就會執行了,它必須等到全部的當前任務(對於一個 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 秒打印一次
  1. 第一秒的時候, log 執行
  2. 第 1300 毫秒時,開始執行 readLargeFileSync 這會須要整整 5 秒鐘的時間
  3. 第 2 秒的時候,log 執行時間到了,可是當前任務並無完成,因此,它不會打印
  4. 第 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)

結語

標題是寫上了 你須要知道的一切都在這裏,可是若是有什麼沒有考慮到了,歡迎你們指出。

相關文章
相關標籤/搜索