理解並優化函數節流Throttle

1、函數爲何要節流

有以下代碼git

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

當咱們在PC端頁面上滑動鼠標時,一秒能夠能夠觸發約60次事件。你們也能夠訪問下面的在線例子進行測試。github

查看在線例子: 函數節流-監聽鼠標移動觸發次數測試 by Logan (@logan70) on CodePen.瀏覽器

這裏的回調函數只是打印字符串,若是回調函數更加複雜,可想而知瀏覽器的壓力會很是大,可能下降用戶體驗。app

resizescrollmousemove等事件的監聽回調會被頻繁觸發,所以咱們要對其進行限制。async

2、實現思路

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

1. 使用時間戳

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

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.ui

3. 兩種方法對比

  • 首次觸發:使用時間戳實現時會當即執行(將previous設爲0的狀況);使用定時器實現會設置定時器,wait毫秒後執行。
  • 中止觸發:使用時間戳實現時,中止觸發後不會再執行;使用定時器實現時,因爲存在定時器,中止觸發後還會執行一次。

3、函數節流 Throttle 應用場景

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

4、函數節流最終版

代碼說話,有錯懇請指出

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.

具體的實現步驟請往下看

5、函數節流 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.

6、參考文章

JavaScript專題之跟着 underscore 學節流

underscore 函數節流的實現

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

相關文章
相關標籤/搜索