老生常談-節流函式

miniGean發表於2018-01-08

節流函式算是老生常談了,專案裡面必備的基礎函式,但你真的用對了嗎?深入研究了一下,發現自己用的確實不太對。首先了解一下節流函式的概念

節流函式:顧名思義,像節流閥一樣均勻的執行,把密集的重複呼叫,通過設定定時器,使函式在固定的間隔時間呼叫,在一段時間內,等時間間隔的呼叫多次,這裡跟的bounce函式一定要區分開。

debounce: 中文意思,防反跳,防止密集的重複請求,即一定時間間隔內的密集請求都丟棄,等待間隔時間後不再請求再去執行,在一段時間內,可能只呼叫一次。

分清楚這兩個概念之後,先看一段程式碼:

const generateThrottle = function (throttleTime) {
  let time = Date.now()
  return function (now) {
    // 如果沒有設定節流時間, 使用預設配置的時間
    if (now - time > (throttleTime || AppConfig.THROTTLE_TIME)) {
      time = now
      return true
    }
  }
}

let resizeThrottle = generateThrottle()
// 監聽視窗resize
window.addEventListener('resize', event => {
  // 釋出滾動事件
  resizeThrottle(Date.now()) && dosomething()
})
複製程式碼

這是我們專案裡面的節流函式,咋一看沒啥問題,直到專案出現了一個很偶現的bug,最後發現是dosomething()這個函式偶爾會不執行引起的。我們再來仔細看一下程式碼,當時間間隔大於THROTTLE_TIME時函式返回ture,否則就不返回,如果最後一次縮放視窗和上次時間間隔小於THROTTLE_TIME,然後停止縮放,那麼這個時候dosomething()就不執行了,最後導致dosomething的狀態還停留在上一次縮放,導致bug。所以當你希望停止縮放後再執行一次dosomething(),那麼上面這個函式顯然是不合理的!

然後我們重寫了這個節流函式如下:

/**
 * 首次呼叫建立一個定時器,最多每隔 mustRunDelay 時間呼叫一次該函式
 * @param fn            執行函式
 * @param delay         時間間隔
 * @param mustRunDelay  必然觸發執行的時間間隔
 * @returns {Function}
 */
var throttle = function(fn, delay, mustRunDelay) {
  var timer = null;
  var previous;
  return function() {
    var context = this, args = arguments, now = +new Date();
    clearTimeout(timer);
    if(!previous) {
      previous = now;
    }
    if(now - previous >= mustRunDelay){
      fn.apply(context, args);
      previous = now;
    } else {
      timer = setTimeout(function(){
        fn.apply(context, args)
      }, delay)
    }
  }
}
複製程式碼
function testFn () {
    console.log(111111)
}
window.onresize = throttle(testFn, 50, 1000)
複製程式碼

這個函式首次呼叫會建立一個定時器,當重複呼叫的時候會不斷的清空並重置定時器,當時間間隔大於等於mustRunDelay時,呼叫該函式,並更新時間戳。這樣就保證了函式每隔mustRunDelay後重復呼叫該函式,並且在最後一次觸發之後的delay時間後最後一次執行該函式。這個函式就基本能滿足我們業務的需求了。如果你想首次呼叫能夠立即執行該函式或者最後一次呼叫並不觸發該函式,那麼繼續往下看。

/**
 * 建立並返回一個像節流閥一樣的函式,當重複呼叫函式的時候,最多每隔 wait毫秒呼叫一次該函式
 * @param func 執行函式
 * @param wait 時間間隔
 * @param options 如果你想禁用第一次首先執行的話,傳遞{leading: false},
 *                如果你想禁用最後一次執行的話,傳遞{trailing: false}
 * @returns {Function}
 */
