效能優化之防抖和節流

落落落洛克發表於2019-07-13

最近公司做一個內部的搜尋系統,因為搜尋框需要搜尋功能需要頻繁的去傳送請求,這很明顯的會給伺服器帶來壓力,那麼這時候就要用到防抖和節流的知識點了

防抖函式debounce

效能優化之防抖和節流

比如我們的百度搜尋,搜尋的時候有關鍵字的提醒,關鍵字的來源來自於客戶端向服務端請求得到的資料,我們通過keyup事件去監聽觸發請求,如果返回值的搜尋key已經不是搜尋框現在的值,就丟棄掉這次返回,於是我們提出這樣一個優化需求觸發事件,但是我一定在事件觸發n秒後才執行,如果你在一個事件觸發的n秒內又觸發了這個事件,那我就以新的事件的時間為準,n 秒後才執行

那麼我們來動手實現我們的第一版的防抖函式吧:

function debounce(fn,wait){
     let timeout
     return function(){
         if(timeout){
             clearTimeout(timeout)
         }
        timeout=setTimeout(fn,wait)
     }
}
複製程式碼

第一版程式碼已經實現了,我們來寫個demo驗證下吧:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Document</title>
    <style>
      #search {
        width: 400px;
        margin-left: 300px;
        margin-bottom: 50px;
      }
      #showSearch {
        width: 800px;
        height: 100px;
        background: lightblue;
        color: red;
        margin-left: 300px;
      }
    </style>
  </head>
  <body>
    <input type="search" name="" id="search" />
    <div id="showSearch"></div>
  </body>
  <script>
    function debounce(fn, wait) {
      let timeout;
      return function() {
        if (timeout) {
          clearTimeout(timeout);
        }
        timeout = setTimeout(fn, wait);
      };
    }
    let search = document.querySelector("#search");
    let showSearch = document.querySelector("#showSearch");
    function getSearchInfo(e) {
       // showSearch.innerText = this.value; // 報錯
      //  console.log(e); // undefined
      showSearch.innerText = search.value;
    }
    search.onkeyup = debounce(getSearchInfo, 1000);
  </script>
</html>

複製程式碼

效果圖:

效能優化之防抖和節流
可以看到執行上面的demo,我們輸入值以後不觸發keyup事件就會隔1秒鐘藍色div才會出現字,如果你一直觸發是不會顯示的等到你停止觸發後的一秒後才顯示

有同學可能對第一版程式碼覺得挺簡單的,確實簡單,不過我還是要囉嗦幾句,第一版的程式碼中,debounce函式返回了一個匿名函式,而這匿名函式又引用了timeout這個變數,這樣就形成了閉包,也就是函式的執行上下文活動物件並不會被銷燬,儲存了timeout變數,才能實現我們這個如果你在一個事件觸發的n秒內又觸發了這個事件,那我就以新的事件的時間為準,n 秒後才執行的需求

修復this指向和event問題

顯然上述中的程式碼還存留了二個問題就是this問題,我們this指向的不是window物件,而是指向我們的dom物件,第二個就是event物件,event物件在這裡會報undefined,那麼接下來我們來完善我們的第二版程式碼吧:

第二版本的防抖函式

 function debounce(fn, wait) {
      let timeout;
      return function() {
        let context = this;
        let args = arguments;
        if (timeout) {
          clearTimeout(timeout);
        }
        timeout = setTimeout(function() {
          fn.apply(context, args);
        }, wait);
      };
    }
複製程式碼

第二版程式碼已經實現了,我們來寫個demo驗證下吧:

let search = document.querySelector("#search");
    let showSearch = document.querySelector("#showSearch");
    function getSearchInfo(e) {
      showSearch.innerText = this.value; 
      console.log(e); 
    }
    search.onkeyup = debounce(getSearchInfo, 1000);
複製程式碼

效果圖:

效能優化之防抖和節流

