相比網上教程中的 debounce
函數,lodash 中的 debounce
功能更爲強大,相應的理解起來更爲複雜;css
解讀源碼通常都是直接拿官方源碼來解讀,不過此次咱們採用另外的方式:從最簡單的場景開始寫代碼,而後慢慢往源碼上來靠攏,按部就班來實現 lodash 中的 debounce
函數,從而更深入理解官方 debounce 源碼的用意。前端
爲了減小純代碼帶來的晦澀感,本文以圖例來輔助講解,一方面這樣能減小源碼閱讀帶來的枯燥感,同時也讓後續回憶源碼內容更加的具體形象。(記住圖的內容,後續再寫出源碼也變得簡單些)git
在本文的末尾還會附上簡易的 debounce & throttle 的實現的代碼片斷,方便平時快速用在簡單場景中,免去引用 lodash
庫。github
本文屬於源碼解讀類型的文章,對 debounce 還不熟悉的讀者建議先經過參考文章(在文末)瞭解該函數的概念和用法。
附源碼 debounce: https://github.com/boycgit/ts...
首先搬出 debounce
(防抖)函數的概念:函數在 wait
秒內只執行一次,若這 wait
秒內,函數高頻觸發,則會從新計算時間。面試
看似簡單一句話,內含乾坤。爲方便行文敘述,約定以下術語:npm
func
函數進行 debounce
處理,經 debounced 後的返回值咱們稱之爲 debounced func
wait
表示傳入防抖函數的時間time
表示當前時間戳lastCallTime
表示上一次調用 debounced func
函數的時間lastInvokeTime
表示上一次 func
函數執行的時間result
是每次調用 debounced func
函數的返回值time
表示當前時間本文將搭配圖例 + 程序代碼的方式,將上述概念具象到圖中。segmentfault
以最簡單的情景爲例:在某一時刻點只調用一次 debounced func
函數,那麼將在 wait
時間後纔會真正觸發 func
函數。緩存
將這個情景造成一幅圖例,最終繪製出的圖以下所示:性能優化
下面咱們詳細講解這幅圖的產生過程,其實不難,基本上看一遍就懂。微信
首先繪製在圖中放置一個黑色鬧鐘表示用戶調用 debounced func
函數:(同時用 lastCallTime
標示出最近一次調用 debounced func
的時間)
同時在距離該黑色鬧鐘 wait
處放置一個藍色鬧鐘,表示setTimout(..., wait)
,該藍色鬧鐘表示將來當代碼運行到該時間點時,須要作一些判斷:
爲了標示出表示程序當前運行的進度(當前時間戳),咱們用橙紅色滑塊來表示:
當紅色滑塊到達該藍色鬧鐘處的時候,藍色鬧鐘會進行判斷:由於當前滑塊距離最近的黑色鬧鐘的時間差爲 wait
:
故而作出判斷(依據 debounce
函數的功能定義):須要觸發一次 func
函數,咱們用紅色鬧鐘來表示 func
函數的調用,因此就放置一個紅色鬧鐘
很顯然藍色和紅色鬧鐘重疊起來的。
同時咱們給紅色鬧鐘標上 lastInvokeTime
,記錄最近一次調用 func
的時間:
注意lastInvokeTime
和lastCallTime
的區別,二者含義是不同的
這樣咱們就完成了最簡單場景下 debounce
圖例的繪製,簡單易懂。
後續咱們會逐漸增長黑色鬧鐘出現的複雜度,不斷去分析紅色鬧鐘的位置。這樣就能將理解 debounce
源碼的問題轉換成「根據圖上黑色鬧鐘的位置,請畫出紅色鬧鐘位置」的問題,而分析紅色鬧鐘位置的過程當中也就是理解 debounce
源碼的過程;
用圖例方式輔助理解源碼的方式能夠減小源碼閱讀帶來的枯燥感,同時後續回憶源碼內容起來也更加具體形象。
爲避免後續寫文章處處解釋圖中元素的概念含義,這裏不妨先羅列出來,若是閱讀過程當中忘記到這裏回憶一下也會方便許多:
time
debounced func
函數的調用debounced func
函數時的時間,最後一次黑色鬧鐘上標上 lastCallTime
,表示最近一次調用的時間戳;func
函數的時間,最後一次紅色鬧鐘上標上 lastInvokeTime
,表示最近一次調用的時間戳;func
函數執行的時間),每次時間軸上的橙紅色滑塊到這個時間點就要作判斷:是執行 func
或者推遲藍色鬧鐘位置有關藍色鬧鐘,這裏有兩個注意點:
debounced func
函數時纔會在 wait
時間後放置藍色鬧鐘,後續鬧鐘的出現位置就由藍色鬧鐘本身決策(下文會舉例說明)如今咱們來一個稍微複雜的場景:
假如在 wait
時間內(記住這個前提條件)調用 n 次 debounced func
函數,以下所示:
第一次調用 debounced func
函數會在 wait
時間後放置藍色鬧鐘(只有第一次調用會放置藍色鬧鐘,後續鬧鐘的位置由藍色鬧鐘本身決策):
以上就是描述,那麼問題來了:請問紅色鬧鐘應該出如今時間軸哪一個位置?
咱們只關注最後一個黑色鬧鐘,並假設藍色鬧鐘距離該黑色鬧鐘時間間隔爲 x
:
那麼第一個黑色鬧鐘和最後一個黑色鬧鐘的時間間隔是 wait - x
:
接下來咱們關注橙紅色滑塊(即當前時間time
)到達藍色鬧鐘的時,藍色鬧鐘開始作決策:計算可知 x < wait
,此時藍色鬧鐘決定不放置紅色鬧鐘(即不觸發 func
),而是將藍色鬧鐘日後挪了挪,挪動距離爲 wait - x
,調整完以後的藍色鬧鐘位置以下:
之因此挪 wait - x
的距離,是由於挪完後的藍色鬧鐘距離最後一個黑色鬧鐘剛好爲 wait
間隔(從而保證 debounce
函數至少間隔 wait
時間 才觸發的條件):
從挪移以後開始,到下一次橙色鬧鐘再次遇到藍色鬧鐘這段期間,咱們暫且稱之爲 」藍色決策間隔期「(請忍耐這抽象的名稱,畢竟我想了很久),藍色鬧鐘基於此間隔期的內容來進行決策,只有兩種決策:
wait
(time - lastCallTime >= wait
),那就會放置紅色鬧鐘(即調用 func
),目標達成;
y
,隨後 又會日後挪動位置 wait-y
,再一次保證藍色鬧鐘距離最後一個黑色鬧鐘剛好爲 wait
間隔 —— 沒錯,又造成了新的 」藍色決策間隔期「;那接下去的分析就又回到了 這裏兩點(即遞歸決策),直到能放置到紅鬧鐘爲止。
從上咱們能夠看到,藍色鬧鐘一直保持 」紳士「 風範,隨着黑色鬧鐘的逼近,藍色鬧鐘一直保持」剋制「態度,不斷調整本身的位置,讓調整後的位置老是和最後一個黑色鬧鐘保持 wait
的距離。
咱們用代碼將上述的過程描述出來,就是下面這個樣子:
function debounce(func, wait, options) { var lastArgs, lastThis, result, timerId, lastCallTime, lastInvokeTime = 0, trailing = true; wait = toNumber(wait) || 0; // 紅色滑塊達到藍色鬧鐘時,藍色鬧鐘根據條件做出決策 function timerExpired() { var time = now(); // 決策 1: 知足放置紅色鬧鐘的條件,則放置紅鬧鐘 if (shouldInvoke(time)) { return trailingEdge(time); } // 不然,決策 2:將藍色鬧鐘再日後挪 `wait-x` 位置,造成 」藍色決策間隔期「 timerId = setTimeout(timerExpired, remainingWait(time)); } // === 如下是具體決策中的函數實現 ==== // 作出 」應當放置紅色鬧鐘「 的決策的條件:藍色鬧鐘和最後一個黑色鬧鐘的間隔不小於 wait 間隔 function shouldInvoke(time) { var timeSinceLastCall = time - lastCallTime; return ( timeSinceLastCall >= wait ); } // 具體函數:放置紅色鬧鐘 function trailingEdge(time) { timerId = undefined; if (trailing && lastArgs) { return invokeFunc(time); } lastArgs = lastThis = undefined; return result; } // 具體函數 - 子函數:在時間軸上放置紅鬧鐘 function invokeFunc(time) { var args = lastArgs, thisArg = lastThis; lastArgs = lastThis = undefined; lastInvokeTime = time; result = func.apply(thisArg, args); return result; } // 具體函數:計算讓藍色鬧鐘日後挪 wait-x 位置 function remainingWait(time) { var timeSinceLastCall = time - lastCallTime, timeWaiting = wait - timeSinceLastCall; return timeWaiting ; } // ============== // 主流程:讓紅色滑塊在時間軸上前進(即 debounced func 函數的執行) function debounced() { var time = now(); lastArgs = arguments; lastThis = this; lastCallTime = time; if (timerId === undefined) { timerId = setTimeout(timerExpired, wait); } return result; } return debounced; }
這部分代碼還請不要略過,由於代碼是從debounce
源碼中整理過來,除了函數順序略有調整外,源碼風格保持原有的,至關於直接閱讀源碼。每一個函數都有註釋,對比着圖例閱讀下來相信讀完會有收穫的。
上述這份代碼已經包含了 debounce
源碼的核心骨架,接下來咱們繼續擴展場景,將源碼內容豐滿起來。
leading
功能簡單理解就是,在第一次(注意這個條件)放下黑色鬧鐘的時候:
wait
處放置方式藍色鬧鐘(注:第一次放下黑色鬧鐘的時候,按理說也會在 wait
處放下藍色鬧鐘,考慮既然 leading
也有這種操做,那麼就很少此一舉。記住:整個時間軸上最多隻能同時有一個藍色鬧鐘)用圖說話:
第一次放置黑色鬧鐘的時候,會疊加上紅色鬧鐘(固然這個紅色鬧鐘上會標示 lastInvokeTime
),另外在 wait
間隔後會有藍色鬧鐘。其餘流程和以前案例分析同樣。
在代碼層面,咱們給剛纔的 debounce
函數添加 leading
功能(經過 options.leading
開啓)、新增一個 leadingEdge
方法後,再微調剛纔的代碼:
function debounce(func, wait, options) { ... var leading = false; // 默認不開啓 leading = !!options.leading; // 經過 options.leading 開啓 ... // 首先:新增執行 leading 處的操做的函數 function leadingEdge(time) { lastInvokeTime = time; // 設置 lastInvokeTime 時間標籤 timerId = setTimeout(timerExpired, wait); // 同時在此後 `wait` 處放置一個藍色鬧鐘 return leading ? invokeFunc(time) : result; // 若是開啓,直接放置紅色鬧鐘;不然直接返回 result 數值 } ... // 其次:給放置紅色鬧鐘新增一種條件 function shouldInvoke(time) { ... return ( lastCallTime === undefined || // 初次執行時 timeSinceLastCall >= wait // 或者前面分析的條件,兩次 `debounced func` 調用間隔大於 wait ); } // 注意:放置完紅色鬧鐘後,記得要清空 timerId,至關於清空時間軸上藍色鬧鐘; function trailingEdge(time) { timerId = undefined; ... } // 最後:leading 邊界調用 function debounced(){ ... var isInvoking = shouldInvoke(time); // 判斷是否能夠放置紅色鬧鐘 ... if (isInvoking) { // 若是能夠放置紅色鬧鐘 if (timerId === undefined) { // 且當時間軸上沒有藍色鬧鐘 // 執行 leading 邊界處操做(放置紅色鬧鐘 或 直接返 result) return leadingEdge(lastCallTime); } } ... return result; } return debounced; }
要理解這個 maxWait
特性,咱們先看一種特殊狀況,在 {leading: false} 下, 時間軸上咱們很密集地放置黑色鬧鐘:
按以前的所述規則,咱們的藍色鬧鐘一直保持紳士態度,隨着黑色鬧鐘的逼近,藍色鬧鐘將不斷將調整本身的位置,讓本身調整後的位置老是和最後一個黑色鬧鐘保持 wait
的距離:
那麼在這種狀況下,若是黑色鬧鐘一直保持這種密集放置狀態,理論上就紅色鬧鐘就沒有機會出如今時間軸上。
那在這種狀況下可否實現一個功能,不管黑色鬧鐘多麼密集,時間軸上最多隔 maxWait
時間就出現紅色鬧鐘,就像下圖那樣:
有了這個功能屬性後,藍色鬧鐘今後 」變得堅強「,也有了 "底線",縱使黑色鬧鐘的不斷逼近,也會堅守 maxWait
底線,到點就放置紅色鬧鐘。
實現該特性的大體思路以下:
maxWait
是與 lastInvokeTime
共同協做maxWait
發揮做用;在沒有 maxWait
的時候,是按上一次黑色鬧鐘進行測距,保證調整後的藍色鬧鐘和黑色鬧鐘保持 wait
的距離;而在有了 maxWait
後,藍色鬧鐘調整距離還會考慮上一次紅色鬧鐘的位置,保持調整後鬧鐘的位置和紅色鬧鐘距離不能超過 maxWait
,這就是底線了,到了必定程度,就算黑色鬧鐘在逼近,藍色鬧鐘也不會 」退縮「:
從代碼層面上看, maxWait
具體是在 remainingWait
方法 和 shouldInvoke
中發揮做用的:
function debounce(func, wait, options) { ... var lastInvokeTime = 0; // 初始化 var maxing = false; // 默認沒有底線 maxing = 'maxWait' in options; maxWait = maxing ? nativeMax(toNumber(options.maxWait) || 0, wait) : maxWait; // 從 options.maxWait 中獲取底線數值 ... // 首先,在在藍色鬧鐘決策後退多少距離時,maxWait 發揮了做用 function remainingWait(time) { var timeSinceLastCall = time - lastCallTime, timeSinceLastInvoke = time - lastInvokeTime, timeWaiting = wait - timeSinceLastCall; // 在這裏發揮做用,保持底線 return maxing ? nativeMin(timeWaiting, maxWait - timeSinceLastInvoke) : timeWaiting; } ... // 其次:針對 `maxWait`,給放置紅色鬧鐘新增一種可能條件 function shouldInvoke(time) { ... var timeSinceLastInvoke = time - lastInvokeTime; // 獲取距離上一次紅色鬧鐘的時間間隔 return ( lastCallTime === undefined || // 初次執行時 timeSinceLastCall >= wait || // 或者前面分析的條件,兩次 `debounced func` 調用間隔大於 wait (maxing && timeSinceLastInvoke >= maxWait) // 兩次紅色鬧鐘間隔超過 maxWait ); } // 最後:leading 邊界調用 function debounced(){ ... var isInvoking = shouldInvoke(time); // 判斷是否能夠放置紅色鬧鐘的條件 ... if (isInvoking) { // 若是能夠放置紅色鬧鐘 ... // 邊界狀況的處理,保證在緊 loop 中能正常保持觸發 if (maxing) { timerId = setTimeout(timerExpired, wait); return invokeFunc(lastCallTime); } } ... return result; } return debounced; }
所以,maxWait
可以讓紅色鬧鐘保證在 maxWait
間隔內至少出現 1 次;
這兩個函數是爲了能隨時控制 debounce
的緩存狀態;
其中 cancel
方法源碼以下:
// 取消防抖 function cancel() { if (timerId !== undefined) { clearTimeout(timerId); } lastInvokeTime = 0; lastArgs = lastCallTime = lastThis = timerId = undefined; }
調用該方法,至關於直接在時間軸上去除藍色鬧鐘,這樣紅色方塊(時間)就永遠碰見不了藍色鬧鐘,那樣也就不會有放置紅色鬧鐘的可能了。
其中 flush
方法源碼以下:
function flush() { return timerId === undefined ? result : trailingEdge(now()); }
很是直觀,調用該方法至關於直接在時間軸上放置紅色鬧鐘。
至此,咱們已經完整實現了 lodash 的 debounce
函數,也就至關於閱讀了一遍其源碼。
在完成上面 debounce
功能和特性後(尤爲是 maxWait
特性),就能借助 debounce
實現 throttle
函數了。
看 throttle 源碼 就能明白:
function throttle(func, wait, options) { var leading = true, trailing = true; // ... return debounce(func, wait, { 'leading': leading, 'maxWait': wait, 'trailing': trailing }); }
因此在 lodash
中,只須要 debounce
函數便可,throttle
至關於 」充話費「 送的。
至此,咱們已經解讀完 lodash 中的 debounce & throttle
函數源碼;
最後附帶一張 lodash 實現執行效果圖,用來自測是否真的理解通透:
注:此圖取自於文章《 聊聊lodash的debounce實現》
在前端領域的性能優化手段中,防抖(debounce
)和節流(throttle
)是必備的技能,網上隨便一搜就有不少文章去分析解釋,不乏優秀的文章使用 圖文混排 + 類比方式 深刻淺出探討這兩函數的用法和使用場景(見文末的參考文檔)。
那我爲何還要寫這一篇文章?
緣起前兩天手動將 lodash 中的 debounce
和 throttle
兩個函數 TS 化的需求,而平時我也只是使用並無在乎它們真正的實現原理,所以在遷移過程我順帶閱讀了一番 lodash 中這兩個函數的源碼。
具體緣由和遷移過程請移步《 技巧 - 快速 TypeScript 化 lodash 中的 throttle & debounce 函數》
本文嘗試提供了另外一個視角去解讀,經過時間軸 + 鬧鐘圖例 + 代碼的方式來解讀 lodash 中的 debounce
& throttle
源碼;
整個流程下來只要理解了黑色、藍色、紅色這 3 種鬧鐘的關係,那麼憑着理解力去實現簡版 lodash 的 debounce
函數並不是難事。
固然上述的敘述中,略過了不少細節和存在性的判斷(諸如 timeId
的存在性判斷、isInvoking
的出現位置等),省略這主要是爲了下降源碼閱讀的難度;(實際中這些細節的處理有時候反而很重要,是代碼健壯性不可或缺的一部分)
但願本文能對讀者理解 lodash 中的 debounce
& throttle
源碼有些許的幫助,歡迎隨時關注微信公衆號或者技術博客留言交流。
若是在你僅僅須要應付簡單的一些場景,也能夠直接使用下方的代碼片斷。
trailing
狀況防抖函數的概念:函數在 n
秒內只執行一次,若這 n
秒內,函數高頻觸發,則會從新計算時間。
將這段話翻譯成代碼,你會發現並不難:
//防抖代碼最簡單的實現 function debounce(func, wait) { let timerId, result; return function() { if(timerId){ clearTimeout(timerId); // 每次觸發 都清除當前timer,從新設置時間 } timerId = setTimeout(function(){ result = func.apply(this, arguments); }, wait); return result; } }
假如調用該閉包兩次:
上述的實現,是最經典的 trailing
狀況,即以 wait 間隔結束點做爲函數調用計時點,是咱們平時用的最多的場景
leading
功能另外用得比較多的就是以 wait 間隔開始點做爲函數調用計時點,即 leading
功能。
將上面代碼中最後的 setTimeout
內容改爲 timerId = undefined
,而將 fn.apply
提取出來加個 if
條件語句就行 ,修改後代碼以下:
//防抖代碼最簡單的實現 function debounce(func, wait) { let timerId, result; return function() { if(timerId){ clearTimeout(timerId); // 每次觸發 都清除當前timer,從新設置時間 } if(!timerId){ result = fn.apply(this, arguments); } timerId = setTimeout(function() { timerId = undefined; }, wait); return result; } }
fn.apply(lastThis, lastArgs)
之因此用 if 條件包裹,是針對首次調用的邊界狀況
timerId
是閉包變量,至關於標誌位,經過它能夠知道某個函數的調用是否在上一次函數調用的影響範圍內假如調用該閉包兩次:
timerId
已是 underfined
的,因此會當即執行 函數,因此最終這兩次調用都會執行
throttle
函數的概念:函數在 n
秒內只執行一次,若這 n
秒內還在有函數調用的請求都直接被忽略掉。
實現原理也很簡單:定義開關變量 canRun
,在定時開啓的這段時間內控制這個開關變量爲canRun = false
(上鎖),執行完後才讓 canRun = true
便可。
function throttle(func, wait) { let canRun = true return function () { if (!canRun) { return // 若是開關關閉了,那就直接不執行下邊的代碼 } canRun = false // 持續觸發的話,run一直是false,就會停在上邊的判斷那裏 setTimeout(() => { func.apply(this, arguments) canRun = true // 定時器到時間以後,會把開關打開,咱們的函數就會被執行 }, wait) } }
下面的是個人公衆號二維碼圖片,歡迎關注交流。