本文同步自個人Bloggit
前段時間團隊內部搞了一個代碼訓練營,你們組織在一塊兒實現 lodash
的 throttle
和 debounce
,實現起來以爲並不麻煩,可是最後和官方的一對比,發現功能的實現上仍是有差距的,爲了尋找個人問題,把官方源碼閱讀了一遍,本文是我閱讀完成後的一篇總結。github
本文只會列出比較核心部分的代碼和註釋,若是對所有的源碼有興趣的歡迎直接看個人repo:app
throttle
(又稱節流)和debounce
(又稱防抖)其實都是函數調用頻率的控制器,這裏只作簡單的介紹,若是想了解更多關於這兩個定義的細節能夠看下後文給出的一張圖片,或者閱讀一下lodash的文檔。函數
throttle
:將一個函數的調用頻率限制在必定閾值內,例如 1s 內一個函數不能被調用兩次。oop
debounce
:當調用函數n秒後,纔會執行該動做,若在這n秒內又調用該函數則將取消前一次並從新計算執行時間,舉個簡單的例子,咱們要根據用戶輸入作suggest,每當用戶按下鍵盤的時候均可以取消前一次,而且只關心最後一次輸入的時間就好了。this
lodash
對這兩個函數又增長了一些參數,主要是如下三個:編碼
leading,函數在每一個等待時延的開始被調用spa
trailing,函數在每一個等待時延的結束被調用設計
maxwait(debounce纔有的配置),最大的等待時間,由於若是 debounce
的函數調用時間不知足條件,可能永遠都沒法觸發,所以增長了這個配置,保證大於一段時間後必定能執行一次函數code
這裏直接劇透一下,其實
throttle
就是設置了maxwait
的debounce
,因此我這裏也只會介紹debounce
的代碼,聰明的讀者們能夠本身思考一下爲何。
我本身的代碼實現放在個人repo裏,你們有興趣的能夠看下。以前說過個人實現和 lodash
有些區別,下面就用兩張圖來展現一下。
這是個人實現
這是lodash的實現
這裏看到,個人代碼主要有兩個問題:
throttle
的最後一次函數會執行兩次,並且並不是穩定復現。
throttle
裏函數執行的順序不對,雖然個人功能實現了,可是對於每一次 wait
來講,我都是執行的 leading
那一次
下面,我就會帶着這幾個問題去看看 lodasah
的代碼。
官方代碼的實現也不是很複雜,這裏我貼出一些核心部分代碼和我閱讀後的註釋,後面會講一下 lodash 的大概流程:
function debounce(func, wait, options) { let lastArgs, lastThis, maxWait, result, timerId, lastCallTime // 參數初始化 let lastInvokeTime = 0 // func 上一次執行的時間 let leading = false let maxing = false let trailing = true // 基本的類型判斷和處理 if (typeof func != 'function') { throw new TypeError('Expected a function') } wait = +wait || 0 if (isObject(options)) { // 對配置的一些初始化 } function invokeFunc(time) { const args = lastArgs const thisArg = lastThis lastArgs = lastThis = undefined lastInvokeTime = time result = func.apply(thisArg, args) return result } function leadingEdge(time) { // Reset any `maxWait` timer. lastInvokeTime = time // 爲 trailing edge 觸發函數調用設定定時器 timerId = setTimeout(timerExpired, wait) // leading = true 執行函數 return leading ? invokeFunc(time) : result } function remainingWait(time) { const timeSinceLastCall = time - lastCallTime // 距離上次debounced函數被調用的時間 const timeSinceLastInvoke = time - lastInvokeTime // 距離上次函數被執行的時間 const timeWaiting = wait - timeSinceLastCall // 用 wait 減去 timeSinceLastCall 計算出下一次trailing的位置 // 兩種狀況 // 有maxing:比較出下一次maxing和下一次trailing的最小值,做爲下一次函數要執行的時間 // 無maxing:在下一次trailing時執行 timerExpired return maxing ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke) : timeWaiting } // 根據時間判斷 func 可否被執行 function shouldInvoke(time) { const timeSinceLastCall = time - lastCallTime const timeSinceLastInvoke = time - lastInvokeTime // 幾種知足條件的狀況 return (lastCallTime === undefined //首次 || (timeSinceLastCall >= wait) // 距離上次被調用已經超過 wait || (timeSinceLastCall < 0) //系統時間倒退 || (maxing && timeSinceLastInvoke >= maxWait)) //超過最大等待時間 } function timerExpired() { const time = Date.now() // 在 trailing edge 且時間符合條件時,調用 trailingEdge函數,不然重啓定時器 if (shouldInvoke(time)) { return trailingEdge(time) } // 重啓定時器,保證下一次時延的末尾觸發 timerId = setTimeout(timerExpired, remainingWait(time)) } function trailingEdge(time) { timerId = undefined // 有lastArgs才執行,意味着只有 func 已經被 debounced 過一次之後纔會在 trailing edge 執行 if (trailing && lastArgs) { return invokeFunc(time) } // 每次 trailingEdge 都會清除 lastArgs 和 lastThis,目的是避免最後一次函數被執行了兩次 // 舉個例子:最後一次函數執行的時候,可能恰巧是前一次的 trailing edge,函數被調用,而這個函數又須要在本身時延的 trailing edge 觸發,致使觸發屢次 lastArgs = lastThis = undefined return result } function cancel() {} function flush() {} function pending() {} function debounced(...args) { const time = Date.now() const isInvoking = shouldInvoke(time) //是否知足時間條件 lastArgs = args lastThis = this lastCallTime = time //函數被調用的時間 if (isInvoking) { if (timerId === undefined) { // 無timerId的狀況有兩種:1.首次調用 2.trailingEdge執行過函數 return leadingEdge(lastCallTime) } if (maxing) { // Handle invocations in a tight loop. timerId = setTimeout(timerExpired, wait) return invokeFunc(lastCallTime) } } // 負責一種case:trailing 爲 true 的狀況下,在前一個 wait 的 trailingEdge 已經執行了函數; // 而此次函數被調用時 shouldInvoke 不知足條件,所以要設置定時器,在本次的 trailingEdge 保證函數被執行 if (timerId === undefined) { timerId = setTimeout(timerExpired, wait) } return result } debounced.cancel = cancel debounced.flush = flush debounced.pending = pending return debounced }
這裏我用文字來簡單描述一下流程:
首次進入函數時由於 lastCallTime === undefined 而且 timerId === undefined,因此會執行 leadingEdge,若是此時 leading 爲 true 的話,就會執行 func。同時,這裏會設置一個定時器,在等待 wait(s) 後會執行 timerExpired,timerExpired 的主要做用就是觸發 trailing。
若是在還未到 wait 的時候就再次調用了函數的話,會更新 lastCallTime,而且由於此時 isInvoking 不知足條件,因此此次什麼也不會執行。
時間到達 wait 時,就會執行咱們一開始設定的定時器timerExpired,此時由於time-lastCallTime < wait,因此不會執行 trailingEdge。
這時又會新增一個定時器,下一次執行的時間是 remainingWait,這裏會根據是否有 maxwait 來做區分:
若是沒有 maxwait,定時器的時間是 wait - timeSinceLastCall,保證下一次 trailing 的執行。
若是有 maxing,會比較出下一次 maxing 和下一次 trailing 的最小值,做爲下一次函數要執行的時間。
最後,若是再也不有函數調用,就會在定時器結束時執行 trailingEdge。
那麼,回到上面的兩個問題,個人代碼到底是哪裏出了問題呢?
研究了一下,lodash是比較穩定的在trailing時觸發前一次函數調用的,而個人則是每次在 maxWait 時觸發的下一次調用。問題就出在對於定時器的控制上。
由於在編碼時考慮到定時器和 maxwait 會衝突的問題,在函數每次被調用的時候都會 clearTimeout(timer)
,所以個人 trailing
判斷其實只對整個執行流的最後一次有效,而非 lodash 所說的 trailing
控制的是函數在每一個 wait
的最後執行。
而 lodash 並不會清除定時器,只是每次生成新的定時器的時候都會根據 lastCallTime 來計算下一次該執行的時間,不只保證了定時器的準確性,也保證了對每次 trailing
的控制。
經過打 log 我發現這種觸發兩次的狀況很是湊巧,最後一次函數執行的時候,正好知足前一個時延的 trailing,而後本身這個 wait 的定時器也觸發了,因此最後又觸發了一次本次時延的 trailing,因此觸發了兩次。
理論上 lodash 也會出現這種狀況,可是它在每次函數執行的時候都會刪除 lastArgs 和 lastThis,而下次函數執行的時候都會判斷這兩個參數是否存在,所以避免了這種狀況。
其實以前就知道 debounce
和 throttle
的用途和含義,可是每次用起來都得去看一眼文檔,經過此次本身實現以及對源碼的閱讀,終於作到了了熟於心,也發現本身的代碼設計能力仍是有缺陷,一開始並無想的很到位。
寫代碼的,仍是要多寫,多看;慢慢作到會寫,會看;與你們共勉。