js面試(防抖)

平平丶淡淡發表於2024-03-17

一、什麼是防抖

防抖(Debounce)是一種用於減少特定事件觸發頻率的技術。在程式設計中,它通常用於確保函式或方法不會在很短的時間內被頻繁呼叫,這有助於最佳化效能並避免不必要的計算或操作。

防抖的實現原理是,在事件被觸發後,一個定時器會被設定。如果在定時器完成之前,相同的事件再次被觸發,那麼原來的定時器會被取消,並重新設定一個新的定時器。這樣,只有在最後一次事件觸發後的一定時間內沒有再次觸發,定時器才會執行其回撥函式。

應用場景:

  1. 登入與傳送簡訊:在連續點選登入按鈕或傳送簡訊時,防抖技術能夠確保不會因使用者點選過快而傳送多次請求。
  2. 表單驗證:在使用者輸入表單資訊時,防抖技術可以確保不會因為頻繁觸發驗證邏輯而導致效能降低。
  3. 實時搜尋與儲存:在文字編輯器或搜尋框中實現實時搜尋和儲存功能時,防抖技術可以確保在使用者停止輸入一段時間後執行搜尋或儲存操作,避免使用者連續輸入導致的頻繁觸發。
  4. 視窗大小調整:在調整瀏覽器視窗大小時,resize事件可能會被頻繁觸發,防抖技術可以確保只執行一次操作,避免不必要的計算。
  5. 滑鼠移動事件:實現一些需要使用者停止移動滑鼠後再執行的功能時,如拖拽功能,防抖技術可以減少事件的處理頻率。

二、前置準備

  1. 準備一個html檔案和一個debounce.js檔案,debounce.js檔案用來編寫防抖函式

    <!-- test.html -->
    <!DOCTYPE html>
    <html lang="en">
    
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>防抖</title>
    </head>
    
    <body>
      <div class="debounce">觸發防抖事件</div>
      <script src="./debounce.js"></script>
    </body>
    
    </html>
    
    // debounce.js
    const debounce = () => {};
    
  2. div繫結點選事件

    <!-- test.html -->
    <!DOCTYPE html>
    <html lang="en">
    
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>防抖</title>
    </head>
    
    <body>
      <div class="debounce">觸發防抖事件</div>
      <script src="./debounce.js"></script>
    
      <script>
        const clickEvent = (e) => { console.log("點選事件觸發", e, this) }
        document.querySelector(".debounce").addEventListener("click", clickEvent)
      </script>
    </body>
    
    </html>
    

    image-20240317023413975

    進行點選測試,發現目前this指向的是Window,因為箭頭函式沒有this,透過作用域鏈往外找,就找到Window了。

    將箭頭函式改為普通函式,就能將this指向改為div。但是這裡暫時先不改,後面遇到問題再說。

  3. 將clickEvent方法傳遞給debounce進行處理

    <!-- test.html -->
    <!DOCTYPE html>
    <html lang="en">
    
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>防抖</title>
    </head>
    
    <body>
      <div class="debounce">觸發防抖事件</div>
      <script src="./debounce.js"></script>
    
      <script>
        const clickEvent = (e) => { console.log("點選事件觸發", e, this) }
        const debounceClickEvent = debounce(clickEvent, 2000)
        document.querySelector(".debounce").addEventListener("click", debounceClickEvent)
      </script>
    </body>
    
    </html>
    

    現在再進行點選測試,發現控制檯什麼也沒有輸出。因為目前debounce方法還沒寫方法體,沒有返回值,所以debounceClickEvent是undefined,所以什麼也不會觸發。

    下面我們就一步步寫防抖函式

