博客 / 詳情

返回

JavaScript之函數防抖、節流

配圖源自 Freepik

一、前言

相信無論在實際應用場景、亦或是面試,都會經常遇得到函數防抖、函數節流等,下面我們來聊一聊吧。

先放出一個示例:

import React, { useEffect, useRef } from 'react'
import debounce from '../../utils/debounce'
import throttle from '../../utils/throttle'
import style from './index.scss'

export default function Demo(props) {
  const inputElem1 = useRef()
  const inputElem2 = useRef()
  const inputElem3 = useRef()

  useEffect(() => {
    inputElem1.current.addEventListener('keyup', request)
    inputElem2.current.addEventListener('keyup', debounce(request, 1000))
    inputElem3.current.addEventListener('keyup', throttle(request, 3000))
  }, [])

  function request(event) {
    const { value } = event.target
    console.log(`Http request: ${value}.`)
  }

  return (
    <div className={style.container}>
      <div className={style.list}>
        <label htmlFor="input1">普通輸入框:</label>
        <input name="input1" ref={inputElem1} defaultValue="" />
      </div>

      <div className={style.list}>
        <label htmlFor="input2">防抖輸入框:</label>
        <input name="input2" ref={inputElem2} defaultValue="" />
      </div>

      <div className={style.list}>
        <label htmlFor="input3">節流輸入框:</label>
        <input name="input3" ref={inputElem3} defaultValue="" />
      </div>
    </div>
  )
}

以上 Demo 只有三個輸入框,很簡單。我給每個輸入框綁定了一個 keyup 鍵盤事件,該事件執行會發起網絡請求(為了更簡潔,這裏只是打印一下而已),而對應防抖、節流輸入框則經過相應的處理。

二、函數防抖(debounce)

如果我們在普通輸入框快速鍵入 12345,可以從控制枱上的打印結果看到,它會發起 5 次網絡請求(假設我們這個是一個簡單的搜索引擎)。

還不知道用什麼截屏/錄屏軟件可以生成 GIF 動圖,有時間再研究下...

從實際場景考慮,如果每鍵入一個字符就立刻發起網絡請求,去檢索結果,這是非常影響體驗的。假設我們限制為:用户在停止輸入後 1s 後才發起網絡請求。

要實現這樣的需求,我們只有使用函數防抖即可。

2.1 什麼是函數防抖?

概念:在一定時間間隔內,事件處理函數只會執行一次。若在該時間間隔內(多次)重新觸發,則重新計時。

怎麼理解?

  • 假設用户鍵入字母 a 後就停止輸入了,那麼網絡請求會在停止鍵入操作的 1s 後發起。這個很好理解。
  • 若用户繼續鍵入字母 b 後,若有所思地停了一會(這個時間在 1s 之內,假設為 800ms 吧),接着鍵入字母 c,之後就停止鍵入了。網絡請求會發生在鍵入字母 c 的 1s 後被髮起,而不是鍵入字母 b 之後的 1s 發起。因為函數防抖會在鍵入 c 之後重新計時。
2.2 函數防抖實現
debounce(func, wait)

實現思路:

首先,接收兩個參數 func(要防抖的函數,一般是事件回調函數)和 wait(需要延遲的時間間隔,單位毫秒)。然後 funcsetTimeout 中執行,而 setTimeout 的延遲時間就是 wait。而重新計時的話,則在每次觸發的時候 clearTimeout 即可實現。

需要注意下,func 的執行上下文(this)及其入參。
// debounce.js
function debounce(func, wait) {
  let timerId
  return function () {
    // 當前運行上下文環境,以及實參
    const context = this
    const args = arguments

    // 重新計時(關鍵是這一步)
    // 在 wait 時間內,若重新觸發,清除 clearTiemout,以達到重新計時的效果
    if(timerId) clearTimeout(timerId)

    timerId = setTimeout(function () {
      // 綁定上下文和參數,否則實參 func 的 this 指向 window 對象,參數為空
      func.apply(context, args)
    }, wait)
  }
}

藉助 ES6 的 Rest 參數和箭頭函數語法,簡化一下:

function debounce(func, wait) {
  let timerId
  return function (...args) {
    if (timerId) clearTimeout(timerId)
    timerId = setTimeout(() => {
      func.apply(this, args)
    }, wait)
  }
}

依次在對應輸入框內鍵入 12345,對比下防抖前後的結果:

兩次鍵入速度差不多,而且每個字符鍵入時間間隔小於 1s(可調大延遲執行時間,更容易對比)。
// 普通輸入框
inputElem1.current.addEventListener('keyup', request)
// 防抖輸入框
inputElem2.current.addEventListener('keyup', debounce(request, 1000))

無防抖處理

防抖處理

對比以上無防抖處理和防抖處理的結果,可以看到前者每鍵入一個字符都會執行回調函數,而後者則會在最後一次觸發的 N 毫秒(即 wait 延遲時間)之後才會執行一次回調函數。

還有一種是“立即執行”的函數防抖:區別在於第一次觸發時,是否立即執行回調函數。

再結合以上的“非立即執行”的防抖,完整方法如下:

/**
 * 函數防抖
 * @param {Function} func 要防抖的函數
 * @param {number} wait 需要延遲的毫秒數
 * @param {boolean} immdeiate 是否立即執行
 * @returns {Function} 返回新的 debounced(防抖動)函數
 */
function debounce(func, wait = 0, immdeiate = false) {
  let timerId
  return function (...args) {
    if (timerId) clearTimeout(timerId)

    if (immdeiate && !timerId) {
      func.apply(this, args)
    }

    timerId = setTimeout(() => {
      func.apply(this, args)
    }, wait)
  }
}

