理解函式防抖Debounce

Logan70發表於2018-11-01

一、函式為什麼要防抖

有如下程式碼

window.onresize = () => {
  console.log('觸發視窗監聽回撥函式')
}
複製程式碼

當我們在PC上縮放瀏覽器視窗時,一秒可以輕鬆觸發30次事件。手機端觸發其他Dom時間監聽回撥時同理。

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

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

二、實現思路

函式去抖簡單來說就是對於一定時間段的連續的函式呼叫,只讓其執行一次,初步的實現思路如下:

第一次呼叫函式,建立一個定時器,在指定的時間間隔之後執行程式碼。當第二次呼叫該函式時,它會清除前一次的定時器並設定另一個。如果前一個定時器已經執行過了,這個操作就沒有任何意義。然而,如果前一個定時器尚未執行,其實就是將其替換為一個新的定時器。目的是隻有在執行函式的請求停止了一段時間之後才執行。

三、Debounce 應用場景

  • 每次 resize/scroll 觸發統計事件
  • 文字輸入的驗證(連續輸入文字後傳送 AJAX 請求進行驗證,驗證一次就好)

四、函式防抖最終版

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

function debounce(method, wait, immediate) {
  let timeout
  // debounced函式為返回值
  // 使用Async/Await處理非同步,如果函式非同步執行,等待setTimeout執行完,拿到原函式返回值後將其返回
  // args為返回函式呼叫時傳入的引數,傳給method
  let debounced = function(...args) {
    return new Promise (resolve => {
      // 用於記錄原函式執行結果
      let result
      // 將method執行時this的指向設為debounce返回的函式被呼叫時的this指向
      let context = this
      // 如果存在定時器則將其清除
      if (timeout) {
        clearTimeout(timeout)
      }
      // 立即執行需要兩個條件,一是immediate為true,二是timeout未被賦值或被置為null
      if (immediate) {
        // 如果定時器不存在,則立即執行,並設定一個定時器,wait毫秒後將定時器置為null
        // 這樣確保立即執行後wait毫秒內不會被再次觸發
        let callNow = !timeout
        timeout = setTimeout(() => {
          timeout = null
        }, wait)
        // 如果滿足上述兩個條件,則立即執行並記錄其執行結果
        if (callNow) {
          result = method.apply(context, args)
          resolve(result)
        }
      } else {
        // 如果immediate為false,則等待函式執行並記錄其執行結果
        // 並將Promise狀態置為fullfilled,以使函式繼續執行
        timeout = setTimeout(() => {
          // args是一個陣列,所以使用fn.apply
          // 也可寫作method.call(context, ...args)
          result = method.apply(context, args)
          resolve(result)
        }, wait)
      }
    })
  }

  // 在返回的debounced函式上新增取消方法
  debounced.cancel = function() {
    clearTimeout(timeout)
    timeout = null
  }

  return debounced
}
複製程式碼

需要注意的是,如果需要原函式返回值,呼叫防抖後的函式的外層函式需要使用Async/Await語法等待執行結果返回

使用方法見程式碼:

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

let debouncedFn = debounce(square, 1000, false)

window.addEventListener('resize', async () => {
  let val
  try {
    val = await debouncedFn(4)
  } catch (err) {
    console.error(err)
  }
  // 停止縮放1S後輸出:
  // 原函式的返回值為:16
  console.log(`原函式返回值為${val}`)
}, false)
複製程式碼

具體的實現步驟請往下看

五、Debounce 的實現

1. 《JavaScript高階程式設計》(第三版)中的實現

function debounce(method, context) {
  clearTimeout(method.tId)
  method.tId = setTimeout(() => {
    method.call(context)
  }, 1000)
}

function print() {
  console.log('Hello World')
}

window.onresize = debounce(print)
複製程式碼

我們不停縮放視窗,當停止1S後,列印出Hello World。

有個可以優化的地方: 此實現方法有副作用(Side Effect),改變了輸入值(method),給method新增了屬性

2. 優化第一版:消除副作用,將定時器隔離

function debounce(method, wait, context) {
  let timeout
  return function() {
    if (timeout) {
      clearTimeout(timeout)
    }
    timeout = setTimeout(() => {
      method.call(context)
    }, wait)
  }
}
複製程式碼

3. 優化第二版:自動調整this正確指向

之前的函式我們需要手動傳入函式執行上下文context,現在優化將 this 指向正確的物件。

function debounce(method, wait) {
  let timeout
  return function() {
    // 將method執行時this的指向設為debounce返回的函式被呼叫時的this指向
    let context = this
    if (timeout) {
      clearTimeout(timeout)
    }
    timeout = setTimeout(() => {
      method.call(context)
    }, wait)
  }
}
複製程式碼

4. 優化第三版:函式可傳入引數

即便我們的函式不需要傳參,但是別忘了JavaScript 在事件處理函式中會提供事件物件 event,所以我們要實現傳參功能。

function debounce(method, wait) {
  let timeout
  // args為返回函式呼叫時傳入的引數,傳給method
  return function(...args) {
    let context = this
    if (timeout) {
      clearTimeout(timeout)
    }
    timeout = setTimeout(() => {
      // args是一個陣列,所以使用fn.apply
      // 也可寫作method.call(context, ...args)
      method.apply(context, args)
    }, wait)
  }
}
複製程式碼

5. 優化第四版:提供立即執行選項

