重新學防抖debounce和節流throttle,及react hook模式中防抖節流的實現方式和注意事項

2020-10-01 12:00:09

概念理解

防抖就是指觸發事件後在 n 秒內函數只能執行一次,如果在 n 秒內又觸發了事件,則會重新計算函數執行時間。

說起防抖大家肯定會想到節流,著兩個就跟一對雙胞胎一樣,讓大家經常傻傻搞不清楚
我們先來看一下節流的概念

節流就是指連續觸發事件但是在 n 秒中只執行一次函數。節流會稀釋函數的執行頻率

我拿電梯關門舉個例子吧:
防抖

你按了電梯關門按鈕,電梯還有三秒要關閉,在你要關閉前的1.5s,按了一次開門按鈕電梯將會重新將要關閉時間重置為3秒

節流

你按了電梯關門按鈕,在電梯將要關閉的三秒內,你再怎麼按電梯按鈕都不會響應

常用情況

防抖

在前端開發中會遇到一些頻繁的事件觸發,比如:
mousedown、mousemove
keyup、keydown

節流

在頁面的無限載入場景下,我們需要使用者在捲動頁面時,每隔一段時間發一次 Ajax 請求,而不是在使用者停下捲動頁面操作時才去請求資料。這樣的場景,就適合用節流技術來實現。

防抖實現方式

第一版

function debounce(func, wait) {
  return function () {
    let timer
    clearTimeout(timer)
    timer = setTimeout(() => {
      func
    }, wait)
  }
}

這個是最簡單的一版debounce的實現方式,但是缺點有許多,我們先來看第一個

一,this 指向問題
我們可知在setTimeout 中this 指向windows 詳情可 MDN看詳細說明

第二版(修改this問題)

function debounce(func, wait) {
  return function () {
    let timer
    const contentThis = this
    clearTImeout(timer)
    timer = setTimeout(() => {
      func.apply(contentThis)
    }, wait)
  }
}

第二版中,this指向是正常情況,但是如果func 有引數的話,就會導致引數的丟失,所以我們開啟第三版

第三版(引數丟失問題)

function debounce(func, wait) {
  return function () {
    let timer
    const contentThis = this
    let arg = arguments
    clearTImeout(timer)
    timer = setTimeout(() => {
      func.apply(contentThis, arg)
    }, wait)
  }
}

第三版完後,大家會發現還有一個問題,這個debounce 並不能立即執行,下面我們再改一下程式碼

function debounce(func, wait, immediate) {
  return function () {
    let timer = null
    const _this = this
    let arg = arguments
    if (timer) clearTImeout(timer)
    if (immediate) {
      let runNow = !tiemr
      timer = setTimeout(function () {
        timer = null
      }, wait)
      if (runNow) {
        func.apply(_this, arg)
      }
    } else {
      timer = setTimeout(() => {
        func.apply(contentThis, arg)
      }, wait)
    }
  }
}

我們還需要注意一種情況,當func 函數有返回值時我們需要將返回值帶上,但是會有一個問題,當setTimeOut this指向的問題,導致再延時器中返回的資料只能是undefined,所以我們只能再immediate上新增

第四版(返回值問題)

function debounce(func, wait, immediate) {
  return function () {
  	let result
    let timer = null
    const _this = this
    let arg = arguments
    if (timer) clearTImeout(timer)
    if (immediate) {
      let runNow = !tiemr
      timer = setTimeout(function () {
        timer = null
      }, wait)
      if (runNow) {
        result = func.apply(_this, arg)
      }
    } else {
      timer = setTimeout(() => {
        func.apply(contentThis, arg)
      }, wait)
    }
    return result
  }
}

第五版(新增防抖取消程式碼)

_.debounce = function (func, wait, immediate) {
    var timeout, result;

    var later = function (context, args) {
        timeout = null;
        if (args) result = func.apply(context, args);
    };

    var debounced = restArgs(function (args) {
        if (timeout) clearTimeout(timeout);
        if (immediate) {
            var callNow = !timeout;
            timeout = setTimeout(later, wait);
            if (callNow) result = func.apply(this, args);
        } else {
            timeout = _.delay(later, wait, this, args);
        }

        return result;
    });

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

    return debounced;
};

節流的實現方式

節流的原理我們在上面已經說過了,每隔一段時間就只執行一次事件
我們先看第一種方式

第一版(時間戳版)

function throttle(func, wait) {
  let previous = 0
  return function () {
    let now = +new Date()
    let _this = this
    let arg = arguments
    if (now - previous > wait) {
      func.apply(_this, arg)
      previous = now
    }
  }
}

使用方式如下

content.onmousemove = throttle(count,1000);

第二版(定時器版)

function throttle(func, wait) {
  return function () {
    let timer = null
    let arg = arguments
    if (!timer) {
      timer = setTimeout(() => {
        timer = null
        func.apply(context, arg)
      }, wait)
    }
  }
}

現在我們分析一下,上面兩版

第一種事件會立刻執行,第二種事件會在 n 秒後第一次執行
第一種事件停止觸發後沒有辦法再執行事件,第二種事件停止觸發後依然會再執行一次事件

所以我們將這兩版總結一下,實現一版,滑鼠移入能立即執行,但是停止觸發的時候還能再次執行一次,程式碼如下:

第三版

function throttle(func, wait) {
  let previous = 0
  let later = function () {
    previous = +new Date()
    tiemr = null
    func.apply(_this, arg)
  }
  let throttled = function () {
    let now = +new Date()
    let remaining = wait - (now - previous)
    let _this = this
    let arg = arguments
    let tiemr = null
    if (remaining <= 0 || remaining > wait) {
      if (tiemr) {
        clearTimeout(tiemr)
        timer = null
      }
      previous = now
      func.apply(_this, arg)
    } else if (!tiemr) {
      tiemr = setTimeout(later, remaining)
    }
  }
  return throttled
}