這裡涉及的知識點就是this指向arguments、apply等,先來說說arguments屬性,我們知道可以通過arguments屬性獲取函式的引數值,而dom事件操作中,會給回撥函式(這裡回撥函式是debounce函式返回的閉包函式)傳遞一個event引數,這樣我們就可以通過arguments屬性拿到event屬性,那麼問題二就解決啦,再來說說問題一的this指向,此時這裡keyup事件的回撥函式是debounce函式返回的閉包函式而不是getSearchInfo函式(但是我們希望處理業務邏輯的getSearchInfo的this指向能夠指向dom物件),我們知道this的指向是我最後被誰呼叫,我就指向誰,那麼我這裡被search呼叫所以this指向search,但是由於有setTimeout非同步操作,我們getSearchInfo函式的this指向是window(非嚴格模式下),所以我們需要改變getSearchInfo的指向,這樣我們用apply就完美實現了

立即執行

這時候我們開發的問題解決了,但是萬惡的產品又提出了一個新的需求方案:我不希望非要等到事件停止觸發後才執行,我希望立刻執行函式,然後等到停止觸發 n 秒後,才可以重新觸發執行,那我們就來實現這個新需求吧

// 引數immediate值 true||false
    function debounce(fn, wait, immediate) {
      let timeout;
      return function() {
        let context = this;
        let args = arguments;
        if (timeout) {
          clearTimeout(timeout);
        }
        let callNow = !timeout;
        if (immediate) {
          // 已經執行過,不再執行
          timeout = setTimeout(function() {
            timeout = null;
          }, wait);
          if (callNow) fn.apply(context, args);
        } else {
          timeout = setTimeout(function() {
            fn.apply(context, args);
          }, wait);
        }
      };
    }
複製程式碼

demo使用:

    search.onkeyup = debounce(getSearchInfo, 100, true);
複製程式碼

效果圖如下:

效能優化之防抖和節流

取消功能

雖然我們的防抖函式已經很完善了,但是我們希望它能支援取消的功能,那接著來完善我們的程式碼吧

第三版本的防抖函式


    // 引數immediate值 true||false
    function debounce(fn, wait, immediate) {
      let timeout;
      let debounced = function() {
        let context = this;
        let args = arguments;
        if (timeout) {
          clearTimeout(timeout);
        }
        let callNow = !timeout;
        if (immediate) {
          // 已經執行過,不再執行
          timeout = setTimeout(function() {
            timeout = null;
          }, wait);
          if (callNow) fn.apply(context, args);
        } else {
          timeout = setTimeout(function() {
            fn.apply(context, args);
          }, wait);
        }
      };
      debounced.cancel = function() {
        clearTimeout(timeout);
        timeout = null;
      };
      return debounced
    }
複製程式碼

修復result bug

到此,我們的防抖函式基本實現了,但是細心的同學可能會發現,如果fn函式引數存在return值,我們需要去接收它,那麼來修復這個小Bug吧

第四版本的防抖函式

// 引數immediate值 true||false
    function debounce(fn, wait, immediate) {
      let timeout, result;
      let debounced = function() {
        let context = this;
        let args = arguments;
        if (timeout) {
          clearTimeout(timeout);
        }
        let callNow = !timeout;
        if (immediate) {
          // 已經執行過,不再執行
          timeout = setTimeout(function() {
            timeout = null;
          }, wait);
          if (callNow) {
            result = fn.apply(context, args);
          }
        } else {
          timeout = setTimeout(function() {
            result = fn.apply(context, args);
            console.log(result);
          }, wait);
        }
      };
      debounced.cancel = function() {
        clearTimeout(timeout);
        timeout = null;
      };
      return debounced;
    }
複製程式碼

到這裡我們的防抖函式已經實現了,大家可以動手實現下

防抖函式的總結

上面羅裡吧嗦的說了一堆,實際上可以精簡成兩個需求

  • 非立即執行:,如果你在一個事件觸發的n秒內又觸發了這個事件,那我就以新的事件的時間為準,n 秒後才執行
  • 立即執行:我不希望非要等到事件停止觸發後才執行,我希望立刻執行函式,然後等到停止觸發 n 秒後,才可以重新觸發執行

我們可以按照立即執行和非立即執行這兩個需求去理解我們的防抖函式

節流 throttle

持續觸發事件,每隔一段時間,只執行一次事件。

節流相對來說還是很好理解了,當然了他也有跟防抖函式一樣有立即執行和非立即執行兩個需求,那我們實現throttle函式的程式碼吧

立即執行

我們可以使用時間戳,當觸發事件的時候,我們取出當前的時間戳,然後減去之前的時間戳(最一開始值設為 0),如果大於設定的時間週期,就執行函式,然後更新時間戳為當前的時間戳,如果小於,就不執行

