理解並優化函式節流Throttle

Logan70發表於2018-11-07

一、函式為什麼要節流

有如下程式碼

let n = 1
window.onmousemove = () => {
  console.log(`第${n}次觸發回撥`)
  n++
}
複製程式碼

當我們在PC端頁面上滑動滑鼠時,一秒可以可以觸發約60次事件。大家也可以訪問下面的線上例子進行測試。

檢視線上例子: 函式節流-監聽滑鼠移動觸發次數測試 by Logan (@logan70) on CodePen.

這裡的回撥函式只是列印字串,如果回撥函式更加複雜,可想而知瀏覽器的壓力會非常大,可能降低使用者體驗。

resizescrollmousemove等事件的監聽回撥會被頻繁觸發,因此我們要對其進行限制。

二、實現思路

函式節流簡單來說就是對於連續的函式呼叫,每間隔一段時間,只讓其執行一次。初步的實現思路有兩種:

1. 使用時間戳

設定一個對比時間戳,觸發事件時,使用當前時間戳減去對比時間戳,如果差值大於設定的間隔時間,則執行函式,並用當前時間戳替換對比時間戳;如果差值小於設定的間隔時間,則不執行函式。

function throttle(method, wait) {
  // 對比時間戳,初始化為0則首次觸發立即執行,初始化為當前時間戳則wait毫秒後觸發才會執行
  let previous = 0
  return function(...args) {
    let context = this
    let now = new Date().getTime()
    // 間隔大於wait則執行method並更新對比時間戳
    if (now - previous > wait) {
      method.apply(context, args)
      previous = now
    }
  }
}
複製程式碼

檢視線上例子: 函式節流-初步實現之時間戳 by Logan (@logan70) on CodePen.

2. 使用定時器

當首次觸發事件時,設定定時器,wait毫秒後執行函式並將定時器置為null,之後觸發事件時,如果定時器存在則不執行,如果定時器不存在則再次設定定時器。

function throttle(method, wait) {
  let timeout
  return function(...args) {
    let context = this
    if (!timeout) {
      timeout = setTimeout(() => {
        timeout = null
        method.apply(context, args)
      }, wait)
    }
  }
}
複製程式碼

檢視線上例子: 函式節流-初步實現之定時器 by Logan (@logan70) on CodePen.

3. 兩種方法對比

  • 首次觸發:使用時間戳實現時會立即執行(將previous設為0的情況);使用定時器實現會設定定時器,wait毫秒後執行。
  • 停止觸發:使用時間戳實現時,停止觸發後不會再執行;使用定時器實現時,由於存在定時器,停止觸發後還會執行一次。

