防抖和節流

前言

  經常聽到防抖和節流這兩個名詞,但一直只是有個粗淺的認識而已,如今終於騰出時間來一探這二者的究竟啦。先用一句話歸納這二者:防抖和節流都是爲了限制函數的執行頻率,以優化函數觸發頻率太高致使的響應速度跟不上觸發頻率,出現頁面延遲、假死或卡頓的現象。主要應用於一些會被頻繁觸發的事件中,例如input輸入框的監聽,resizescrollmousemovekeyup等。git

  下面分別介紹防抖和節流的異同和實現方式,其中的函數實現主要參照underscore,再根據本身的一些理解寫成的,諸位看官能夠翻讀underscore的源碼。github

防抖

  防抖其實就是當觸發一個事件後,若是在指定時間 n 內再次觸發該事件,則以新的觸發時間爲準從新計算執行時間,保證每次觸發都會等到 n 秒後才執行而且不會累計觸發(持續觸發的狀況下只會執行一次)。bash

  舉個栗子,咱們經常會給輸入框綁定一個input監聽函數,根據用戶的輸入內容來發送Ajax請求實時顯示搜索結果。但這樣有個問題,每次用戶敲一下鍵盤都會觸發到監聽函數,形成監聽函數被頻繁地觸發到了(尤爲是輸入中文的時候),若是監聽函數是發送Ajax請求的話那簡直就是場災難。這時候防抖就能夠派上用場了,咱們設置一個定時器setTimeout讓監聽函數在指定的延遲時間好比 1s 後才執行,若是在定時器計時的這 1s 內再次觸發了事件,則清除以前的定時器從新開始計時,保證監聽函數必定會在每次觸發的 1s 後才執行。這樣監聽input事件時就不會頻繁地觸發到監聽函數了,大大優化了頁面性能。其餘的監聽事件如scrollmousemove也是如此。app

  咱們先來看看普通的input監聽函數的執行狀況,從下圖中能夠看到我只是輸入短短的 「世界很溫柔」 五個字,監聽函數卻執行了多達 17 次。能夠戳這裏進行實驗函數

  下面咱們先來實現一個簡單版的防抖,抓住每次觸發事件時都會先清除掉以前的定時器再開始計時,咱們不難寫出下面這幾行代碼。須要注意的是要使用applycall來顯示綁定監聽函數(如下簡稱fn)的 this 爲當前綁定監聽函數的 DOM 對象,不然fn裏的 this 會指向 window。性能

function debounce(fn, wait) {
  let timer = null;
  return function(...arg) {
    // 爲fn綁定this指向
    let context = this;
    // 先清空定時器再從新開始計時
    clearTimeout(timer);
    timer = setTimeout(function() {
      fn.apply(context, arg);
    }, wait)
  }
}
複製代碼

  上面基本版的防抖,是每次觸發事件後都會等過了指定的延遲時間才執行 fn,但有時候咱們須要讓事件觸發的時候立刻執行 fn,因此咱們能夠給debounce函數增長第三個參數 immediate 來選擇是否觸發事件後立刻執行 fn。當 immediate 爲 true 時,第一次觸發事件的時候 timer 由於被初始化成了 null,因此會立刻執行 fn。而每一次觸發的時候都會從新將 timer 指向一個定時器,使 timer 在指定的延遲時間後重置爲 null,從而控制 fn 能夠再次執行。當 immediate 爲 false 時,則和上文的基本版防抖同樣。修改後的代碼以下,實現效果能夠戳這裏查看優化

function debounce(fn, wait, immediate) {
  let timer = null;
  return function(...arg) {
    let context = this;
    if(timer)  clearTimeout(timer);
    // 觸發後當即執行
    if(immediate) {
      // 若是兩次觸發的間隔小於wait,此時timer還不爲null,不執行fn
      if(!timer) {
        fn.apply(context, arg);
      }
      // wait時間後把timer從新設置爲null,表示能夠再次執行fn了
      timer = setTimeout(function() {
        timer = null;
      }, wait)
    }
    // 觸發後延時執行
    else {
      timer = setTimeout(function() {
        fn.apply(context, arg);
      }, wait);
    }
  }
}
複製代碼

節流

  說完防抖咱們再說說節流,節流和防抖功能大體相同,不一樣點在於若是持續觸發一個事件,防抖只會執行一次 fn,而節流則是每隔指定的時間就執行一次,保證每次執行 fn 的間隔時間相同。防抖和節流均可以下降函數的執行頻率,而實際寫代碼的時候是選擇防抖仍是節流,還得要取決於具體的需求。ui

  節流有兩種實現方式,一種是使用時間戳,另外一種是使用定時器。使用時間戳的話,由於開始時間被初始化爲 0,因此第一次觸發時會立刻執行 fn,而且中止觸發後不會再執行。而使用定時器的話,第一次觸發則會延遲執行,而中止觸發後由於還有定時器的存在,因此會再執行一次 fn。下面給出兩種實現方式的代碼。this