function throttle(fn, wait) {
      let context, args;
      let previous = 0;
      return function() {
        let now = +new Date();
        context = this;
        args = arguments;
        if (now - previous > wait) {
          fn.apply(context, args);
          previous = now;
        }
      };
    }
複製程式碼

demo使用:

demo跟上述防抖用是一樣

    search.onkeyup = throttle(getSearchInfo, 3000);
複製程式碼

這裡的節流立即執行版跟我們的防抖第一版的知識點原理一樣,不再過多的贅述

非立即執行

非立即執行就是過了n秒後我們再執行,那麼我們自然而然想到定時器

function throttle(fn, wait) {
      let timeout;
      return function() {
        let context = this;
        let args = arguments;
        // 這裡不需要清除定時器 清除了會重新計算時間
        // 清除這個定時器不代表timeout為空
        if (timeout) {
          return false;
        }
        timeout = setTimeout(function() {
          fn.apply(context, args);
          timeout = null;
        }, wait);
      };
    }
複製程式碼

demo使用

    function getSearchInfo(e) {
      console.log(this.value);
      // 驗證的效果是 showSearch多久才能讀到這個this.value
      showSearch.innerText = this.value;

    }
    search.onkeyup = throttle(getSearchInfo, 3000);
複製程式碼

這次的效果是等待過了三秒才執行,那我們來比較立即執行和非立即執行的效果

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

一統立即執行和非立即執行

我們的需求想要這樣:我觸發這個事件想要立即執行,事件停止觸發後再執行一遍,也就是n秒內,假設我觸發事件大於等於兩次先會立即執行,最後會再觸發一次

 function throttle(fn, wait) {
      let timeout, remaining, context, args;
      let previous = 0;
      // timeout等於null,代表定時器已經完成
      // 最後觸發的函式
      let later = function() {
        previous = +new Date();
        timeout = null;
        fn.apply(context, args);
        console.log("最後執行的");
      };
      let throttled = function() {
        context = this;
        args = arguments;
        let now = +new Date();
        // 下次觸發fn剩餘的時間
        remaining = wait - (now - previous);
        // 代表我這個定時器執行完了 那麼就執行n秒後(比如:3~6秒)的事件操作
        // 如果沒有剩餘的時間
        if (remaining <= 0) {
          if (timeout) {
            clearTimeout(timeout);
            timeout = null;
          }
          previous = now;
          // 立即執行
          fn.apply(context, args);
        } else if (!timeout) {
          timeout = setTimeout(later, remaining);
        }
      };
      return throttled;
    }
複製程式碼

效果圖:

效能優化之防抖和節流

包含立即執行和非立即執行

有時候我們想要一種情況就行,想要立即執行不需要再去執行一次,或者想要最後執行一次,不需要立即執行,那我們對上述做個相容

實現方式是定義一個options物件,禁止立即執行或者非立即執行

  • immediate:false 表示禁用第一次執行
  • last: false 表示禁用停止觸發的回撥
    function throttle(fn, wait, options) {
      let timeout, remaining, context, args;
      let previous = 0;
      // timeout等於null,代表定時器已經完成
      // 如果沒有傳options預設為空
      if (!options) {
        options = {}; // 雖然沒有宣告options, 相當於window.options={}
      }
      let later = function() {
        // previous = +new Date();
        previous = options.immediate == false ? 0 : new Date().getTime(); // +new Date() 等同於:new Date().getTime()
        timeout = null;
        fn.call(context, args);
        console.log("最後執行的");
      };
      let throttled = function() {
        context = this;
        args = arguments;
        let now = +new Date();
        if (!previous && options.immediate === false) {
          previous = now;
        }
        // 下次觸發 func 剩餘的時間
        remaining = wait - (now - previous);
        // 代表我這個定時器執行完了 那麼就執行n秒後(比如:3~6秒)的事件操作
        // 如果沒有剩餘的時間了
        if (remaining <= 0) {
          if (timeout) {
            clearTimeout(timeout);
            timeout = null;
          }
          previous = now;
          // 立即執行
          fn.apply(context, args);
        } else if (!timeout && options.last !== false) {
          timeout = setTimeout(later, remaining);
        }
      };
      return throttled;
    }
複製程式碼

