本篇文章主要介紹防抖和節流的原理,以及它們的區別。
防抖與節流的問題總是會在面試中出現(然而我并沒有遇到),如果你在面試前有背書,那肯定能過這題的,但如果現實開發中用的不多的話,估計就很快忘記了怎么寫來著(我就是這樣)。究其原因就是沒徹底弄清楚這兩個的原理與區別,所以準備這次來好好梳理一下。
我們知道前端開發中會遇到頻繁觸發的事件,比如keyup、keydown事件,mousedown、mousemove事件,還有window 的 resize、scroll等。如果任由用戶頻繁觸發此類事件,將帶來極大的性能消耗,或可能導致頁面卡頓。作為有前途的前端人,我們有必要掌握優化技巧,一般解決這類問題的方法也就是防抖
和節流
了。
那么問題也就來了,我們可以去網上搜到相關插件,也能搜到很多優秀的實現源碼,那到底什么場景用防抖,什么場景用節流呢?我們慢慢來看。
防抖(debounce)
先看防抖,用一句話概括防抖就是:觸發高頻事件后n秒內函數只會執行一次,如果n秒內高頻事件再次被觸發,則重新計算時間
說的通俗點就是:你盡管頻繁觸發事件,但我一定是在觸發事件的n秒后才執行,如果在前一個事件觸發的n秒內又重新觸發了這個事件,那就以新的事件的時間為準,n 秒后才執行。核心點就是,要等你在觸發事件后的n秒內不再重新觸發事件,我才執行。
我先放一段基本的防抖函數源碼:
// fn 函數傳入用戶方法
// delay 延遲執行的時間,默認 500ms
function debounce(fn, delay = 500) {
let timer = null
return function() {
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
fn.apply(this, arguments)
timer = null
}, delay)
}
}
社區有很多文章寫了關于防抖函數的實現,每個人的實現方式可能都有細微區別,但其核心部分也就是上面這段了,對我而言夠用,后面會詳細說這段代碼?,F在我們舉例一個常見場景來應用一下這段代碼,我們來監聽一下 input元素的 keyup
事件,每次釋放鍵盤時打印輸入值,我們先不加入防抖:
<body>
<input id="input" type="text" />
<script>
let input = document.getElementById('input')
input.addEventListener('keyup', function() {
console.log(input.value)
})
</script>
</body>
運行上面代碼,可以看到每次釋放鍵盤時控制臺都在打印,頻率很高,但因為要執行的操作只是簡單的打印,所以感受不到性能的消耗。而現實開發里時常要執行的操作是ajax
請求,假設 1 秒觸發了 60 次,每個請求回調就必須在 1000 / 60 = 16.67ms 內完成,否則可能就會出現卡頓。所以優化這段操作很有必要,我們來應用前面的防抖函數:
<body>
<input id="input" type="text" />
<script>
let input = document.getElementById('input')
input.addEventListener(
'keyup',
debounce(function() { // 此處通過 debounce 返回用戶操作函數
console.log(input.value)
}, 1000)
)
</script>
</body>
加入防抖的效果大家是可以預見的,它抑制住了高頻操作,此時用戶持續輸入,并在 1s 內重新輸入時是不會觸發打印操作的,核心就在這個 1s 內,如果用戶停止輸入并超過 1s ,則會執行打印。我們可以結合上面的debounce
函數源碼來分析一下流程:
- 當輸入第一個字符,并第一次觸發
keyup
時,timer
為null,所以開始新的定時任務,1秒后執行打印操作,并晴空timer
; - 在 1 秒內,用戶又輸入了第二個字符,再次觸發事件,此時定時器保存了上一次的任務,所以執行
clearTimeout(timer)
清空了定時器,并重新賦值新的定時任務; - 后續用戶持續輸入時,反復執行上一步的操作;
- 當用戶停止輸入時,經過 1 秒后,則終于可以執行定時器里的任務。
以前不知道為什么這么寫,現在了解了,記住就行了。另外debounce
函數中有一個問題一直被人問起,就是為什么要fn.apply(this, arguments)
這樣,而不是直接fn()
這樣。其實不使用apply
也是可以的,但為了程序的穩定性,還是加入比較好,畢竟又不麻煩。加入apply
后解決了兩個不穩定因素:
- 不使用防抖函數時,在fn中打印this,本例中指向的是
<input id="input" type="text">
,而在加入防抖函數后,指向的是Window
對象,所以要手動改正 this 指向。 - 事件處理函數中會提供事件對象
event
,使用防抖函數前后會改變事件對象。比如例子中,使用防抖前,event
指向的是KeyboardEvent
對象,加入防抖后則變成undefined
了,所以也要手動傳入參數。
這些都是js基礎,還是需要打牢的。如果源碼中的定時器里不是箭頭函數,就需要這樣寫了:
function debounce(fn, delay = 1000) {
let timer = null
return function(...args) { // 此處顯示定義出參數對象
let context = this // 緩存 this 對象
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(function() {
fn.apply(context, args) // 注意改變
timer = null
}, delay)
}
}
節流(throttle)
也用一句話概括節流:高頻事件觸發,但在n秒內只會執行一次,所以節流會稀釋函數的執行頻率。
同樣是抑制高頻事件觸發,與防抖的區別在于它不需要用戶停頓,而是在持續觸發的過程中每隔 n 秒執行一次。
實現節流一般有兩個方向,一是使用時間戳,而是使用定時器。
既然是為了比較,那還是使用上面的例子,即監聽keyup
事件。我們先看用時間戳來實現節流:
// fn 函數傳入用戶方法
// wait 間隔執行時間,默認 500ms
function throttle(fn, wait=500) {
// 初始時間點
let previous = 0
return function(...args) {
let context = this
let now = +new Date() // 當前時間戳
if (now - previous > wait) {
fn.apply(context, args)
previous = now
}
}
}
當我們應用這個方法時,即:
input.addEventListener(
'keyup',
throttle(function() {
console.log(input.value)
}, 1000) // 便于演示,設定wait為 1秒
)
此時瀏覽器運行代碼,在input框持續輸入時,會發現每隔 1 秒就會打印值。我們來梳理一下流程:
- 輸入第一個字符時,進入節流邏輯,時間戳肯定大于 1 秒,所以立刻執行打印操作,同時將
previous
設定為當前時間戳; - 持續輸入,間隔時間小于 1 秒時,不執行操作,
previous
不變,now
一直在增長; - 當
now
增長到與previous
的差值大于 1000 時,執行打印,更新previous
; - 如此往復,每隔 1 秒打印一次。而最后輸入的值則不會被打印,因為持續的過程中,最后一次的差值還沒到1000就停止輸入了,超過 1000 時,則是算重新第一次輸入了。我說的可能不好明白,自己走一遍流程就清楚了。
由此,上面的節流方案可以做到限制高頻觸發事件,它的特點是:使用時間戳方式實現的節流,在第一次觸發時會立刻執行,而停止觸發后沒有辦法再執行事件。
現在我們再來試試使用定時器實現的節流方式,放上源碼:
// fn 函數傳入用戶方法
// wait 間隔執行時間,默認 500ms
function throttle(fn, wait) {
let timeout
return function(...args) {
if (!timeout) {
timeout = setTimeout(() => {
fn.apply(this, args)
timeout = null
}, wait)
}
}
}
使用方法是一樣的:
input.addEventListener(
'keyup',
throttle(function() {
console.log(input.value)
}, 1000) // 便于演示,設定wait為 1秒
)
此時運行效果依舊是每隔 1 秒執行一次,但也稍有區別,再來梳理一下這個流程:
- 輸入第一個字符時,進入節流邏輯,初始定時器無值,所以賦值新的定時任務,1 秒后執行;
- 此時用戶在持續輸入,但因為第 1 步定時器已經被賦值了,所以不重新賦值了,函數不執行邏輯;
- 此時 1 秒已經過去了,第一步中的定時任務觸發,執行打印操作,清空定時器;
- 繼續輸入時,
timeout
定時器因為被清空了,所以重新賦值,走第 1 步中的邏輯; - 如此往復,總是間隔 1 秒執行一次。可以發現第一次觸發事件時不會立刻執行,而停止輸入時,最后還會執行一次。
自己多過幾遍流程就會很清晰了。
總結
來做個總結:
防抖與節流的區別
我不想從定義上說區別,直接從使用結果上比較區別:
- 使用防抖:持續觸發高頻事件時,只要觸發時間間隔小于設定的時間閥值,不管持續多久都不會執行用戶操作,只有當停頓時間超過設定的時間閥值時,才會執行一次操作。
- 使用節流:持續觸發高頻事件時,每隔一段時間就觸發一次操作,不需要“停頓”,這個一段時間是指你設定的時間閥值。所以在這個持續的過程中,會多次觸發操作,而防抖是一個持續過程后只觸發一次。
所以何時使用防抖,何時使用節流,全看你需要的效果,而效果就是上面總結的。
節流兩種實現方式的區別
- 時間戳方式:事件會立刻執行,事件停止觸發后沒有辦法再執行。
- 定時器方式:事件會在 n 秒后第一次執行,事件停止觸發后依然會再執行一次事件。
當然有時候這兩種節流方式可能都不能滿足需求,比如你既想要能夠立即執行,也要結束時還能執行一次,又比如你想要自己控制它開始和結束的狀態,不用怕,社區里都能找到你要的,況且我們還有 Lodash 這樣優秀的插件。我在這里只是想要介紹一下他們的原理和區別。
如有不對之處,望指正,謝謝。