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

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

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

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

到現在可能會有一些人感覺這個問題其實並沒有實際的價值,但是作爲一個 Javascript 開發者,如果不知道本質,那麼就有可能不能完全的理解 V8 (或者其它VM)是到底是如何與瀏覽器或者 Node.js 相互作用的。

暫緩一個函數的執行

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

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

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

在上面的示例中, setTimeoutconsole.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) 的整個執行過程:

  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)

結語

標題是寫上了 你需要知道的一切都在這裏,但是如果有什麼沒有考慮到了,歡迎大家指出。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章