有些時候我不希望非要等到事件停止觸發後才執行,我希望立刻執行函式,然後等到停止觸發n毫秒後,才可以重新觸發執行。

function debounce(method, wait, immediate) {
  let timeout
  return function(...args) {
    let context = this
    if (timeout) {
      clearTimeout(timeout)
    }
    // 立即執行需要兩個條件,一是immediate為true,二是timeout未被賦值或被置為null
    if (immediate) {
      // 如果定時器不存在,則立即執行,並設定一個定時器,wait毫秒後將定時器置為null
      // 這樣確保立即執行後wait毫秒內不會被再次觸發
      let callNow = !timeout
      timeout = setTimeout(() => {
        timeout = null
      }, wait)
      if (callNow) {
        method.apply(context, args)
      }
    } else {
      // 如果immediate為false,則函式wait毫秒後執行
      timeout = setTimeout(() => {
        // args是一個類陣列物件,所以使用fn.apply
        // 也可寫作method.call(context, ...args)
        method.apply(context, args)
      }, wait)
    }
  }
}
複製程式碼

6. 優化第五版:提供取消功能

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

function debounce(method, wait, immediate) {
  let timeout
  // 將返回的匿名函式賦值給debounced,以便在其上新增取消方法
  let debounced = function(...args) {
    let context = this
    if (timeout) {
      clearTimeout(timeout)
    }
    if (immediate) {
      let callNow = !timeout
      timeout = setTimeout(() => {
        timeout = null
      }, wait)
      if (callNow) {
        method.apply(context, args)
      }
    } else {
      timeout = setTimeout(() => {
        method.apply(context, args)
      }, wait)
    }
  }

  // 加入取消功能,使用方法如下
  // let myFn = debounce(otherFn)
  // myFn.cancel()
  debounced.cancel = function() {
    clearTimeout(timeout)
    timeout = null
  }
}
複製程式碼

至此,我們已經比較完整地實現了一個underscore中的debounce函式。

六、遺留問題

需要防抖的函式可能是存在返回值的,我們要對這種情況進行處理,underscore的處理方法是將函式返回值在返回的debounced函式內再次返回,但是這樣其實是有問題的。如果引數immediate傳入值不為true的話,當防抖後的函式第一次被觸發時,如果原始函式有返回值,其實是拿不到返回值的,因為原函式是在setTimeout內,是非同步延遲執行的,而return是同步執行的,所以返回值是undefined

第二次觸發時拿到的返回值其實是第一次執行的返回值,第三次觸發時拿到的返回值其實是第二次執行的返回值,以此類推。

1. 使用回撥函式處理函式返回值

function debounce(method, wait, immediate, callback) {
  let timeout, result
  let debounced = function(...args) {
    let context = this
    if (timeout) {
      clearTimeout(timeout)
    }
    if (immediate) {
      let callNow = !timeout
      timeout = setTimeout(() => {
        timeout = null
      }, wait)
      if (callNow) {
        result = method.apply(context, args)
        // 使用回撥函式處理函式返回值
        callback && callback(result)
      }
    } else {
      timeout = setTimeout(() => {
        result = method.apply(context, args)
        // 使用回撥函式處理函式返回值
        callback && callback(result)
      }, wait)
    }
  }

  debounced.cancel = function() {
    clearTimeout(timeout)
    timeout = null
  }

  return debounced
}
複製程式碼

這樣我們就可以在函式防抖時傳入一個回撥函式來處理函式的返回值,使用程式碼如下:

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

let debouncedFn = debounce(square, 1000, false, val => {
  console.log(`原函式的返回值為:${val}`)
})

window.addEventListener('resize', () => {
  debouncedFn(4)
}, false)

// 停止縮放1S後輸出:
// 原函式的返回值為:16
複製程式碼

2. 使用Promise處理返回值

function debounce(method, wait, immediate) {
  let timeout, result
  let debounced = function(...args) {
    // 返回一個Promise,以便可以使用then或者Async/Await語法拿到原函式返回值
    return new Promise(resolve => {
      let context = this
      if (timeout) {
        clearTimeout(timeout)
      }
      if (immediate) {
        let callNow = !timeout
        timeout = setTimeout(() => {
          timeout = null
        }, wait)
        if (callNow) {
          result = method.apply(context, args)
          // 將原函式的返回值傳給resolve
          resolve(result)
        }
      } else {
        timeout = setTimeout(() => {
          result = method.apply(context, args)
          // 將原函式的返回值傳給resolve
          resolve(result)
        }, wait)
      }
    })
  }

  debounced.cancel = function() {
    clearTimeout(timeout)
    timeout = null
  }

  return debounced
}
複製程式碼

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

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

let debouncedFn = debounce(square, 1000, false)

window.addEventListener('resize', () => {
  debouncedFn(4).then(val => {
    console.log(`原函式的返回值為:${val}`)
  })
}, false)

// 停止縮放1S後輸出:
// 原函式的返回值為:16
複製程式碼

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

使用方法見程式碼:

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

let debouncedFn = debounce(square, 1000, false)

window.addEventListener('resize', async () => {
  let val
  try {
    val = await debouncedFn(4)
  } catch (err) {
    console.error(err)
  }
  console.log(`原函式返回值為${val}`)
}, false)

// 停止縮放1S後輸出:
// 原函式的返回值為:16
複製程式碼

七、參考文章

JavaScript專題之跟著underscore學防抖

underscore 函式去抖的實現

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

相關文章