當我們修改成:

inputElem2.current.addEventListener('keyup', debounce(request, 1000, true))

從以下結果可以看到,當我在防抖輸入框鍵入 12345 的時候,它會在鍵入 1 時立刻發起一次網絡請求,由於每個字符鍵入的時間間隔都在 1s 之內,因此它只會在最後停止鍵入的 1s 後才會發起網絡請求。

三、函數節流(throttle)

概念:在一定時間間隔內只會觸發一次函數。若在該時間間隔內觸發多次函數,只有第一次生效。

3.1 函數節流實現
function throttle(func, wait) {
  // 記錄上一次執行 func 的時間
  let prev = 0
  return function (...args) {
    // 當前觸發的時間(時間戳)
    const now = Number(new Date()) // +new Date()
    
    // 單位時間內只會執行一次
    if (now >= prev + wait) {
      // 符合條件執行 func 時,需要更新 prev 時間
      prev = now
      func.apply(this, args)
    }
  }
}
3.2 函數節流優化

以上節流方法有個問題,假設節流控制間隔時間為 1s,若最後一次觸發時間在 1.5s,則最後一次觸發並不會執行。因此,需要在節流中嵌入防抖思想,以保證最後一次會被觸發。

function throttle(func, wait) {
  // 記錄上一次執行 func 的時間
  let prev = 0
  let timerId
  return function (...args) {
    // 當前觸發的時間(時間戳)
    const now = Number(new Date()) // +new Date()

    // 保證最後一次也會觸發
    // 我看到很多文章,將清除定時器的步驟放到 2️⃣ 裏面
    // 我認為應該放在這裏才對,原因看我下面舉例的場景。
    if (timerId) clearTimeout(timerId)
    
    if (now >= prev + wait) {
      // 1️⃣
      // 符合條件執行 func 時,需要更新 prev 時間
      prev = now
      func.apply(this, args)
    } else {
      // 2️⃣
      // 單位時間內只會執行一次
      // if (timerId) clearTimeout(timerId) // 不應該放在這裏
      timerId = setTimeout(() => {
        prev = now
        func.apply(this, args)
      }, wait)
    }
  }
}

假設我將 clearTimeout() 放在了 2️⃣ 裏面,而不是在外層。基於 throttle(func, 1000) 考慮以下場景:

我在 4s 時觸發了一次,應該走 1️⃣ 邏輯。然後在 4.9s 時又觸發了一次,這會走的 2️⃣ 邏輯並記錄了一個定時任務。然後時間到了 5s,我又觸發了一次(後面就停止操作了),它會走 1️⃣ 邏輯一次,接着時間來的 5.9s,它還會執行一遍 fn.apply(this, args),因為在 5s 觸發時,沒有 clearTimeout()。因此,清除定時器的步驟應該放在外層,以保證每次被觸發是都清掉最後一次的定時器,避免在一些邊界 Case 觸發兩次。

當然,以上場景是在理想的狀態,實際場景可能幾乎碰不到這些邊界。但從嚴謹的角度去看問題,應該也要考慮的。

寫到這裏,我又在想剛剛的“立即執行的函數防抖”,跟這個優化版的節流是不是有點像,第一次觸發都會執行回調函數。但區別是防抖會重新計時,而節流在第一次觸發後面的每個間隔時間點都會觸發,非間隔點的最後一次觸發也將會被執行。

我在節流輸入框內,依次鍵入 1234567890,可以看到:在鍵入字符 1 時執行了回調;接着鍵入的 23467 字符都屬在上一個時間間隔內,因此無法執行回調。其中鍵入的 90 字符應屬於 8 之後的 1s 週期之內,由於鍵入 0 字符屬於最後一次的非時間間隔內的觸發動作,因此回調會在鍵入 0 的 1s 後被執行。(可打印時間戳的形式,更精細地對比)

inputElem3.current.addEventListener('keyup', throttle(request, 1000))

節流處理

四、防抖與節流

其實,函數防抖和函數節流都是為了防止某個時間段頻繁觸發某個事件。它倆在某個時間間隔內多次重複觸發,都只會執行一次回調函數。區別在於函數防抖最後一次觸發有效,而函數節流則是第一次觸發有效。

而在上面,都對函數防抖和函數節流做了“拓展”,例如:

  • 在函數防抖中,增加了 immediate 的參數,用於控制第一次是否執行回調。
  • 在函數節流中,允許最後一次在非時間間隔的觸發動作有效。

應用場景:

  • 函數防抖(debounce)

    • 搜索場景:防止用户不停地輸入,來節約請求資源。
    • window resize:調整瀏覽器窗口大小時,利用防抖使其只觸發一次。
  • 函數節流(throttle)

    • 鼠標事件、mousemove 拖拽
    • 監聽滾動事件

如果還是不太明白 debounce 和 throttle 的差異,可以在以下這個頁面,可視化體驗。

五、拓展

還是那句話:

生產環境請使用 Lodash 庫,對應的方法是 _.debounce() 和 _.throttle()。

畢竟 Lodash 是經過社區考驗的,肯定會完善很多。而我這篇文章可能會有一些我未曾想到的場景沒有處理的,面向學習和麪試(手動狗頭)。

如有不足,歡迎指出 👋 ~

TODO List:

  • 詳細閲讀 Lodash 的防抖和節流源碼。
  • window.requestAnimationFrame

六、參考

  • Lodash debounce
  • 函數防抖與函數節流(司徒正美大佬)
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.