一、前言
相信無論在實際應用場景、亦或是面試,都會經常遇得到函數防抖、函數節流等,下面我們來聊一聊吧。
先放出一個示例:
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(需要延遲的時間間隔,單位毫秒)。然後 func 在 setTimeout 中執行,而 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 時執行了回調;接着鍵入的 234、67 字符都屬在上一個時間間隔內,因此無法執行回調。其中鍵入的 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
- 函數防抖與函數節流(司徒正美大佬)