三、函式節流 Throttle 應用場景

  • DOM 元素的拖拽功能實現(mousemove
  • 射擊遊戲的 mousedown/keydown 事件(單位時間只能發射一顆子彈)
  • 計算滑鼠移動的距離(mousemove
  • Canvas 模擬畫板功能(mousemove
  • 搜尋聯想(keyup
  • 監聽滾動事件判斷是否到頁面底部自動載入更多:給 scroll 加了 debounce 後,只有使用者停止滾動後,才會判斷是否到了頁面底部;如果是 throttle 的話,只要頁面滾動就會間隔一段時間判斷一次

四、函式節流最終版

程式碼說話,有錯懇請指出

function throttle(method, wait, {leading = true, trailing = true} = {}) {
  // result 記錄method的執行返回值
  let timeout, result
  // 記錄上次原函式執行的時間(非每次更新)
  let methodPrevious = 0
  // 記錄上次回撥觸發時間(每次都更新)
  let throttledPrevious = 0
  let throttled =  function(...args) {
    let context = this
    // 使用Promise,可以在觸發回撥時拿到原函式執行的返回值
    return new Promise(resolve => {
      let now = new Date().getTime()
      // 兩次相鄰觸發的間隔
      let interval = now - throttledPrevious
      // 更新本次觸發時間供下次使用
      throttledPrevious = now
      // 重置methodPrevious為now,remaining = wait > 0,假裝剛執行過,實現禁止立即執行
      // 統一條件:leading為false
      // 加上以下條件之一
      // 1. 首次觸發(此時methodPrevious為0)
      // 2. trailing為true時,停止觸發時間超過wait,定時器內函式執行(methodPrevious被置為0),然後再次觸發
      // 3. trailing為false時(不設定時器,methodPrevious不會被置為0),停止觸發時間超過wait後再次觸發(interval > wait)
      if (leading === false && (!methodPrevious || interval > wait)) {
        methodPrevious = now
        // 保險起見,清除定時器並置為null
        // 假裝剛執行過要假裝的徹底XD
        if (timeout) {
          clearTimeout(timeout)
          timeout = null
        }
      }
      // 距離下次執行原函式的間隔
      let remaining = wait - (now - methodPrevious)
      // 1. leading為true時,首次觸發就立即執行
      // 2. 到達下次執行原函式時間
      // 3. 修改了系統時間
      if (remaining <= 0 || remaining > wait) {
        if (timeout) {
          clearTimeout(timeout)
          timeout = null
        }
        // 更新對比時間戳,執行函式並記錄返回值,傳給resolve
        methodPrevious = now
        result = method.apply(context, args)
        resolve(result)
        // 解除引用,防止記憶體洩漏
        if (!timeout) context = args = null
      } else if (!timeout && trailing !== false) {
        timeout = setTimeout(() => {
          // leading為false時將methodPrevious設為0的目的在於
          // 若不將methodPrevious設為0,如果定時器觸發後很長時間沒有觸發回撥
          // 下次觸發時的remaining為負,原函式會立即執行,違反了leading為false的設定
          methodPrevious = leading === false ? 0 : new Date().getTime()
          timeout = null
          result = method.apply(context, args)
          resolve(result)
          // 解除引用,防止記憶體洩漏
          if (!timeout) context = args = null
        }, remaining)
      }
    })
  }
  // 加入取消功能,使用方法如下
  // let throttledFn = throttle(otherFn)
  // throttledFn.cancel()
  throttled.cancel = function() {
    clearTimeout(timeout)
    previous = 0
    timeout = null
  }

  return throttled
}
複製程式碼

呼叫節流後的函式的外層函式也需要使用Async/Await語法等待執行結果返回

使用方法見程式碼:

function square(num) {
  return Math.pow(num, 2)
}

// let throttledFn = throttle(square, 1000)
// let throttledFn = throttle(square, 1000, {leading: false})
// let throttledFn = throttle(square, 1000, {trailing: false})
let throttledFn = throttle(square, 1000, {leading: false, trailing: false})

window.onmousemove = async () => {
  try {
    let val = await throttledFn(4)
    // 原函式不執行時val為undefined
    if (typeof val !== 'undefined') {
      console.log(`原函式返回值為${val}`)
    }
  } catch (err) {
    console.error(err)
  }
}

// 滑鼠移動時,每間隔1S輸出:
// 原函式的返回值為:16
複製程式碼

檢視線上例子: 函式節流-最終版 by Logan (@logan70) on CodePen.

具體的實現步驟請往下看

五、函式節流 Throttle 的具體實現步驟

1. 優化第一版:融合兩種實現方式

這樣實現的效果是首次觸發立即執行,停止觸發後會再執行一次

function throttle(method, wait) {
  let timeout
  let previous = 0
  return function(...args) {
    let context = this
    let now = new Date().getTime()
    // 距離下次函式執行的剩餘時間
    let remaining = wait - (now - previous)
    // 如果無剩餘時間或系統時間被修改
    if (remaining <= 0 || remaining > wait) {
      // 如果定時器還存在則清除並置為null
      if (timeout) {
        clearTimeout(timeout)
        timeout = null
      }
      // 更新對比時間戳並執行函式
      previous = now
      method.apply(context, args)
    } else if (!timeout) {
      // 如果有剩餘時間但定時器不存在,則設定定時器
      // remaining毫秒後執行函式、更新對比時間戳
      // 並將定時器置為null
      timeout = setTimeout(() => {
        previous = new Date().getTime()
        timeout = null
        method.apply(context, args)
      }, remaining)
    }
  }
}
複製程式碼

我們來捋一捋,假設連續觸發回撥:

  1. 第一次觸發:對比時間戳為0,剩餘時間為負數,立即執行函式並更新對比時間戳
  2. 第二次觸發:剩餘時間為正數,定時器不存在,設定定時器
  3. 之後的觸發:剩餘時間為正數,定時器存在,不執行其他行為
  4. 直至剩餘時間小於等於0或定時器內函式執行(由於回撥觸發有間隔,且setTimeout有誤差,故哪個先觸發並不確定)
  • 若定時器內函式執行,更新對比時間戳,並將定時器置為null,下一次觸發繼續設定定時器
  • 若定時器內函式未執行,但剩餘時間小於等於0,清除定時器並置為null,立即執行函式,更新時間戳,下一次觸發繼續設定定時器
  1. 停止觸發後:若非在上面所述的兩個特殊時間點時停止觸發,則會存在一個定時器,原函式還會被執行一次

檢視線上例子: 函式節流-優化第一版:融合兩種實現方式 by Logan (@logan70) on CodePen.

2. 優化第二版:提供首次觸發時是否立即執行的配置項

// leading為控制首次觸發時是否立即執行函式的配置項
function throttle(method, wait, leading = true) {
  let timeout
  let previous = 0
  return function(...args) {
    let context = this
    let now = new Date().getTime()
    // !previous代表首次觸發或定時器觸發後的首次觸發,若不需要立即執行則將previous更新為now
    // 這樣remaining = wait > 0,則不會立即執行,而是設定定時器
    if (!previous && leading === false) previous = now
    let remaining = wait - (now - previous)
    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        clearTimeout(timeout)
        timeout = null
      }
      previous = now
      method.apply(context, args)
    } else if (!timeout) {
      timeout = setTimeout(() => {
        // 如果leading為false,則將previous設為0,
        // 下次觸發時會與下次觸發時的now同步,達到首次觸發(對於使用者來說)不立即執行
        // 如果直接設為當前時間戳,若停止觸發一段時間,下次觸發時的remaining為負值,會立即執行
        previous = leading === false ? 0 : new Date().getTime()
        timeout = null
        method.apply(context, args)
      }, remaining)
    }
  }
}
複製程式碼

檢視線上例子: 函式節流-優化第二版:提供首次觸發時是否立即執行的配置項 by Logan (@logan70) on CodePen.

3. 優化第三版:提供停止觸發後是否還執行一次的配置項

// trailing為控制停止觸發後是否還執行一次的配置項
function throttle(method, wait, {leading = true, trailing = true} = {}) {
  let timeout
  let previous = 0
  return function(...args) {
    let context = this
    let now = new Date().getTime()
    if (!previous && leading === false) previous = now
    let remaining = wait - (now - previous)
    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        clearTimeout(timeout)
        timeout = null
      }
      previous = now
      method.apply(context, args)
    } else if (!timeout && trailing !== false) {
      // 如果有剩餘時間但定時器不存在,且trailing不為false,則設定定時器
      // trailing為false時等同於只使用時間戳來實現節流
      timeout = setTimeout(() => {
        previous = leading === false ? 0 : new Date().getTime()
        timeout = null
        method.apply(context, args)
      }, remaining)
    }
  }
}
複製程式碼

