淺談js防抖和節流
防抖和節流嚴格算起來應該屬於效能優化的知識,但實際上遇到的頻率相當高,處理不當或者放任不管就容易引起瀏覽器卡死。所以還是很有必要早點掌握的。(信我,你看完肯定就懂了)
從滾動條監聽的例子說起
先說一個常見的功能,很多網站會提供這麼一個按鈕:用於返回頂部。
這個按鈕只會在滾動到距離頂部一定位置之後才出現,那麼我們現在抽象出這個功能需求-- 監聽瀏覽器滾動事件,返回當前滾條與頂部的距離
這個需求很簡單,直接寫:
function showTop() { var scrollTop = document.body.scrollTop || document.documentElement.scrollTop; console.log('滾動條位置:' + scrollTop); } window.onscroll= showTop
但是!
在執行的時候會發現存在一個問題: 這個函式的預設執行頻率,太!高!了!。 高到什麼程度呢?以chrome為例,我們可以點選選中一個頁面的滾動條,然後點選一次鍵盤的【向下方向鍵】,會發現函式執行了 8-9次 !
然而實際上我們並不需要如此高頻的反饋,畢竟瀏覽器的效能是有限的,不應該浪費在這裡,所以接著討論如何優化這種場景。
防抖(debounce)
基於上述場景,首先提出第一種思路: 在第一次觸發事件時,不立即執行函式,而是給出一個期限值比如200ms
- 如果在200ms內沒有再次觸發滾動事件,那麼久執行函式
- 如果在200ms內再次觸發滾動事件,那麼當前的計時取消,重新開始計時
效果:如果短時間內大量觸發同一事件,只會執行一次函式。
實現:既然前面都提到了計時,那實現的關鍵就在於 setTimeOut
這個函式,由於還需要一個變數來儲存計時,考慮維護全域性純淨,可以藉助閉包來實現:
/* * fn [function] 需要防抖的函式 * delay [number] 毫秒,防抖期限值 */ function debounce(fn,delay){ let timer = null //藉助閉包 return function() { if(timer){ clearTimeout(timer) //進入該分支語句 timer = setTimeOut(fn,delay) 說明如果當前正在計時過程中,又觸發了相同事件。所以取消當前的計時,重新開始計時 }else{ timer = setTimeOut(fn,delay) // 進入該分支說明當前並沒有在計時,那麼就開始一個計時 } } }
當然 上述程式碼是為了貼合思路,方便理解(這麼貼心不給個贊咩?),寫完會發現其實 time = setTimeOut(fn,delay)
是一定會執行的,所以可以稍微簡化下:
/*****************************簡化後的分割線 ******************************/ function debounce(fn,delay){ let timer = null //藉助閉包 return function() { if(timer){ clearTimeout(timer) } timer = setTimeout(fn,delay) // 簡化寫法 } } // 然後是舊程式碼 function showTop() { var scrollTop = document.body.scrollTop || document.documentElement.scrollTop; console.log('滾動條位置:' + scrollTop); } window.onscroll = debounce(showTop,1000) // 為了方便觀察效果我們取個大點的間斷值,實際使用根據需要來配置
此時會發現,必須在停止滾動1秒以後,才會打印出滾動條位置。
到這裡,已經把 防抖 實現了,現在給出定義:
- 對於 短時間內連續觸發 的事件(上面的滾動事件), 防抖 就是限制 某個時間段內(上面的1000毫秒) 事件處理函式只會執行一次。
節流(throttle)
繼續思考,使用上面的防抖方案來處理問題的結果是:
- 如果在限定時間段內,不斷觸發滾動事件(比如某個使用者閒著無聊,按住滾動不斷的拖來拖去),只要不停止觸發,理論上就永遠不會輸出當前距離頂部的距離。
但是如果產品同學的期望處理方案是即使使用者不斷拖動滾動條,也能再某個時間間隔之後給出反饋呢?(此處暫且不論哪種方案更合適,既然產品爸爸說話了我們就先考慮怎麼實現)
其實很簡單:在前面講防抖的時候,遇到期限內的連續觸發,我們的處理方案是【重新計時】;而這裡我們不重新計時,而是類似 控制閥門 一樣定期開放,也就是 執行完一次讓該函式暫時失效,等到一段時間後再重新啟用 。
效果:如果短時間內大量觸發同一事件,那麼在執行一次函式之後,該函式在指定的 時間期限 內不再工作,直至過了這段時間才重新生效。
實現這裡藉助 setTimeout
來實現一個簡單的方案,我們加上一個狀態位 valid
來表示當前函式是否處於工作狀態:
function throttle(fn,delay){ let valid = true return function() { if(!valid){ //休息時間 暫不接客 return false } // 工作時間,執行函式並且在間隔期內把狀態位設為無效 valid = false setTimeout(() => { fn() valid = true; }, delay) } } /* 節流函式並不止上面這種實現方案, 例如可以完全不借助setTimeout,可以把valid換成時間戳,然後利用時間戳想減是否大於指定間隔時間來寫。 又或者不額外增加狀態位 直接將setTimeout的返回標記當做狀態位,判斷當前定時器是否存在,並且在執行fn之後消除定時器即可,原理都一樣 */ // 以下照舊 function showTop() { var scrollTop = document.body.scrollTop || document.documentElement.scrollTop; console.log('滾動條位置:' + scrollTop); } window.onscroll = throttle(showTop,1000)
執行以上程式碼的結果是:
- 如果一直拖著滾動條進行滾動,那麼會以1s的時間間隔,持續輸出當前位置和頂部的距離
其他應用場景舉例
講完了這兩個技巧,下面介紹一下平時開發中常遇到的場景:
- 搜尋框input事件,例如要支援輸入實時搜尋可以使用節流方案(間隔一段時間就必須查詢相關內容),或者實現輸入間隔大於某個值(如500ms),就當做使用者輸入完成,然後開始搜尋,具體使用哪種方案要看業務需求。
- 頁面resize事件,常見於需要做頁面適配的時候。需要根據最終呈現的頁面情況進行dom渲染(這種情形一般是使用防抖,因為只需要判斷最後一次的變化情況)
思考總結
上述內容基於防抖和節流的核心思路設計了簡單的實現演算法,但是不代表實際的庫(例如undercore js)的原始碼就直接是這樣的,最起碼的可以看出,在上述程式碼實現中,因為 showTop
本身的很簡單,無需考慮作用域和引數傳遞,所以連 apply
都沒有用到,實際上肯定還要考慮傳遞 argument
以及上下文環境(畢竟apply需要用到this物件)。這裡的相關知識在 本專欄《柯里化》和《this物件》 的文章裡也有提到。本文依然堅持突出核心程式碼,儘可能剝離無關功能點的思路行文因此不做贅述。
慣例:如果內容有錯誤的地方歡迎指出(覺得看著不理解不舒服想吐槽也完全沒問題);如果有幫助,歡迎點贊和收藏,轉載請徵得同意後著明出處,如果有問題也歡迎私信交流,主頁有郵箱地址