摘要:同時,這里會設置一個定時器,在等待后會執行,的主要作用就是觸發。最后,如果不再有函數調用,就會在定時器結束時執行。問題就出在對于定時器的控制上。
本文同步自我的Blog
前段時間團隊內部搞了一個代碼訓練營,大家組織在一起實現 lodash 的 throttle 和 debounce,實現起來覺得并不麻煩,但是最后和官方的一對比,發現功能的實現上還是有差距的,為了尋找我的問題,把官方源碼閱讀了一遍,本文是我閱讀完成后的一篇總結。
本文只會列出比較核心部分的代碼和注釋,如果對全部的源碼有興趣的歡迎直接看我的repo:
什么是throttle和debouncethrottle(又稱節流)和debounce(又稱防抖)其實都是函數調用頻率的控制器,這里只做簡單的介紹,如果想了解更多關于這兩個定義的細節可以看下后文給出的一張圖片,或者閱讀一下lodash的文檔。
throttle:將一個函數的調用頻率限制在一定閾值內,例如 1s 內一個函數不能被調用兩次。
debounce:當調用函數n秒后,才會執行該動作,若在這n秒內又調用該函數則將取消前一次并重新計算執行時間,舉個簡單的例子,我們要根據用戶輸入做suggest,每當用戶按下鍵盤的時候都可以取消前一次,并且只關心最后一次輸入的時間就行了。
lodash 對這兩個函數又增加了一些參數,主要是以下三個:
leading,函數在每個等待時延的開始被調用
trailing,函數在每個等待時延的結束被調用
maxwait(debounce才有的配置),最大的等待時間,因為如果 debounce 的函數調用時間不滿足條件,可能永遠都無法觸發,因此增加了這個配置,保證大于一段時間后一定能執行一次函數
我的實現與lodash的區別這里直接劇透一下,其實 throttle 就是設置了 maxwait 的 debounce,所以我這里也只會介紹 debounce 的代碼,聰明的讀者們可以自己思考一下為什么。
我自己的代碼實現放在我的repo里,大家有興趣的可以看下。之前說過我的實現和 lodash 有些區別,下面就用兩張圖來展示一下。
這是我的實現
這是lodash的實現
這里看到,我的代碼主要有兩個問題:
throttle 的最后一次函數會執行兩次,而且并非穩定復現。
throttle 里函數執行的順序不對,雖然我的功能實現了,但是對于每一次 wait 來說,我都是執行的 leading 那一次
lodash 的實現解讀下面,我就會帶著這幾個問題去看看 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 的用途和含義,但是每次用起來都得去看一眼文檔,通過這次自己實現以及對源碼的閱讀,終于做到了了熟于心,也發現自己的代碼設計能力還是有缺陷,一開始并沒有想的很到位。
寫代碼的,還是要多寫,多看;慢慢做到會寫,會看;與大家共勉。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/89922.html
摘要:最簡單的案例以最簡單的情景為例在某一時刻點只調用一次函數,那么將在時間后才會真正觸發函數。后續我們會逐漸增加黑色鬧鐘出現的復雜度,不斷去分析紅色鬧鐘的位置。 序 相比網上教程中的 debounce 函數,lodash 中的 debounce 功能更為強大,相應的理解起來更為復雜; 解讀源碼一般都是直接拿官方源碼來解讀,不過這次我們采用另外的方式:從最簡單的場景開始寫代碼,然后慢慢往源碼...
摘要:防抖函數防抖和節流是一對常常被放在一起的場景。同時,這里會設置一個定時器,在等待后會執行,的主要作用就是觸發。最后,如果不再有函數調用,就會在定時器結束時執行。 函數節流和去抖的出現場景,一般都伴隨著客戶端 DOM 的事件監聽。比如scroll resize等事件,這些事件在某些場景觸發非常頻繁。 比如,實現一個原生的拖拽功能(不能用 H5 Drag&Drop API),需要一路監聽...
摘要:當函數被再次觸發時,清除已設置的定時器,重新設置定時器。函數設置定時器,并根據傳參配置決定是否在等待開始時執行函數。函數取消定時器,并重置內部參數。 throttle函數與debounce函數 有時候,我們會對一些觸發頻率較高的事件進行監聽,如果在回調里執行高性能消耗的操作,反復觸發時會使得性能消耗提高,瀏覽器卡頓,用戶使用體驗差?;蛘呶覀冃枰獙τ|發的事件延遲執行回調,此時可以借助th...
摘要:舉例舉例通過拖拽瀏覽器窗口,可以觸發很多次事件。不支持,所以不能在服務端用于文件系統事件??偨Y將一系列迅速觸發的事件例如敲擊鍵盤合并成一個單獨的事件。確保一個持續的操作流以每毫秒執行一次的速度執行。 Debounce 和 Throttle 是兩個很相似但是又不同的技術,都可以控制一個函數在一段時間內執行的次數。 當我們在操作 DOM 事件的時候,為函數添加 debounce 或者 th...
摘要:背景需要包寫起來爽,然而如果遇到沒有現成的化的工具函數,就需要自己想辦法弄出一份類型聲明文件了。最為重要的是,這種遷移方面我們可以隨意自定義化中所需要的工具函數,遷移粒度都可以由自己控制。 1、背景 1.1、需要 TS 包 TypeScript 寫起來爽,然而如果遇到沒有現成的 TS 化的工具函數,就需要自己想辦法弄出一份類型聲明文件了。 前兩天要寫的小工具庫(Typescript 語...
閱讀 3236·2021-11-24 09:39
閱讀 2912·2021-09-09 11:34
閱讀 3190·2021-09-07 09:58
閱讀 2299·2019-08-30 13:07
閱讀 2859·2019-08-29 15:09
閱讀 1560·2019-08-29 13:01
閱讀 2300·2019-08-26 12:18
閱讀 1911·2019-08-26 10:28