本篇文章主要介紹防抖和節(jié)流的原理裤园,以及它們的區(qū)別。
防抖與節(jié)流的問題總是會在面試中出現(xiàn)(然而我并沒有遇到)剂府,如果你在面試前有背書拧揽,那肯定能過這題的,但如果現(xiàn)實開發(fā)中用的不多的話腺占,估計就很快忘記了怎么寫來著(我就是這樣)淤袜。究其原因就是沒徹底弄清楚這兩個的原理與區(qū)別,所以準備這次來好好梳理一下衰伯。
我們知道前端開發(fā)中會遇到頻繁觸發(fā)的事件饮怯,比如keyup、keydown事件嚎研,mousedown蓖墅、mousemove事件,還有window 的 resize临扮、scroll等论矾。如果任由用戶頻繁觸發(fā)此類事件,將帶來極大的性能消耗杆勇,或可能導致頁面卡頓贪壳。作為有前途的前端人,我們有必要掌握優(yōu)化技巧蚜退,一般解決這類問題的方法也就是防抖
和節(jié)流
了闰靴。
那么問題也就來了,我們可以去網(wǎng)上搜到相關插件钻注,也能搜到很多優(yōu)秀的實現(xiàn)源碼蚂且,那到底什么場景用防抖,什么場景用節(jié)流呢幅恋?我們慢慢來看杏死。
防抖(debounce)
先看防抖,用一句話概括防抖就是:觸發(fā)高頻事件后n秒內(nèi)函數(shù)只會執(zhí)行一次,如果n秒內(nèi)高頻事件再次被觸發(fā)淑翼,則重新計算時間
說的通俗點就是:你盡管頻繁觸發(fā)事件腐巢,但我一定是在觸發(fā)事件的n秒后才執(zhí)行,如果在前一個事件觸發(fā)的n秒內(nèi)又重新觸發(fā)了這個事件玄括,那就以新的事件的時間為準冯丙,n 秒后才執(zhí)行。核心點就是遭京,要等你在觸發(fā)事件后的n秒內(nèi)不再重新觸發(fā)事件银还,我才執(zhí)行。
我先放一段基本的防抖函數(shù)源碼:
// fn 函數(shù)傳入用戶方法
// delay 延遲執(zhí)行的時間洁墙,默認 500ms
function debounce(fn, delay = 500) {
let timer = null
return function() {
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
fn.apply(this, arguments)
timer = null
}, delay)
}
}
社區(qū)有很多文章寫了關于防抖函數(shù)的實現(xiàn)蛹疯,每個人的實現(xiàn)方式可能都有細微區(qū)別,但其核心部分也就是上面這段了热监,對我而言夠用捺弦,后面會詳細說這段代碼。現(xiàn)在我們舉例一個常見場景來應用一下這段代碼孝扛,我們來監(jiān)聽一下 input元素的 keyup
事件列吼,每次釋放鍵盤時打印輸入值,我們先不加入防抖:
<body>
<input id="input" type="text" />
<script>
let input = document.getElementById('input')
input.addEventListener('keyup', function() {
console.log(input.value)
})
</script>
</body>
運行上面代碼苦始,可以看到每次釋放鍵盤時控制臺都在打印寞钥,頻率很高,但因為要執(zhí)行的操作只是簡單的打印陌选,所以感受不到性能的消耗理郑。而現(xiàn)實開發(fā)里時常要執(zhí)行的操作是ajax
請求,假設 1 秒觸發(fā)了 60 次咨油,每個請求回調(diào)就必須在 1000 / 60 = 16.67ms 內(nèi)完成您炉,否則可能就會出現(xiàn)卡頓。所以優(yōu)化這段操作很有必要役电,我們來應用前面的防抖函數(shù):
<body>
<input id="input" type="text" />
<script>
let input = document.getElementById('input')
input.addEventListener(
'keyup',
debounce(function() { // 此處通過 debounce 返回用戶操作函數(shù)
console.log(input.value)
}, 1000)
)
</script>
</body>
加入防抖的效果大家是可以預見的赚爵,它抑制住了高頻操作,此時用戶持續(xù)輸入法瑟,并在 1s 內(nèi)重新輸入時是不會觸發(fā)打印操作的冀膝,核心就在這個 1s 內(nèi),如果用戶停止輸入并超過 1s ,則會執(zhí)行打印霎挟。我們可以結合上面的debounce
函數(shù)源碼來分析一下流程:
- 當輸入第一個字符窝剖,并第一次觸發(fā)
keyup
時,timer
為null氓扛,所以開始新的定時任務枯芬,1秒后執(zhí)行打印操作论笔,并晴空timer
采郎; - 在 1 秒內(nèi)千所,用戶又輸入了第二個字符,再次觸發(fā)事件蒜埋,此時定時器保存了上一次的任務淫痰,所以執(zhí)行
clearTimeout(timer)
清空了定時器,并重新賦值新的定時任務整份; - 后續(xù)用戶持續(xù)輸入時待错,反復執(zhí)行上一步的操作;
- 當用戶停止輸入時烈评,經(jīng)過 1 秒后火俄,則終于可以執(zhí)行定時器里的任務。
以前不知道為什么這么寫讲冠,現(xiàn)在了解了瓜客,記住就行了。另外debounce
函數(shù)中有一個問題一直被人問起竿开,就是為什么要fn.apply(this, arguments)
這樣谱仪,而不是直接fn()
這樣。其實不使用apply
也是可以的否彩,但為了程序的穩(wěn)定性疯攒,還是加入比較好,畢竟又不麻煩列荔。加入apply
后解決了兩個不穩(wěn)定因素:
- 不使用防抖函數(shù)時敬尺,在fn中打印this,本例中指向的是
<input id="input" type="text">
贴浙,而在加入防抖函數(shù)后筷转,指向的是Window
對象,所以要手動改正 this 指向悬而。 - 事件處理函數(shù)中會提供事件對象
event
呜舒,使用防抖函數(shù)前后會改變事件對象。比如例子中笨奠,使用防抖前袭蝗,event
指向的是KeyboardEvent
對象,加入防抖后則變成undefined
了般婆,所以也要手動傳入?yún)?shù)到腥。
這些都是js基礎,還是需要打牢的蔚袍。如果源碼中的定時器里不是箭頭函數(shù)乡范,就需要這樣寫了:
function debounce(fn, delay = 1000) {
let timer = null
return function(...args) { // 此處顯示定義出參數(shù)對象
let context = this // 緩存 this 對象
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(function() {
fn.apply(context, args) // 注意改變
timer = null
}, delay)
}
}
節(jié)流(throttle)
也用一句話概括節(jié)流:高頻事件觸發(fā)配名,但在n秒內(nèi)只會執(zhí)行一次,所以節(jié)流會稀釋函數(shù)的執(zhí)行頻率晋辆。
同樣是抑制高頻事件觸發(fā)渠脉,與防抖的區(qū)別在于它不需要用戶停頓,而是在持續(xù)觸發(fā)的過程中每隔 n 秒執(zhí)行一次瓶佳。
實現(xiàn)節(jié)流一般有兩個方向芋膘,一是使用時間戳,而是使用定時器霸饲。
既然是為了比較为朋,那還是使用上面的例子,即監(jiān)聽keyup
事件厚脉。我們先看用時間戳來實現(xiàn)節(jié)流:
// fn 函數(shù)傳入用戶方法
// wait 間隔執(zhí)行時間习寸,默認 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框持續(xù)輸入時,會發(fā)現(xiàn)每隔 1 秒就會打印值精钮。我們來梳理一下流程:
- 輸入第一個字符時威鹿,進入節(jié)流邏輯,時間戳肯定大于 1 秒轨香,所以立刻執(zhí)行打印操作忽你,同時將
previous
設定為當前時間戳; - 持續(xù)輸入臂容,間隔時間小于 1 秒時科雳,不執(zhí)行操作,
previous
不變脓杉,now
一直在增長糟秘; - 當
now
增長到與previous
的差值大于 1000 時,執(zhí)行打印球散,更新previous
; - 如此往復尿赚,每隔 1 秒打印一次。而最后輸入的值則不會被打印蕉堰,因為持續(xù)的過程中凌净,最后一次的差值還沒到1000就停止輸入了,超過 1000 時屋讶,則是算重新第一次輸入了冰寻。我說的可能不好明白,自己走一遍流程就清楚了皿渗。
由此斩芭,上面的節(jié)流方案可以做到限制高頻觸發(fā)事件轻腺,它的特點是:使用時間戳方式實現(xiàn)的節(jié)流,在第一次觸發(fā)時會立刻執(zhí)行划乖,而停止觸發(fā)后沒有辦法再執(zhí)行事件贬养。
現(xiàn)在我們再來試試使用定時器實現(xiàn)的節(jié)流方式,放上源碼:
// fn 函數(shù)傳入用戶方法
// wait 間隔執(zhí)行時間迁筛,默認 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 秒執(zhí)行一次耕挨,但也稍有區(qū)別细卧,再來梳理一下這個流程:
- 輸入第一個字符時,進入節(jié)流邏輯筒占,初始定時器無值贪庙,所以賦值新的定時任務,1 秒后執(zhí)行翰苫;
- 此時用戶在持續(xù)輸入止邮,但因為第 1 步定時器已經(jīng)被賦值了,所以不重新賦值了奏窑,函數(shù)不執(zhí)行邏輯导披;
- 此時 1 秒已經(jīng)過去了,第一步中的定時任務觸發(fā)埃唯,執(zhí)行打印操作撩匕,清空定時器;
- 繼續(xù)輸入時墨叛,
timeout
定時器因為被清空了止毕,所以重新賦值,走第 1 步中的邏輯漠趁; - 如此往復扁凛,總是間隔 1 秒執(zhí)行一次〈炒可以發(fā)現(xiàn)第一次觸發(fā)事件時不會立刻執(zhí)行谨朝,而停止輸入時,最后還會執(zhí)行一次甥绿。
自己多過幾遍流程就會很清晰了字币。
總結
來做個總結:
防抖與節(jié)流的區(qū)別
我不想從定義上說區(qū)別,直接從使用結果上比較區(qū)別:
- 使用防抖:持續(xù)觸發(fā)高頻事件時妹窖,只要觸發(fā)時間間隔小于設定的時間閥值纬朝,不管持續(xù)多久都不會執(zhí)行用戶操作,只有當停頓時間超過設定的時間閥值時骄呼,才會執(zhí)行一次操作共苛。
- 使用節(jié)流:持續(xù)觸發(fā)高頻事件時判没,每隔一段時間就觸發(fā)一次操作,不需要“停頓”隅茎,這個一段時間是指你設定的時間閥值澄峰。所以在這個持續(xù)的過程中,會多次觸發(fā)操作辟犀,而防抖是一個持續(xù)過程后只觸發(fā)一次俏竞。
所以何時使用防抖,何時使用節(jié)流堂竟,全看你需要的效果魂毁,而效果就是上面總結的。
節(jié)流兩種實現(xiàn)方式的區(qū)別
- 時間戳方式:事件會立刻執(zhí)行出嘹,事件停止觸發(fā)后沒有辦法再執(zhí)行席楚。
- 定時器方式:事件會在 n 秒后第一次執(zhí)行,事件停止觸發(fā)后依然會再執(zhí)行一次事件税稼。
當然有時候這兩種節(jié)流方式可能都不能滿足需求烦秩,比如你既想要能夠立即執(zhí)行,也要結束時還能執(zhí)行一次郎仆,又比如你想要自己控制它開始和結束的狀態(tài)只祠,不用怕,社區(qū)里都能找到你要的扰肌,況且我們還有 Lodash 這樣優(yōu)秀的插件抛寝。我在這里只是想要介紹一下他們的原理和區(qū)別。
如有不對之處狡耻,望指正墩剖,謝謝。