三、基礎防抖實現

  1. 思考防抖函式需要接收什麼引數,又需要返回什麼

    • 接收引數:一個點選事件的處理方法、延遲執行的時間
    • 返回值:做了防抖處理的方法

    根據以上條件,可以先寫出如下程式碼

    // debounce.js
    const debounce = (fun, time) => {
      return fun;
    };
    

    上面程式碼接收了一個方法,又直接將該方法返回了。等於什麼也沒做,至少也得做個延時處理吧

    // debounce.js
    const debounce = (fun, time) => {
      return () => {
        setTimeout(fun, time);
      };
    };
    

    上面的方法雖然做了延時處理,但還是造成了處理方法被多次呼叫

    那我們應該怎樣讓前面的處理方法被取消執行呢?既然處理方法都放進了定時器,那我們就把定時器清除就行了。

    // debounce.js
    const debounce = (fun, time) => {
      let timer;
      return () => {
        timer && clearTimeout(timer);
        timer = setTimeout(fun, time);
      };
    };
    

    image-20240317025923041

    這下多次點選,就只觸發了最後一次處理方法了。但是透過列印可以發現,this指向的是Window,事件源也是undefined。

    我們如何將this指向變成呼叫方法的div,又如何獲取事件源呢

  2. 改變this指向

    我們先分析一下,為什麼this指向的是window?

    const clickEvent = (e) => { console.log("點選事件觸發", e, this) }
    const debounceClickEvent = debounce(clickEvent, 2000)
    
    // debounce.js
    const debounce = (fun, time) => {
      let timer;
      return () => {
        timer && clearTimeout(timer);
        timer = setTimeout(fun, time);
      };
    };
    

    上面的寫法,其實等價於下面的寫法

    // debounce.js
    const debounce = (fun, time) => {
      let timer;
      return () => {
        timer && clearTimeout(timer);
        timer = setTimeout((e) => { console.log("點選事件觸發", e, this) }, time);
      };
    };
    
    

    那再來分析一下this指向,箭頭函式沒有this,它透過作用域鏈往外找,就找到Window了

    那我們在哪裡能獲取到正確的this指向呢?來看看哪個方法是被div呼叫的,是不是return 後面那個方法

    既然這個方法是div呼叫的,那我們應該可以拿到this,但是這裡也是箭頭函式,沒有this。所以我們先將它修改為普通函式

    // debounce.js
    const debounce = (fun, time) => {
      let timer;
      return function () {
        console.log(this);
        timer && clearTimeout(timer);
        timer = setTimeout(fun, time);
      };
    };
    

    我們列印一下this,看看是不是div

    image-20240317031539559

    這裡的this指向確實是指向div的,那我們就可以透過call、apply、bind等方法修改fun的this指向了

    // debounce.js
    const debounce = (fun, time) => {
      let timer;
      return function () {
        timer && clearTimeout(timer);
        timer = setTimeout(fun.bind(this), time);
      };
    };
    

    再來看看能列印出正確的this嗎

    image-20240317031756029

    結果this還是指向的Window,別忘了fun是一個箭頭函式,它沒有this,又怎麼能去修改呢。

    所以我們需要將這個fun(clickEvent)也改為普通函式

    const clickEvent = function (e) {
        console.log("點選事件觸發", e, this)
    }
    const debounceClickEvent = debounce(clickEvent, 2000)
    document.querySelector(".debounce").addEventListener("click", debounceClickEvent)
    

    這下再來看看this指向正確了嗎

    image-20240317032129969

  3. 獲取事件源

    為什麼這裡的e列印出來是undefined?

    我們來看看div呼叫的是哪個方法,是不是debounce函式中return的那個方法。那這個方法應該是可以接收到事件源的。

    再看看fun,我們使用bind的時候,根本就沒有給它設定引數,所以e列印出來是undefined

    // debounce.js
    const debounce = (fun, time) => {
      let timer;
      return function (e) {
        console.log(e);
        timer && clearTimeout(timer);
        timer = setTimeout(fun.bind(this), time);
      };
    };
    

    image-20240317032714448

    從返回的方法裡面確實可以拿到事件源,那我們將這個 e 傳遞給bind 的第二個引數就好了

    // debounce.js
    const debounce = (fun, time) => {
      let timer;
      return function (e) {
        timer && clearTimeout(timer);
        timer = setTimeout(fun.bind(this, e), time);
      };
    };
    

    image-20240317032857839