demo使用:

search.onkeyup = throttle(getSearchInfo, 3000, { immediate: true, last: false });
複製程式碼

優化throttle函式

上述例子中我們使用了閉包,而閉包所引用的變數挺多的,但是一直沒有被gc回收,我們來手動回收下這些變數

function throttle(fn, wait, options) {
      let timeout, remaining, context, args;
      let previous = 0;
      // timeout等於null,代表定時器已經完成
      // 如果沒有傳options預設為空
      if (!options) {
        options = {}; // 雖然沒有宣告options, 相當於window.options={}
      }
      let later = function() {
        // previous = +new Date();
        previous = options.immediate == false ? 0 : new Date().getTime(); // +new Date() 等同於:new Date().getTime()
        timeout = null;
        fn.call(context, args);
        if (!timeout) context = args = null;
        console.log("最後執行的");
      };
      let throttled = function() {
        context = this;
        args = arguments;
        let now = +new Date();
        if (!previous && options.immediate === false) {
          previous = now;
        }
        // 下次觸發 func 剩餘的時間
        remaining = wait - (now - previous);
        // 代表我這個定時器執行完了 那麼就執行n秒後(比如:3~6秒)的事件操作
        // 如果沒有剩餘的時間了
        if (remaining <= 0) {
          if (timeout) {
            clearTimeout(timeout);
            timeout = null;
          }
          previous = now;
          // 立即執行
          fn.apply(context, args);
          if (!timeout) context = args = null;
        } else if (!timeout && options.last !== false) {
          timeout = setTimeout(later, remaining);
        }
      };
      return throttled;
    }
複製程式碼

取消功能

我們再擴充個取消功能,取消功能實際就是取消定時器,讓它在這一次事件觸發中立即執行

function throttle(fn, wait, options) {
      let timeout, remaining, context, args;
      let previous = 0;
      // timeout等於null,代表定時器已經完成
      // 如果沒有傳options預設為空
      if (!options) {
        options = {}; // 雖然沒有宣告options, 相當於window.options={}
      }
      let later = function() {
        // previous = +new Date();
        previous = options.immediate == false ? 0 : new Date().getTime(); // +new Date() 等同於:new Date().getTime()
        timeout = null;
        fn.call(context, args);
        if (!timeout) context = args = null;
        console.log("最後執行的");
      };
      let throttled = function() {
        context = this;
        args = arguments;
        let now = +new Date();
        if (!previous && options.immediate === false) {
          previous = now;
        }
        // 下次觸發 func 剩餘的時間
        remaining = wait - (now - previous);
        // 代表我這個定時器執行完了 那麼就執行n秒後(比如:3~6秒)的事件操作
        // 如果沒有剩餘的時間了
        if (remaining <= 0) {
          if (timeout) {
            clearTimeout(timeout);
            timeout = null;
          }
          previous = now;
          // 立即執行
          fn.apply(context, args);
          if (!timeout) context = args = null;
        } else if (!timeout && options.last !== false) {
          timeout = setTimeout(later, remaining);
        }
      };
      throttled.cancel = function() {
        clearTimeout(timeout);
        timeout = null;
        previous = 0;
      };
      return throttled;
    }
複製程式碼

到這裡我們的節流函式就基本實現完了,接下來那聊聊防抖函式和節流函式的區別吧

防抖和節流的區別

實際上防抖和節流都是限制一些頻繁的事件觸發window 的 resize、scroll、mousedown、mousemove、keyup、keydown),但他們還是有實質性的區別的

  • 防抖是雖然事件持續觸發,但只有等事件停止觸發後n秒才執行函式(如果你在時間內重新觸發事件,那麼時間就重新算,這是防抖的特點,可以按照這個特點選擇場景)。比如生活中的坐公交,就是一定時間內,如果有人陸續刷卡上車,司機就不會開車。只有別人沒刷,司機才開車。

  • 節流是持續觸發的時候,每隔n秒執行一次函式比如人的眨眼睛,就是一定時間內眨一次。這是函式節流最形象的解釋

總結

防抖函式和節流函式涉及到的知識點很多,他們以接收一個函式為引數,實際就是一個高階函式,也用到了閉包,this指向,apply等知識點,當然函式的實現是參考大佬的部落格,我這裡就是做下記錄以及總結

相關文章