上面的程式碼還有許多侷限,比如說,我想要實現,滑鼠移入不執行,但是停止觸發的時候再執行一次的效果,或者滑鼠移入立即執行,但是停止觸發的時候不再執行的效果。對於這種情況我們應該如何去做呢?

那我們設定個 options 作為第三個引數,然後根據傳的值判斷到底哪種效果,我們約定:

leading:false 表示禁用第一次執行
trailing: false 表示禁用停止觸發的回撥

我們來優化一下程式碼

程式碼


function throttle(func, wait, options) {
  let previous = 0
  if (!options) {
    options = {}
  }

  let later = function () {
    previous = options.leading === false ? 0 : new Date().getTime()
    tiemr = null
    func.apply(_this, arg)
    if (!timeout) _this = args = null
  }

  let throttled = function () {
    let now = new Date().getTime()
    if (!previous && options.leading === false) previous = now
    let remaining = wait - (now - previous)
    let _this = this
    arg = arguments
    if (remaining <= 0 || remaining > wait) {
      if (tiemr) {
        clearTimeout(timer)
        timer = null
      }
      previous = now
      func.apply(_this, arg)
      if (!tiemr) _this = arg = null
    } else if (!tiemr && options.trailing !== false) {
      timer = setTimeout(later, remaining)
    }
  }
  return throttled
}

注意

我們要注意有這樣一個問題:

那就是 leading:false 和 trailing: false 不能同時設定。

如果同時設定的話,比如當你將滑鼠移出的時候,因為 trailing 設定為 false,停止觸發的時候不會設定定時器,所以只要再過了設定的時間,再移入的話,就會立刻執行,就違反了 leading: false,bug 就出來了,所以,這個 throttle 只有三種用法:

container.onmousemove = throttle(getUserAction, 1000);
container.onmousemove = throttle(getUserAction, 1000, {
    leading: false
});
container.onmousemove = throttle(getUserAction, 1000, {
    trailing: false
});

以上就完成了函數防抖和節流的實現

react hook 下的防抖和節流

最近我在react hook 中使用防抖和節流發現不起作用,最後發現是自己沒有掌握react hook 的機制導致的
當元件元件重新渲染的時候,hooks 會重新執行一遍,這樣debounce高階函數裡面的timer就不能起到快取的作用(每次重渲染都被置空)。timer不可靠,debounce的核心就被破壞了。
所以我們可以利用React元件的快取機制重新實現一個react hook 版的防抖和節流

react hook 防抖

import * as React from 'react'

export function useDebounce(fn, delay, dep = []) {
  const { current } = React.useRef({ fn, timer: null })
  React.useEffect(
    function () {
      current.fn = fn
    },
    [fn]
  )

  return React.useCallback(function f(...args) {
    if (current.timer) {
      clearTimeout(current.timer)
    }
    current.timer = setTimeout(() => {
      current.fn.call(this, ...args)
    }, delay)
  }, dep)
}

react hook 節流

import * as React from 'react'

export function useThrottle(fn, delay, dep = []) {
  const { current } = React.useRef({ fn, timer: null })
  React.useEffect(
    function () {
      current.fn = fn
    },
    [fn]
  )

  return React.useCallback(function f(...args) {
    if (!current.timer) {
      current.timer = setTimeout(() => {
        delete current.timer
      }, delay)
      current.fn.call(this, ...args)
    }
  }, dep)
}

react 使用防抖和節流的注意點

我在實現防抖的時候還遇到遇到一個問題,我再這一起記錄一下吧
當我在實現react中觸發input 元件中的onChange 事件時去傳送一個非同步請求,但是發現一直獲取不到value 值,程式碼如下

  const onSearchChange = (e) => {
    e.persist()
    e.preventDefault()
    e.stopPropagation()
    setSearchValue(e.currentTarget.value)
    useDebounce((e) => {
      setFilterValue(e.currentTarget.value)
    }, 500)
  }

就是setFilterValue 一直獲取不到值,最後經過調查發現了原由,
首先,在 onSearchChange 中,事件觸發,就能獲取到 event 物件,其中主要就是 event.target 就是當前觸發事件的 dom 物件,由於 useDebounce延遲執行,導致了 onSearchChange 函數已經執行完了,進入了 react-dom 中相關一系列操作(進行了一系列複雜的操作),下面給出最關鍵的 executeDispatchesAndRelease,executeDispatchesAndRelease 方法釋放 event.target 的值

        /**
 * Dispatches an event and releases it back into the pool, unless persistent.
 *
 * @param {?object} event Synthetic event to be dispatched.
 * @private
 */
        var executeDispatchesAndRelease = function(event) {
            if (event) {
                executeDispatchesInOrder(event);
                if (!event.isPersistent()) {
                    event.constructor.release(event);
                }
            }
        };

由於 event 在 useDebounce中作為了引數,記憶體中沒有清除,執行上面的方法 event.target = null; event 為參照型別,一處改變,所有用到的地方都會改變。導致了 useDebounce中 event 也發生了變化。
解決辦法:

  const onSearchChange = (e) => {
    e.persist()
    e.preventDefault()
    e.stopPropagation()
    setSearchValue(e.currentTarget.value)
    handleSearchChange(e.currentTarget.value)
  }

  const handleSearchChange = useDebounce((value) => {
    setFilterValue(value)
  }, 500)

參考檔案:
JavaScript專題之跟著 underscore 學節流
React hooks 怎樣做防抖?