四、接收多個引數

  1. 上面已經實現了最基本的防抖函式,但還有一些地方需要最佳化

    • 如果傳遞了多個引數又如何接收
    • 每次觸發新的點選事件,會清空定時器。那最後一次執行完了,又怎麼清空呢?這個物件始終沒有釋放掉
  2. 接收多個引數

    // debounce.js
    const debounce = (fun, time) => {
      let timer;
      return function (...args) {
        timer && clearTimeout(timer);
        timer = setTimeout(fun.bind(this, ...args), time);
      };
    };
    
  3. 最後一次執行完畢,將timer釋放掉

    // debounce.js
    const debounce = (fun, time) => {
      let timer;
      return function (...args) {
        timer && clearTimeout(timer);
        timer = setTimeout(() => {
          fun.apply(this, args);
          timer = null;
        }, time);
      };
    };
    

五、取消處理方法

  1. 怎麼將最後一次的處理方法給取消呢?

    想辦法拿到定時器timer,然後使用clearTimeout(timer)就可以了

  2. 準備一個div,作為取消按鈕

    <!-- test.html -->
    <!DOCTYPE html>
    <html lang="en">
    
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>防抖</title>
    </head>
    
    <body>
      <div class="debounce">觸發防抖事件</div>
      <div class="cancle">取消</div>
      <script src="./debounce.js"></script>
    
      <script>
        const clickEvent = function (e) {
          console.log("點選事件觸發", e, this)
        }
    
        const debounceClickEvent = debounce(clickEvent, 2000)
        const cancle = () => { }
    
        document.querySelector(".debounce").addEventListener("click", debounceClickEvent)
        document.querySelector(".cancle").addEventListener("click", cancle)
      </script>
    </body>
    
    </html>
    
  3. 在返回的函式身上再繫結一個方法用來清除定時器

    // debounce.js
    const debounce = (fun, time) => {
      let timer;
      const debounceEvent = function debounceEvent(...args) {
        timer && clearTimeout(timer);
        timer = setTimeout(() => {
          fun.apply(this, args);
          timer = null;
        }, time);
      };
    
      debounceEvent.cancle = () => {
        timer && clearTimeout(timer);
        timer = null;
      };
    
      return debounceEvent;
    };
    

    image-20240317035101187

  4. 給取消按鈕繫結方法

    <!-- test.html -->
    <!DOCTYPE html>
    <html lang="en">
    
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>防抖</title>
    </head>
    
    <body>
      <div class="debounce">觸發防抖事件</div>
      <div class="cancle">取消</div>
      <script src="./debounce.js"></script>
    
      <script>
        const clickEvent = function (e) {
          console.log("點選事件觸發", e, this)
        }
    
        const debounceClickEvent = debounce(clickEvent, 2000)
        const cancle = () => { debounceClickEvent.cancle() }
    
        document.querySelector(".debounce").addEventListener("click", debounceClickEvent)
        document.querySelector(".cancle").addEventListener("click", cancle)
      </script>
    </body>
    
    </html>
    

