Debounce 和 throttle 是我們在 JavaScript 中使用的兩個概念,用於增強對函式執行的控制,這在事件處理程式中特別有用。這兩種技術都回答了同一個問題“一段時間內某個函式的呼叫頻率是多少?”
? 相關連結
文中內容多數來自以下文章,侵刪!
? Debounce
1. 概念
-
本是機械開關的“去彈跳”概念,彈簧開關按下後,由於簧片的作用,接觸點會連續接觸斷開好多次,如果每次接觸都通電對用電器不好,所以就要控制按下到穩定的這段時間不通電
-
前端開發中則是一些頻繁的事件觸發
- 滑鼠(
mousemove
...)鍵盤(keydown
...)事件等 - 表單的實時校驗(頻繁傳送驗證請求)
- 滑鼠(
-
在 debounce 函式沒有再被呼叫的情況下經過 delay 毫秒後才執行回撥函式,例如
- 在
mousemove
事件中,確保多次觸發只呼叫一次監聽函式 - 在表單校驗的時候,不加防抖,依次輸入
user
,就會分成u
,us
,use
,user
四次發出請求;而新增防抖,設定好時間,可以實現完整輸入user
才發出校驗請求
- 在
2. 思路
-
由 debounce 的功能可知防抖函式至少接收兩個引數(流行類庫中都是 3 個引數)
- 回撥函式
fn
- 延時時間
delay
- 回撥函式
-
debounce 函式返回一個閉包,閉包被頻繁的呼叫
- debounce 函式只呼叫一次,之後呼叫的都是它返回的閉包函式
- 在閉包內部限制了回撥函式
fn
的執行,強制只有連續操作停止後執行一次
-
使用閉包是為了使指向定時器的變數不被
gc
回收- 實現在延時時間
delay
內的連續觸發都不執行回撥函式fn
,使用的是在閉包內設定定時器setTimeOut
- 頻繁呼叫這個閉包,在每次呼叫時都要將上次呼叫的定時器清除
- 被閉包儲存的變數就是指向上一次設定的定時器
- 實現在延時時間
3. 實現
-
符合原理的簡單實現
function debounce(fn, delay) { var timer; return function() { // 清除上一次呼叫時設定的定時器 // 計時器清零 clearTimeout(timer); // 重新設定計時器 timer = setTimeout(fn, delay); }; } 複製程式碼
-
簡單實現的程式碼,可能會造成兩個問題
-
this
指向問題。debounce 函式在定時器中呼叫回撥函式fn
,所以fn
執行的時候this
指向全域性物件(瀏覽器中window
),需要在外層用變數將this
儲存下來,使用apply
進行顯式繫結function debounce(fn, delay) { var timer; return function() { // 儲存呼叫時的this var context = this; clearTimeout(timer); timer = setTimeout(function() { // 修正 this 的指向 fn.apply(this); }, delay); }; } 複製程式碼
-
event
物件。JavaScript 的事件處理函式中會提供事件物件event
,在閉包中呼叫時需要將這個事件物件傳入function debounce(fn, delay) { var timer; return function() { // 儲存呼叫時的this var context = this; // 儲存引數 var args = arguments; clearTimeout(timer); timer = setTimeout(function() { console.log(context); // 修正this,並傳入引數 fn.apply(context, args); }, delay); }; } 複製程式碼
-
4. 完善(underscore
的實現)
-
立刻執行。增加第三個引數,兩種情況
- 先執行回撥函式
fn
,等到停止觸發後的delay
毫秒,才可以再次觸發(先執行) - 連續的呼叫 debounce 函式不觸發回撥函式,停止呼叫經過
delay
毫秒後才執行回撥函式(後執行) clearTimeout(timer)
後,timer
並不會變成null
,而是依然指向定時器物件
function debounce(fn, delay, immediate) { var timer; return function() { var context = this; var args = arguments; // 停止定時器 if (timer) clearTimeout(timer); // 回撥函式執行的時機 if (immediate) { // 是否已經執行過 // 執行過,則timer指向定時器物件,callNow 為 false // 未執行,則timer 為 null,callNow 為 true var callNow = !timer; // 設定延時 timer = setTimeout(function() { timer = null; }, delay); if (callNow) fn.apply(context, args); } else { // 停止呼叫後delay時間才執行回撥函式 timer = setTimeout(function() { fn.apply(context, args); }, delay); } }; } 複製程式碼
- 先執行回撥函式
-
返回值與取消 debounce 函式
- 回撥函式可能有返回值。
- 後執行情況可以不考慮返回值,因為在執行回撥函式前的這段時間裡,返回值一直是
undefined
- 先執行情況,會先得到返回值
- 後執行情況可以不考慮返回值,因為在執行回撥函式前的這段時間裡,返回值一直是
- 能取消 debounce 函式。一般當
immediate
為true
的時候,觸發一次後要等待delay
時間後才能再次觸發,但是想要在這個時間段內想要再次觸發,可以先取消掉之前的 debounce 函式
function debounce(fn, delay, immediate) { var timer, result; var debounced = function() { var context = this; var args = arguments; // 停止定時器 if (timer) clearTimeout(timer); // 回撥函式執行的時機 if (immediate) { // 是否已經執行過 // 執行過,則timer指向定時器物件,callNow 為 false // 未執行,則timer 為 null,callNow 為 true var callNow = !timer; // 設定延時 timer = setTimeout(function() { timer = null; }, delay); if (callNow) result = fn.apply(context, args); } else { // 停止呼叫後delay時間才執行回撥函式 timer = setTimeout(function() { fn.apply(context, args); }, delay); } // 返回回撥函式的返回值 return result; }; // 取消操作 debounced.cancel = function() { clearTimeout(timer); timer = null; }; return debounced; } 複製程式碼
- 回撥函式可能有返回值。
-
ES6 寫法
function debounce(fn, delay, immediate) { let timer, result; // 這裡不能使用箭頭函式,不然 this 依然會指向 Windows物件 // 使用rest引數,獲取函式的多餘引數 const debounced = function(...args) { if (timer) clearTimeout(timer); if (immediate) { const callNow = !timer; timer = setTimeout(() => { timer = null; }, delay); if (callNow) result = fn.apply(this, args); } else { timer = setTimeout(() => { fn.apply(this, args); }, delay); } return result; }; debounced.cancel = () => { clearTimeout(timer); timer = null; }; return debounced; } 複製程式碼
? throttle
1. 概念
-
固定函式執行的速率
-
如果持續觸發事件,每隔一段時間,執行一次事件
- 例如監聽
mousemove
事件時,不管滑鼠移動的速度,【節流】後的監聽函式會在 wait 秒內最多執行一次,並以此【勻速】觸發執行
- 例如監聽
-
window
的resize
、scroll
事件的優化等
2. 思路
-
有兩種主流實現方式
- 使用時間戳
- 設定定時器
-
節流函式 throttle 呼叫後返回一個閉包
- 閉包用來儲存之前的時間戳或者定時器變數(因為變數被返回的函式引用,所以無法被垃圾回收機制回收)
-
時間戳方式
- 當觸發事件的時候,取出當前的時間戳,然後減去之前的時間戳(初始設定為 0)
- 結果大於設定的時間週期,則執行函式,然後更新時間戳為當前時間戳
- 結果小於設定的時間週期,則不執行函式
-
定時器方式
- 當觸發事件的時候,設定一個定時器
- 再次觸發事件的時候,如果定時器存在,就不執行,知道定時器執行,然後執行函式,清空定時器
- 設定下個定時器
-
將兩種方式結合,可以實現兼併立刻執行和停止觸發後依然執行一次的效果
3. 實現
-
時間戳實現
function throttle(fn, wait) { var args; // 前一次執行的時間戳 var previous = 0; return function() { // 將時間轉為時間戳 var now = +new Date(); args = arguments; // 時間間隔大於延遲時間才執行 if (now - previous > wait) { fn.apply(this, args); previous = now; } }; } 複製程式碼
- 觸發監聽事件,回撥函式會立刻執行(初始的
previous
為 0,除非設定的時間間隔大於當前時間的時間戳,否則差值肯定大於時間間隔) - 停止觸發後,無論停止時間在哪,都不會再執行。例如,1 秒執行 1 次,在 4.2 秒停止,則第 5 秒不會再執行 1 次
- 觸發監聽事件,回撥函式會立刻執行(初始的
-
定時器實現
function throttle(fn, wait) { var timer, context, args; return function() { context = this; args = arguments; // 如果定時器存在,則不執行 if (!timer) { timer = setTimeout(function() { // 執行後釋放定時器變數 timer = null; fn.apply(context, args); }, wait); } }; } 複製程式碼
- 回撥函式不會立刻執行,要在 wait 秒後第一次執行,停止觸發閉包後,如果停止時間在兩次執行之間,則還會執行一次
-
結合時間戳和定時器實現
function throttle(fn, wait) { var timer, context, args; var previous = 0; // 延時執行函式 var later = function() { previous = +new Date(); // 執行後釋放定時器變數 timer = null; fn.apply(context, args); if (!timeout) context = args = null; }; var throttled = function() { var now = +new Date(); // 距離下次執行 fn 的時間 // 如果人為修改系統時間,可能出現 now 小於 previous 情況 // 則剩餘時間可能超過時間週期 wait var remaining = wait - (now - previous); context = this; args = arguments; // 沒有剩餘時間 || 修改系統時間導致時間異常,則會立即執行回撥函式fn // 初次呼叫時,previous為0,除非wait大於當前時間的時間戳,否則剩餘時間一定小於0 if (remaining <= 0 || remaining > wait) { // 如果存在延時執行定時器,將其取消掉 if (timer) { clearTimeout(timer); timer = null; } previous = now; fn.apply(context, args); if (!timeout) context = args = null; } else if (!timer) { // 設定延時執行 timer = setTimeout(later, remaining); } }; return throttled; } 複製程式碼
- 過程中的節流功能是由時間戳的原理實現,同時實現了立刻執行
- 定時器只是用來設定在最後退出時增加一個延時執行
- 定時器在每次觸發時都會重新計時,但是隻要不停止觸發,就不會去執行回撥函式 fn
4. 優化完善
-
增加第三個引數,讓使用者可以自己選擇模式
- 忽略開始邊界上的呼叫,傳入
{ leading: false }
- 忽略結尾邊界上的呼叫,傳入
{ trailing: false }
- 忽略開始邊界上的呼叫,傳入
-
增加返回值功能
-
增加取消功能
function throttle(func, wait, options) { var context, args, result; var timeout = null; // 上次執行時間點 var previous = 0; if (!options) options = {}; // 延遲執行函式 var later = function() { // 若設定了開始邊界不執行選項,上次執行時間始終為0 previous = options.leading === false ? 0 : new Date().getTime(); timeout = null; // func 可能會修改 timeout 變數 result = func.apply(context, args); // 定時器變數引用為空,表示最後一次執行,則要清除閉包引用的變數 if (!timeout) context = args = null; }; var throttled = function() { var now = new Date().getTime(); // 首次執行時,如果設定了開始邊界不執行選項,將上次執行時間設定為當前時間。 if (!previous && options.leading === false) previous = now; // 延遲執行時間間隔 var remaining = wait - (now - previous); context = this; args = arguments; // 延遲時間間隔remaining小於等於0,表示上次執行至此所間隔時間已經超過一個時間視窗 // remaining 大於時間視窗 wait,表示客戶端系統時間被調整過 if (remaining <= 0 || remaining > wait) { if (timeout) { clearTimeout(timeout); timeout = null; } previous = now; result = func.apply(context, args); if (!timeout) context = args = null; } else if (!timeout && options.trailing !== false) { timeout = setTimeout(later, remaining); } // 返回回撥函式執行後的返回值 return result; }; throttled.cancel = function() { clearTimeout(timeout); previous = 0; timeout = context = args = null; }; return throttled; } 複製程式碼
- 有個問題,
leading: false
和trailing: false
不能同時設定- 第一次開始邊界不執行,但是,第一次觸發時,
previous
為 0,則remaining
值和wait
相等。所以,if (!previous && options.leading === false)
為真,改變了previous
的值,而if (remaining <= 0 || remaining > wait)
為假 - 以後再觸發就會導致
if (!previous && options.leading === false)
為假,而if (remaining <= 0 || remaining > wait)
為真。就變成了開始邊界執行。這樣就和leading: false
衝突了
- 第一次開始邊界不執行,但是,第一次觸發時,
- 有個問題,
? 總結
- 至此,完整實現了一個
underscore
中的 debounce 函式和 throttle 函式 - 而
lodash
中 debounce 函式和 throttle 函式的實現更加複雜,封裝更加徹底 - 推薦兩個視覺化執行過程的工具
- 自己實現是為了學習其中的思想,實際開發中儘量使用 lodash 或 underscore 這樣的類庫。
對比
-
throttle 和 debounce 是解決請求和響應速度不匹配問題的兩個方案。二者的差異在於選擇不同的策略
-
電梯超時現象解釋兩者區別。假設電梯設定為 15 秒,不考慮容量限制
throttle
策略:保證如果電梯第 1 個人進來後,15 秒後準時送一次,不等待。如果沒有人,則待機、debounce
策略:如果電梯有人進來,等待 15 秒,如果又有人進來,重新計時 15 秒,直到 15 秒超時都沒有人再進來,則開始運送