// 使用時間戳
function throttle(fn, wait) {
  let startTime = 0;
  return function(...arg) {
    let now = new Date();
    // 當前觸發和上一次觸發的時間間隔
    if(now - startTime > wait) {
      fn.apply(this, arg);
      startTime = now;
    }
  }
}
複製代碼
// 使用定時器
function throttle(fn, wait) {
  let timer = null;
  return function(...arg) {
    let context = this;
    if(timer === null) {
      timer = setTimeout(function() {
        timer = null;
        // 實際上只要把fn執行代碼放到setTimeout外就可使第一次觸發當即執行了,但中止觸發後就不會再執行一次了
        fn.apply(context, arg);
      }, wait)
    }
  }
}
複製代碼

  若是結合二者,就能夠設置一個第一次觸發就可以立刻執行,最後一次觸發後還能再執行一次的節流函數了。這裏主要是使用一個 remaining 來計算還剩下多少時間纔可以執行 fn,若是 remaining 小於等於 0,則說明距離上一次的 fn 執行時間已超出指定的間隔時間,此時能夠再次執行 fn。若是 remaining 大於 0,則說明當前尚未達到間隔時間,若此時沒有定時器在計時等待執行 fn,則設置一個定時器在稍後執行 fn,不然再也不從新設置定時器。spa

function throttle(fn, wait) {
  let startTime = 0;
  let timer = null;
  return function(...arg) {
    let context = this;
    let now = new Date();
    // remaining表示還剩下多少時間就能執行fn
    let remaining = wait - (now - startTime);
    // 爲了第一次觸發可以立刻執行
    if(remaining <= 0) {
      // 須要先清空定時器,不然會重複執行
      if(timer) {
        clearTimeout(timer);
        timer = null;
      }
      fn.apply(context, arg);~
      startTime = now;
    }
    // 爲了最後一次觸發還可以再執行一次
    else {
      // 若是不能立刻執行fn而且定時器爲空(表示前面沒有定時器在計時)纔開始計時等待執行
      if(timer === null) {
        timer = setTimeout(function() {
          fn.apply(context, arg);
          timer = null;
          startTime = new Date();
        }, remaining)
      }
    }
  }
}
複製代碼

  再進一步,咱們還能夠跟防抖同樣,給節流函數設置第三個參數來傳入一個對象,其中 leading 屬性表示第一次觸發是否立刻執行 fn,trailing 屬性表示最後一次觸發是否會再執行一次 fn。咱們先看看具體的實現代碼,註釋部分表示和上面代碼的不同之處。

function throttle(fn, wait, options) {
  // 若沒有傳遞options,則默認二者都開啓
  if(!options) {
    options = {
      leading: true,
      trailing: true
    }
  }
  let startTime = 0;
  let timer = null;
  return function(...arg) {
    let context = this;
    let now = new Date();
    // 若設置第一次觸發不立刻執行,則將開始時間設置爲當前時間
    if(startTime === 0 && options.leading === false) {
      startTime = now;
    }
    let remaining = wait - (now - startTime);
    if(remaining <= 0) {
      if(timer) {
        clearTimeout(timer);
        timer = null;
      }
      fn.apply(context, arg);
      startTime = now;
    }
    else {
      // 若是設置最後一次觸發再也不執行的話,則直接使用時間戳法就夠了
      if(timer === null && options.trailing === true) {
        timer = setTimeout(function() {
          fn.apply(context, arg);
          timer = null;
          // 須要將開始時間置爲0,才能在下一次觸發時將開始時間置爲now,即不立刻執行
          startTime = options.leading === false ? 0 : new Date();
        }, remaining)
      }
    }
  }
}
複製代碼

  這裏的實現思想是,經過是否重置初始的 startTime 爲觸發的當前時間來控制第一次觸發是否立刻執行 fn。若是最後一次觸發再也不執行一次 fn,則直接使用時間戳法就夠了,不然還得使用定時器。可能定時器裏這句startTime = options.leading === false ? 0 : new Date()不太容易理解,這裏解釋一下。當一次持續觸發事後,startTime 還會保留着上次的執行時間,若是等一會我再次觸發它,由於此時 remaining 小於等於 0,因此第二次觸發會立刻執行。所以當設置了 options.leading 爲 false 時就須要把 startTime 置爲 0,才能使第二次觸發可以把跟第一次觸發同樣把 startTime 重置爲當前觸發時間,從而不立刻執行。

  須要注意的是,leading 和 trailing 二者不能同時設置爲 false,不然第二次觸發時仍是會立刻執行 fn。這是由於當 trailing 爲 false 時,執行事件沒有使用到定時器。也就同上面解釋的,最後一次執行後沒有把 startTime 置爲 0,startTime 仍是保留着上次的執行時間。致使下一次觸發時 remaining 小於等於 0 因此會當即執行 fn。

  不得不說這是一個很大的 bug,在 underscore 的 issues 裏也有人提出過這個問題,但做者彷佛不理解爲何要把 leading 和 trailing 都設置爲 false,做者的原意就是 leading 和 trailing 必須至少一個爲 true。做者原話:

Why are both leading and trailing false? One of them should be true.

  emmm,我不太理解做者的想法,難道不該該考慮二者都爲 false 的狀況嗎?好吧,我本身想了下,發現要解決這個問題的關鍵就在於如何在一次持續觸發結束後把 startTime 重置爲 0,但我想來想去仍是沒想明白要在哪裏重置 startTime,新設置一個定時器來重置它好像也行不通。如何區分同一次的持續觸發和不一樣次的持續觸發,纔好重置 startTime?

  讀者能夠在這裏進行實驗,設置不一樣的 leading 和 trailing 值來觀察差異。

相關文章
相關標籤/搜索