六、立即執行

  1. 如何讓第一次處理函式立即執行,後面再做防抖處理

    可以透過一個變數來控制,第一次立即執行,然後將該變數取反,後面執行的時候,使用原來的邏輯

  2. 為debounce函式新增引數,用來標誌是否第一次立即執行

    // debounce.js
    const debounce = (fun, time, immediately = false) => {
      let timer;
      const debounceEvent = function debounceEvent(...args) {
        timer && clearTimeout(timer);
        timer = setTimeout(() => {
          fun.apply(this, args);
          timer = null;
        }, time);
      };
    
      debounceEvent.cancle = () => {
        console.log("清除定時器");
        timer && clearTimeout(timer);
        timer = null;
      };
    
      return debounceEvent;
    };
    
  3. 修改第一次執行的邏輯

    // debounce.js
    const debounce = (fun, time, immediately = false) => {
      let timer;
      // 是否已經立即執行
      let running = false;
      const debounceEvent = function debounceEvent(...args) {
        timer && clearTimeout(timer);
        // 開啟了立即執行並且還沒有立即執行,則說明這是第一次,直接立即執行
        if (immediately && !running) {
          // 第一次已經立即執行了,後面再次觸發就不能再立即執行了
          running = true;
          fun.apply(this, args);
        } else {
          timer = setTimeout(() => {
            fun.apply(this, args);
            timer = null;
            // 最後一次防抖方法完成後,下一次還可以立即執行
            running = false;
          }, time);
        }
      };
    
      debounceEvent.cancle = () => {
        console.log("清除定時器");
        timer && clearTimeout(timer);
        timer = null;
        // 取消最後一次防抖方法後,恢復下一次的立即執行
        running = false;
      };
    
      return debounceEvent;
    };
    

    上面的程式碼實現了第一次觸發時立即執行,然後每次觸發做防抖處理。最後一次防抖方法處理完成(或被取消)後,在下一次觸發時,又可以立即執行。

    但是這仍然存在一個小問題,如果第一次立即執行後不觸發頻繁的點選操作,而是等第一次完成之後,再點選,這時還會立即執行嗎?很明顯不能。目前第一次立即執行後,想要恢復立即執行,就必須經過頻繁觸發事件,讓最後一次防抖方法被處理了,才能再次恢復立即執行。

    在下面的程式碼中,我們使用了一個定時器。在第一次立即執行完成後,開啟一個定時器,在一段時間後恢復立即執行,使再次點選時,可以立即執行。但是,如果在這一段時間內,頻繁的觸發了點選事件,那就清除定時器,在最後一次防抖處理方法完成後,再恢復立即執行。

    // debounce.js
    const debounce = (fun, time, immediately = false) => {
      let timer;
      // 是否已經立即執行
      let running = false;
      // 恢復立即執行的定時器
      let timerRunning;
      const debounceEvent = function debounceEvent(...args) {
        timer && clearTimeout(timer);
        // 開啟了立即執行並且還沒有立即執行,則說明這是第一次,直接立即執行
        if (immediately && !running) {
          // 第一次已經立即執行了,後面再次觸發就不能再立即執行了
          running = true;
          fun.apply(this, args);
          // 第一次立即執行已經完成了,我們在一段時間後恢復立即執行
          timerRunning = setTimeout(() => {
            running = false;
          }, time);
        } else {
          // 如果頻繁觸發了事件,則不恢復立即執行,而是等最後一次處理方法完成再恢復立即執行
          timerRunning && clearTimeout(timerRunning);
          timer = setTimeout(() => {
            fun.apply(this, args);
            timer = null;
            // 最後一次防抖方法完成後,下一次還可以立即執行
            running = false;
          }, time);
        }
      };
    
      debounceEvent.cancle = () => {
        console.log("清除定時器");
        timer && clearTimeout(timer);
        timer = null;
        // 取消最後一次防抖方法後,恢復下一次的立即執行
        running = false;
      };
    
      return debounceEvent;
    };
    