檢視線上例子: 函式節流-優化第三版:提供停止觸發後是否還執行一次的配置項 by Logan (@logan70) on CodePen.

4. 優化第四版:提供取消功能

有些時候我們需要在不可觸發的這段時間內能夠手動取消節流,程式碼實現如下:

function throttle(method, wait, {leading = true, trailing = true} = {}) {
  let timeout
  let previous = 0
  // 將返回的匿名函式賦值給throttled,以便在其上新增取消方法
  let throttled =  function(...args) {
    let context = this
    let now = new Date().getTime()
    if (!previous && leading === false) previous = now
    let remaining = wait - (now - previous)
    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        clearTimeout(timeout)
        timeout = null
      }
      previous = now
      method.apply(context, args)
    } else if (!timeout && trailing !== false) {
      timeout = setTimeout(() => {
        previous = leading === false ? 0 : new Date().getTime()
        timeout = null
        method.apply(context, args)
      }, remaining)
    }
  }

  // 加入取消功能,使用方法如下
  // let throttledFn = throttle(otherFn)
  // throttledFn.cancel()
  throttled.cancel = function() {
    clearTimeout(timeout)
    previous = 0
    timeout = null
  }

  // 將節流後函式返回
  return throttled
}
複製程式碼

檢視線上例子: 函式節流-優化第四版:提供取消功能 by Logan (@logan70) on CodePen.

5. 優化第五版:處理原函式返回值

需要節流的函式可能是存在返回值的,我們要對這種情況進行處理,underscore的處理方法是將函式返回值在返回的debounced函式內再次返回,但是這樣其實是有問題的。如果原函式執行在setTimeout內,則無法同步拿到返回值,我們使用Promise處理原函式返回值。

