一、前言
為什麼會有去抖和節流這類工具函式?
在使用者和前端頁面的互動過程中,很多操作的觸發頻率非常高,比如滑鼠移動 mousemove 事件, 滾動條滑動 scroll 事件, 輸入框 input 事件, 鍵盤 keyup 事件,瀏覽器視窗 resize 事件。
在以上事件上繫結回撥函式,如果回撥函式是一些需要大量計算、消耗記憶體、HTTP 請求、DOM 操作等,那麼應用的效能和體驗就會非常的差。
去抖和節流函式的根據思想就是,減少高頻率事件處理函式 handler 的執行頻率(注意是事件處理函式,不是事件回撥函式),將多次事件的回撥合併成一個回撥來執行,從而優化效能。
二、簡單版去抖(debounce)
去抖(debounce),也叫防抖,那抖動指的是什麼呢?抖動意味著操作的不穩定性,你可以理解成躁動症,安靜不下來~防抖的含義便是為了防止抖動造成的結果不準確,等到穩定的時候再處理結果。
比如在輸入事件,滑鼠移動,滾動條滑動,鍵盤敲擊事件中,等到停止事件觸發,頻率穩定為零後,才開始執行回撥函式,也就是所謂的沒有抖動後處理。
個人總結:去抖,就是事件觸發頻率穩定後,才開始執行回撥函式, 一連串的事件觸發,但只進行一次事件處理。
頻率就是單位時間觸發的次數,如果單位時間內,事件觸發超過一次,就只執行最後一次,如果單位時間內沒有觸發超過一次,那就正常執行。去抖分為延遲執行和立即執行兩種思路。
看一個簡單版的去抖函式延遲執行實現:
<div>
輸入框: <input type="text" id="exampleInput">
</div>
<script>
window.onload = function() {
var inputEl = document.getElementById("exampleInput");
inputEl.oninput = debounce(ajax); // debouce 函式執行了,返回一個函式,該函式為事件的回撥函式
// 事件真正的處理函式(handler),引數是回撥函式傳遞過來的。
// 常見場景就是邊輸入查詢關鍵字,邊請求查詢資料,比如百度的首頁搜尋
function ajax(event) {
console.log("HTTP 非同步請求:", event.target.value);
// $.ajax() 請求資料 ...
}
function debounce(func, delay) { // 引數為傳入的事件處理函式和間隔時間
var interval = delay || 1000;
var timer = null; // 閉包儲存的 timer 變數,會常駐記憶體
return function(args) { // 返回的匿名函式是事件的回撥函式,在事件觸發時執行,引數為 DOM 事件物件(event)
var context = this; // 事件的回撥函式中,this 指向事件的繫結的 DOM 元素物件(HTMLElement)
console.log(timer);
clearTimeout(timer); // 如果事件回撥函式中存在定時器,則清空上次定時器,重新計時。如果間隔時間到後,處理函式自然就被執行了。
timer = setTimeout(function() {
func.call(context, args); // 定時器時間到後,執行事件真正的處理函式 handler
// 執行的事件處理函式(handler),需要把呼叫物件 this 和事件物件 傳遞過去,就像沒被debounce處理過一樣
}, interval)
}
}
}
</script>
上面程式碼中我的註釋已經能夠說明整個去抖的過程,再來囉嗦幾句話~
- debounce 函式在主執行緒順序執行時已經被呼叫,傳入的引數一個是真正想在事件觸發執行的事件處理函式
- 另一個引數是事件觸發的間隔時間,間隔時間內再次觸發事件,則重新計時,類似於罰你 5 分鐘內不準說話,時間到後就可以開始說話,如果 5 分鐘內說話了,則再罰你 5 分鐘內不準說話,以此類推~
- debounce 函式有一個 timer 內部變數,timer 在返回的執行函式中被訪問,形成了閉包,有關閉包的內容,可以翻看我之前的文章《JavaScript之閉包》
- bebounce 函式返回的匿名函式才是 input 事件的回撥函式,所以該匿名函式有一個預設引數 event 物件。
- 同第 4 點,匿名函式是 dom 元素註冊事件的回撥函式,所以匿名函式(回撥函式)的 this 指向 HTMLInput 元素。
- 同第 2 點,觸發函式後,如果發現閉包中儲存著 timer 變數, timer 變數初始值為 null, 之後觸發定時器後,timer 為當次定時器的 id,id 是一個數字。去抖的過程在於,如果在定時器的間隔時間內觸發了函式,它會把上一次事件觸發時定義的定時器清除,又重新定義一個定時器。如果本次定時器沒有被清除,時間到後就會自然執行事件處理函式。對這個過程有困惑的同學,可以把 timer 變數在 clearTimeout 之前列印出來就明白了。
- 延時執行了事件處理函式(handler),需要傳遞呼叫物件和事件物件過去,此處 call 可以和 apply 互換,如果用 apply, 傳遞 arguments 類陣列即可。這樣保證了引數的一致性,就像沒被 debounce 處理過一樣。
以上就是去抖函式的基本思想, 可以參考示意圖
下面這張圖是高設 3 裡講的節流函式,其實是這一節所說的去抖函式,高設 3 將 timer 變數用傳入的處理函式的屬性代替了而已。
三、立即執行
第二節的簡單版去抖函式能滿足大部分只需要觸發一次事件處理的去抖場景:輸入資料查詢事件,下拉滾動條到視窗底部懶載入資料。
但是有一個問題,假如我想輸入框輸入內容時,第一個字輸完就請資料怎麼做? 你可以理解為,你可以馬上開始說話,但是說完話後 5 分鐘不能說話,如果 5 分鐘內說話,則接下來再加 5 分鐘不能說話。如果 5 分鐘後沒說話, 那麼接下來,你又可以先說話,然後閉嘴 5 分鐘~
所以,引出來了立即執行版的去抖函式。
取消功能實現
<div>
輸入框: <input type="text" id="exampleInput">
</div>
<script>
window.onload = function() {
var inputEl = document.getElementById("exampleInput");
inputEl.oninput = debounce(ajax, 1000, true); // debouce 函式執行了,返回一個函式,該函式為事件的回撥函式
// 事件真正的處理函式(handler),引數是回撥函式傳遞過來的。
function ajax(event) {
console.log("HTTP 非同步請求:", event.target.value);
}
function debounce(func, delay, immediate) {
var interval = delay || 1000;
var timer = null; // 定時器的初始值為 null, 所以第一次觸發事件會立即執行,整個過程中 timer 充當了 flag 的作用,判斷能否立即執行(第一次或者上一次立即執行後超過了間隔時間)
return function(args) {
var context = this; // 事件的回撥函式中,this 指向事件的繫結的 DOM 元素物件(HTMLElement)
console.log(timer);
clearTimeout(timer); // 每次有新事件觸發,都會清除之前的定時器,如果可以立即執行則執行,如果不可以立即執行則重新建立定時器。
if (immediate) {
// 如果上一次的 timer 不為 null, 說明自上一次事件觸發並且立即執行處理函式後,間隔時間還未結束。所以 timer 本應為數字 id,不為 null!
callNow = !timer;
timer = setTimeout(function() {
timer = null; // 每次事件觸發,並在定時器時間超過後, 把定時器變數設定 null, 從而可以判斷出下一次是否能夠立即執行。
}, interval);
if (callNow) {
func.call(context, args);
}
} else {
timer = setTimeout(function() {
func.call(context, args); // 定時器時間到後,執行事件真正的處理函式 handler
}, interval)
}
}
}
}
</script>
上面程式碼的註釋,可以解釋整個流程,下面大致說一下:
- 非立即執行版本和前一節內容一樣,跳過。
- timer 初始值為 null, 第一次觸發為立即執行,!timer 為 true, 所以能夠立即呼叫事件處理函式。
- 每次事件觸發, 都會把 timer 重新賦值,在間隔時間到之前 timer 為數字 id, !timer 為 false, 所以不能立即執行。如果間隔時間到了,會把當次事件觸發的定時器 id 置為 null, 下一次事件觸發就能立即執行了。
- 朋友們可以通過觀察 timer 值的變化,思考整個過程,timer 在去抖的過程中充當 flag 的作用,可以用來判斷能否立即執行。
看看效果:
取消函式
假如去抖函式的間隔時間為 5 秒鐘,我在這 5 秒鐘內又想立即執行可以怎麼做?於是我們給回撥函式加個取消函式屬性。
函式也是一個物件,可以像其他一般物件那樣新增方法:
<div>
輸入框: <input type="text" id="exampleInput"><button id="cancelBtn">取消</button>
</div>
<script>
window.onload = function() {
var inputEl = document.getElementById("exampleInput");
var debouncedFunc = debounce(ajax, 5000, true); // 將事件處理函式經過去抖函式處理。
inputEl.oninput = debouncedFunc; // 繫結去抖後的事件回撥函式
var cancelBtnEL = document.getElementById("cancelBtn");
cancelBtnEL.onclick = debouncedFunc.cancel; // 繫結回撥函式的屬性 cancel 方法,點選頁面,重置去抖效果
function ajax(event) {
console.log("HTTP 非同步請求:", event.target.value);
}
function debounce(func, delay, immediate) {
var interval = delay || 5000;
var timer = null;
var revokeFunc = function(args) {
var context = this;
clearTimeout(timer);
if (immediate) {
callNow = !timer;
timer = setTimeout(function() {
timer = null;
}, interval);
if (callNow) {
func.call(context, args);
}
} else {
timer = setTimeout(function() {
func.call(context, args);
}, interval)
}
}
revokeFunc.cancel = function() {
clearTimeout(timer); // 清空上一次事件觸發的定時器
timer = null; // 重置 timer 為 null, 從而下一次事件觸發就能立即執行。
}
return revokeFunc;
}
}
</script>
看看效果:
總結
去抖函式的意義在於合併多次事件觸發為一次事件處理,從而降低事件處理函式可能引發的大量重繪重排,http 請求,記憶體佔用和頁面卡頓。
另外,本文有關 this, call, apply,閉包的知識,可以翻看我之前分享的文章。