function throttle (fn, wait, options) {
  var context, args, result;
  var timeout = null;
  var previous = 0;
  if (!options) options = {};
  
  var later = function() {
    previous = options.leading === false ? 0 : new Date().getTime();
    timeout = null;
    result = fn.apply(context, args);
    contxt = args = null;
  }
  
  return function(){
    var now = new Date().getTime();
    if(!previous && options.leading === false) previous = now;
    var remaining = wait - (now - previous);
    context = this;
    args = arguments;
    if(remaining <=0 || remaining > wait) {
      if (timeout){
        clearTimeout(timeout);
        timeout = null;
      }
      previous = now;
      result = fn.apply(context, args);
      if(!timeout) context = args = null;
    } else if(!timeout && options.trailing !== false){
      timeout = setTimeout(later, remaining)
    }
    return result;
  }
}
複製程式碼
function testFn () {
   console.log(111111)
}
window.onresize = throttle(testFn, 200, {leading:false})
複製程式碼

這個節流函式就相當的完美了,預設是首次立即執行fn,最後一次呼叫之後的wait時間後執行,當重複呼叫的時候,最多每隔wait時間間隔呼叫一次fn。也可以通過傳參來禁用第一次執行和最後一次執行。

debounce函式

然後我們再來看看高程上面的節流函式,函式每次呼叫的時候先清空定時器,再設定一個新的定時器,當視窗縮放的時候會不斷的觸發throttle,這個時候實際上fn只有在最後一次觸發throttle後100ms執行,而且只執行一次。所以這個並不是真正意義上面的節流函式。而是一個debounce函式,丟棄一些密集重複的操作。

function throttle (method, context) {
    clearTimeout(method.tId);
  	method.tId = setTimeout(function(){
      method.call(context)
    }, 100)
}

function fn () {
    console.log(111111)
}
window.onresize = function(){
    throttle(fn);
}
複製程式碼

下面這個函式寫法不一樣,其實結果也是一樣,只執行一次

let throttle = function(fn, delay){
  let timer = null;
  return function(){
    let content = this,
      args = arguments;
    clearTimeout(timer);
    timer = setTimeout(function(){
      fn.apply(content, args)
    }, delay)
  }
}
複製程式碼
function testFn () {
    console.log(111111)
}
window.onresize = throttle(testFn, 1000)
複製程式碼

debounce函式的使用場景很多,比如使用者輸入驗證,在使用者的輸入過程中不斷請求可以都丟棄,停止輸入後在進行驗證。 再比如下拉重新整理,如何第一次請求的時候執行,多次重複請求的話,直接丟棄掉,停止操作後再進行請求。像這種重新整理需要立即請求的情況上面的函式就滿足不了了,然後就有了下面的函式

/**
 * 防反跳   fn函式在最後一次呼叫時刻的wait毫秒之後執行!
 * @param  fn 執行函式
 * @param  wait 時間間隔
 * @param  immediate 為true,debounce會在wait 時間間隔的開始呼叫這個函式
 * @returns {Function}
 */
function debounce(fn, wait, immediate) {
  var timeout, args, context, timestamp, result;
  var later = function(){
    var last = new Date().getTime() - timestamp;
    if (last < wait && last >= 0) {
        timeout = setTimeout(later, wait - last);
    } else {
      timeout = null;
      if(!immediate) {
         result = fn.apply(context, args);
         context = args = null;
      }
    }
  }
  return function() {
    context = this;
    args = arguments;
    timestamp = new Date().getTime(); //每次觸發時更新
    var callNow = immediate && !timeout;
    if(!timeout) {
      timeout = setTimeout(later, wait);
    }
    if (callNow) {
      result = fn.apply(context, args);
      context = args = null;
    }
    return result;
  }
}
複製程式碼
function testFn () {
   console.log(111111)
}
window.onresize = debounce(testFn, 300)
複製程式碼

1.當immediate 為true時,立即執行fn,wait時間間隔內頻繁呼叫,不斷的重置定時器,並不會觸發fn,wait時間之後通過將定時器清空,等待wait間隔之後再次呼叫,則立即執行fn。之後如此迴圈往復。

使用場景:比如微博下拉重新整理,首次下拉請求,中間頻繁的下拉並不會觸發請求,一定時間後再重新整理會重新觸發

2.當immediate 為false,不會立即執行該函式,wait 時間間隔內頻繁呼叫,不斷重置定時器,wait 時間間隔後,如果無觸發,則清空定時器,觸發fn,只執行一次fn;

使用場景:使用者輸入驗證,不在輸入過程中處理,停止輸入後進行驗證

相關文章