function throttle(method, wait, {leading = true, trailing = true} = {}) {
  // result記錄原函式執行結果
  let timeout, result
  let previous = 0
  let throttled =  function(...args) {
    let context = this
    // 返回一個Promise,以便可以使用then或者Async/Await語法拿到原函式返回值
    return new Promise(resolve => {
      let now = new Date().getTime()
      if (!previous && leading === false) previous = now
      let remaining = wait - (now - previous)
      if (remaining <= 0 || remaining > wait) {
        if (timeout) {
          clearTimeout(timeout)
          timeout = null
        }
        previous = now
        result = method.apply(context, args)
        // 將函式執行返回值傳給resolve
        resolve(result)
      } else if (!timeout && trailing !== false) {
        timeout = setTimeout(() => {
          previous = leading === false ? 0 : new Date().getTime()
          timeout = null
          result = method.apply(context, args)
          // 將函式執行返回值傳給resolve
          resolve(result)
        }, remaining)
      }
    })
  }

  throttled.cancel = function() {
    clearTimeout(timeout)
    previous = 0
    timeout = null
  }

  return throttled
}
複製程式碼

使用方法一:在呼叫節流後的函式時,使用then拿到原函式的返回值

function square(num) {
  return Math.pow(num, 2)
}

let throttledFn = throttle(square, 1000, false)

window.onmousemove = () => {
  throttledFn(4).then(val => {
    console.log(`原函式的返回值為:${val}`)
  })
}

// 滑鼠移動時,每間隔1S後輸出:
// 原函式的返回值為:16
複製程式碼

使用方法二:呼叫節流後的函式的外層函式使用Async/Await語法等待執行結果返回

使用方法見程式碼:

function square(num) {
  return Math.pow(num, 2)
}

let throttledFn = throttle(square, 1000)

window.onmousemove = async () => {
  try {
    let val = await throttledFn(4)
    // 原函式不執行時val為undefined
    if (typeof val !== 'undefined') {
      console.log(`原函式返回值為${val}`)
    }
  } catch (err) {
    console.error(err)
  }
}

// 滑鼠移動時,每間隔1S輸出:
// 原函式的返回值為:16
複製程式碼

檢視線上例子: 函式節流-優化第五版:處理原函式返回值 by Logan (@logan70) on CodePen.

6. 優化第六版:可同時禁用立即執行和後置執行

模仿underscore實現的函式節流有一點美中不足,那就是 leading:falsetrailing: false 不能同時設定。

如果同時設定的話,比如當你將滑鼠移出的時候,因為 trailing 設定為 false,停止觸發的時候不會設定定時器,所以只要再過了設定的時間,再移入的話,remaining為負數,就會立刻執行,就違反了 leading: false,這裡我們優化的思路如下:

計算連續兩次觸發回撥的時間間隔,如果大於設定的間隔值時,重置對比時間戳為當前時間戳,這樣就相當於回到了首次觸發,達到禁止首次觸發(偽)立即執行的效果,程式碼如下,有錯懇請指出:

function throttle(method, wait, {leading = true, trailing = true} = {}) {
  let timeout, result
  let methodPrevious = 0
  // 記錄上次回撥觸發時間(每次都更新)
  let throttledPrevious = 0
  let throttled =  function(...args) {
    let context = this
    return new Promise(resolve => {
      let now = new Date().getTime()
      // 兩次觸發的間隔
      let interval = now - throttledPrevious
      // 更新本次觸發時間供下次使用
      throttledPrevious = now
      // 更改條件,兩次間隔時間大於wait且leading為false時也重置methodPrevious,實現禁止立即執行
      if (leading === false && (!methodPrevious || interval > wait)) {
        methodPrevious = now
      }
      let remaining = wait - (now - methodPrevious)
      if (remaining <= 0 || remaining > wait) {
        if (timeout) {
          clearTimeout(timeout)
          timeout = null
        }
        methodPrevious = now
        result = method.apply(context, args)
        resolve(result)
        // 解除引用,防止記憶體洩漏
        if (!timeout) context = args = null
      } else if (!timeout && trailing !== false) {
        timeout = setTimeout(() => {
          methodPrevious = leading === false ? 0 : new Date().getTime()
          timeout = null
          result = method.apply(context, args)
          resolve(result)
          // 解除引用,防止記憶體洩漏
          if (!timeout) context = args = null
        }, remaining)
      }
    })
  }

  throttled.cancel = function() {
    clearTimeout(timeout)
    methodPrevious = 0
    timeout = null
  }

  return throttled
}
複製程式碼

檢視線上例子: 函式節流-優化第六版:可同時禁用立即執行和後置執行 by Logan (@logan70) on CodePen.

六、參考文章

JavaScript專題之跟著 underscore 學節流

underscore 函式節流的實現

如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。

相關文章