七、獲取防抖函式的返回值

  1. 先來手動呼叫一下點選處理函式

    <!-- test.html -->
    <!DOCTYPE html>
    <html lang="en">
    
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>防抖</title>
    </head>
    
    <body>
      <div class="debounce">觸發防抖事件</div>
      <div class="cancle">取消</div>
      <script src="./debounce.js"></script>
    
      <script>
        const clickEvent = function (e) {
          console.log("點選事件觸發", e, this)
        }
    
        const debounceClickEvent = debounce(clickEvent, 2000, true)
        const cancle = () => { debounceClickEvent.cancle() }
    
        document.querySelector(".debounce").addEventListener("click", debounceClickEvent)
        document.querySelector(".cancle").addEventListener("click", cancle)
    
        // 手動呼叫點選處理函式
        debounceClickEvent()
        debounceClickEvent()
        debounceClickEvent()
        debounceClickEvent()
      </script>
    </body>
    
    </html>
    

    我們在上面手動呼叫了4次點選事件的處理函式,檢視控制檯也發現了列印了兩次,一次是立即執行,一次是防抖處理的最後一次執行

    image-20240317134911297

  2. 給點選處理函式設定返回值

    下面,我們給clickEvent方法設定一個返回值。

    <!-- test.html -->
    <!DOCTYPE html>
    <html lang="en">
    
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>防抖</title>
    </head>
    
    <body>
      <div class="debounce">觸發防抖事件</div>
      <div class="cancle">取消</div>
      <script src="./debounce.js"></script>
    
      <script>
        const clickEvent = function (e) {
          console.log("點選事件觸發", e, this)
          const result = "請求結果資料"
          return result
        }
    
        const debounceClickEvent = debounce(clickEvent, 2000, true)
        const cancle = () => { debounceClickEvent.cancle() }
    
        document.querySelector(".debounce").addEventListener("click", debounceClickEvent)
        document.querySelector(".cancle").addEventListener("click", cancle)
    
        // 手動呼叫點選處理函式
        console.log(debounceClickEvent())
        console.log(debounceClickEvent())
        console.log(debounceClickEvent())
        console.log(debounceClickEvent())
      </script>
    </body>
    
    </html>
    

    然後需要在debounceEvent方法中將結果返回

    // debounce.js
    const debounce = (fun, time, immediately = false) => {
      let timer;
      // 是否已經立即執行
      let running = false;
      // 恢復立即執行的定時器
      let timerRunning;
      const debounceEvent = function debounceEvent(...args) {
        // 返回結果
        let result;
        timer && clearTimeout(timer);
        // 開啟了立即執行並且還沒有立即執行,則說明這是第一次,直接立即執行
        if (immediately && !running) {
          // 第一次已經立即執行了,後面再次觸發就不能再立即執行了
          running = true;
          // 獲取方法執行的返回結果
          result = fun.apply(this, args);
          // 第一次立即執行已經完成了,我們在一段時間後恢復立即執行
          timerRunning = setTimeout(() => {
            running = false;
          }, time);
        } else {
          // 如果頻繁觸發了事件,則不恢復立即執行,而是等最後一次處理方法完成再恢復立即執行
          timerRunning && clearTimeout(timerRunning);
          timer = setTimeout(() => {
            // 獲取方法執行的返回結果
            result = fun.apply(this, args);
            timer = null;
            // 最後一次防抖方法完成後,下一次還可以立即執行
            running = false;
          }, time);
        }
        // 返回結果
        return result;
      };
    
      debounceEvent.cancle = () => {
        console.log("清除定時器");
        timer && clearTimeout(timer);
        timer = null;
        // 取消最後一次防抖方法後,恢復下一次的立即執行
        running = false;
      };
    
      return debounceEvent;
    };
    

    檢視控制輸出

    image-20240317140343576

    第一次是立即執行的,所以可以拿到返回值。但最後一次是非同步執行的,所以拿不到。可以使用Promise來處理

    // debounce.js
    const debounce = (fun, time, immediately = false) => {
      let timer;
      // 是否已經立即執行
      let running = false;
      // 恢復立即執行的定時器
      let timerRunning;
      const debounceEvent = function debounceEvent(...args) {
        return new Promise((resolve, reject) => {
          // 返回結果
          let result;
          timer && clearTimeout(timer);
          // 開啟了立即執行並且還沒有立即執行,則說明這是第一次,直接立即執行
          if (immediately && !running) {
            // 第一次已經立即執行了,後面再次觸發就不能再立即執行了
            running = true;
            result = fun.apply(this, args);
            resolve(result);
            // 第一次立即執行已經完成了,我們在一段時間後恢復立即執行
            timerRunning = setTimeout(() => {
              running = false;
            }, time);
          } else {
            // 如果頻繁觸發了事件,則不恢復立即執行,而是等最後一次處理方法完成再恢復立即執行
            timerRunning && clearTimeout(timerRunning);
            timer = setTimeout(() => {
              result = fun.apply(this, args);
              resolve(result);
              timer = null;
              // 最後一次防抖方法完成後,下一次還可以立即執行
              running = false;
            }, time);
          }
        });
      };
    
      debounceEvent.cancle = () => {
        console.log("清除定時器");
        timer && clearTimeout(timer);
        timer = null;
        // 取消最後一次防抖方法後,恢復下一次的立即執行
        running = false;
      };
    
      return debounceEvent;
    };
    
    <!-- test.html -->
    <!DOCTYPE html>
    <html lang="en">
    
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>防抖</title>
    </head>
    
    <body>
      <div class="debounce">觸發防抖事件</div>
      <div class="cancle">取消</div>
      <script src="./debounce.js"></script>
    
      <script>
        const clickEvent = function (e) {
          console.log("點選事件觸發", e, this)
          const result = "請求結果資料"
          return result
        }
    
        const debounceClickEvent = debounce(clickEvent, 2000, true)
        const cancle = () => { debounceClickEvent.cancle() }
    
        document.querySelector(".debounce").addEventListener("click", debounceClickEvent)
        document.querySelector(".cancle").addEventListener("click", cancle)
    
        // 手動呼叫點選處理函式
        // console.log(debounceClickEvent())
        // console.log(debounceClickEvent())
        // console.log(debounceClickEvent())
        // console.log(debounceClickEvent())
        debounceClickEvent().then(res => console.log(res))
        debounceClickEvent().then(res => console.log(res))
        debounceClickEvent().then(res => console.log(res))
        debounceClickEvent().then(res => console.log(res))
      </script>
    </body>
    
    </html>
    

    檢視控制檯輸出,這下就沒問題了

    image-20240317140944835

八、完整程式碼

<!-- test.html -->
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>防抖</title>
</head>

<body>
  <div class="debounce">觸發防抖事件</div>
  <div class="cancle">取消</div>
  <script src="./debounce.js"></script>

  <script>
    const clickEvent = function (e) {
      console.log("點選事件觸發", e, this)
      const result = "請求結果資料"
      return result
    }

    const debounceClickEvent = debounce(clickEvent, 2000, true)
    const cancle = () => { debounceClickEvent.cancle() }

    document.querySelector(".debounce").addEventListener("click", debounceClickEvent)
    document.querySelector(".cancle").addEventListener("click", cancle)

    // 手動呼叫點選處理函式
    // console.log(debounceClickEvent())
    // console.log(debounceClickEvent())
    // console.log(debounceClickEvent())
    // console.log(debounceClickEvent())
    debounceClickEvent().then(res => console.log(res))
    debounceClickEvent().then(res => console.log(res))
    debounceClickEvent().then(res => console.log(res))
    debounceClickEvent().then(res => console.log(res))
  </script>
</body>

</html>
// debounce.js
const debounce = (fun, time, immediately = false) => {
  let timer;
  // 是否已經立即執行
  let running = false;
  // 恢復立即執行的定時器
  let timerRunning;
  const debounceEvent = function debounceEvent(...args) {
    return new Promise((resolve, reject) => {
      // 返回結果
      let result;
      timer && clearTimeout(timer);
      // 開啟了立即執行並且還沒有立即執行,則說明這是第一次,直接立即執行
      if (immediately && !running) {
        // 第一次已經立即執行了,後面再次觸發就不能再立即執行了
        running = true;
        result = fun.apply(this, args);
        resolve(result);
        // 第一次立即執行已經完成了,我們在一段時間後恢復立即執行
        timerRunning = setTimeout(() => {
          running = false;
        }, time);
      } else {
        // 如果頻繁觸發了事件,則不恢復立即執行,而是等最後一次處理方法完成再恢復立即執行
        timerRunning && clearTimeout(timerRunning);
        timer = setTimeout(() => {
          result = fun.apply(this, args);
          resolve(result);
          timer = null;
          // 最後一次防抖方法完成後,下一次還可以立即執行
          running = false;
        }, time);
      }
    });
  };

  debounceEvent.cancle = () => {
    console.log("清除定時器");
    timer && clearTimeout(timer);
    timer = null;
    // 取消最後一次防抖方法後,恢復下一次的立即執行
    running = false;
  };

  return debounceEvent;
};

相關文章