摘要:同時,這里會設置一個定時器,在等待后會執(zhí)行,的主要作用就是觸發(fā)。最后,如果不再有函數(shù)調(diào)用,就會在定時器結束時執(zhí)行。問題就出在對于定時器的控制上。
本文同步自我的Blog
前段時間團隊內(nèi)部搞了一個代碼訓練營,大家組織在一起實現(xiàn) lodash 的 throttle 和 debounce,實現(xiàn)起來覺得并不麻煩,但是最后和官方的一對比,發(fā)現(xiàn)功能的實現(xiàn)上還是有差距的,為了尋找我的問題,把官方源碼閱讀了一遍,本文是我閱讀完成后的一篇總結。
本文只會列出比較核心部分的代碼和注釋,如果對全部的源碼有興趣的歡迎直接看我的repo:
什么是throttle和debouncethrottle(又稱節(jié)流)和debounce(又稱防抖)其實都是函數(shù)調(diào)用頻率的控制器,這里只做簡單的介紹,如果想了解更多關于這兩個定義的細節(jié)可以看下后文給出的一張圖片,或者閱讀一下lodash的文檔。
throttle:將一個函數(shù)的調(diào)用頻率限制在一定閾值內(nèi),例如 1s 內(nèi)一個函數(shù)不能被調(diào)用兩次。
debounce:當調(diào)用函數(shù)n秒后,才會執(zhí)行該動作,若在這n秒內(nèi)又調(diào)用該函數(shù)則將取消前一次并重新計算執(zhí)行時間,舉個簡單的例子,我們要根據(jù)用戶輸入做suggest,每當用戶按下鍵盤的時候都可以取消前一次,并且只關心最后一次輸入的時間就行了。
lodash 對這兩個函數(shù)又增加了一些參數(shù),主要是以下三個:
leading,函數(shù)在每個等待時延的開始被調(diào)用
trailing,函數(shù)在每個等待時延的結束被調(diào)用
maxwait(debounce才有的配置),最大的等待時間,因為如果 debounce 的函數(shù)調(diào)用時間不滿足條件,可能永遠都無法觸發(fā),因此增加了這個配置,保證大于一段時間后一定能執(zhí)行一次函數(shù)
我的實現(xiàn)與lodash的區(qū)別這里直接劇透一下,其實 throttle 就是設置了 maxwait 的 debounce,所以我這里也只會介紹 debounce 的代碼,聰明的讀者們可以自己思考一下為什么。
我自己的代碼實現(xiàn)放在我的repo里,大家有興趣的可以看下。之前說過我的實現(xiàn)和 lodash 有些區(qū)別,下面就用兩張圖來展示一下。
這是我的實現(xiàn)
這是lodash的實現(xiàn)
這里看到,我的代碼主要有兩個問題:
throttle 的最后一次函數(shù)會執(zhí)行兩次,而且并非穩(wěn)定復現(xiàn)。
throttle 里函數(shù)執(zhí)行的順序不對,雖然我的功能實現(xiàn)了,但是對于每一次 wait 來說,我都是執(zhí)行的 leading 那一次
lodash 的實現(xiàn)解讀下面,我就會帶著這幾個問題去看看 lodasah 的代碼。
官方代碼的實現(xiàn)也不是很復雜,這里我貼出一些核心部分代碼和我閱讀后的注釋,后面會講一下 lodash 的大概流程:
function debounce(func, wait, options) { let lastArgs, lastThis, maxWait, result, timerId, lastCallTime // 參數(shù)初始化 let lastInvokeTime = 0 // func 上一次執(zhí)行的時間 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 觸發(fā)函數(shù)調(diào)用設定定時器 timerId = setTimeout(timerExpired, wait) // leading = true 執(zhí)行函數(shù) return leading ? invokeFunc(time) : result } function remainingWait(time) { const timeSinceLastCall = time - lastCallTime // 距離上次debounced函數(shù)被調(diào)用的時間 const timeSinceLastInvoke = time - lastInvokeTime // 距離上次函數(shù)被執(zhí)行的時間 const timeWaiting = wait - timeSinceLastCall // 用 wait 減去 timeSinceLastCall 計算出下一次trailing的位置 // 兩種情況 // 有maxing:比較出下一次maxing和下一次trailing的最小值,作為下一次函數(shù)要執(zhí)行的時間 // 無maxing:在下一次trailing時執(zhí)行 timerExpired return maxing ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke) : timeWaiting } // 根據(jù)時間判斷 func 能否被執(zhí)行 function shouldInvoke(time) { const timeSinceLastCall = time - lastCallTime const timeSinceLastInvoke = time - lastInvokeTime // 幾種滿足條件的情況 return (lastCallTime === undefined //首次 || (timeSinceLastCall >= wait) // 距離上次被調(diào)用已經(jīng)超過 wait || (timeSinceLastCall < 0) //系統(tǒng)時間倒退 || (maxing && timeSinceLastInvoke >= maxWait)) //超過最大等待時間 } function timerExpired() { const time = Date.now() // 在 trailing edge 且時間符合條件時,調(diào)用 trailingEdge函數(shù),否則重啟定時器 if (shouldInvoke(time)) { return trailingEdge(time) } // 重啟定時器,保證下一次時延的末尾觸發(fā) timerId = setTimeout(timerExpired, remainingWait(time)) } function trailingEdge(time) { timerId = undefined // 有l(wèi)astArgs才執(zhí)行,意味著只有 func 已經(jīng)被 debounced 過一次以后才會在 trailing edge 執(zhí)行 if (trailing && lastArgs) { return invokeFunc(time) } // 每次 trailingEdge 都會清除 lastArgs 和 lastThis,目的是避免最后一次函數(shù)被執(zhí)行了兩次 // 舉個例子:最后一次函數(shù)執(zhí)行的時候,可能恰巧是前一次的 trailing edge,函數(shù)被調(diào)用,而這個函數(shù)又需要在自己時延的 trailing edge 觸發(fā),導致觸發(fā)多次 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 //函數(shù)被調(diào)用的時間 if (isInvoking) { if (timerId === undefined) { // 無timerId的情況有兩種:1.首次調(diào)用 2.trailingEdge執(zhí)行過函數(shù) return leadingEdge(lastCallTime) } if (maxing) { // Handle invocations in a tight loop. timerId = setTimeout(timerExpired, wait) return invokeFunc(lastCallTime) } } // 負責一種case:trailing 為 true 的情況下,在前一個 wait 的 trailingEdge 已經(jīng)執(zhí)行了函數(shù); // 而這次函數(shù)被調(diào)用時 shouldInvoke 不滿足條件,因此要設置定時器,在本次的 trailingEdge 保證函數(shù)被執(zhí)行 if (timerId === undefined) { timerId = setTimeout(timerExpired, wait) } return result } debounced.cancel = cancel debounced.flush = flush debounced.pending = pending return debounced }
這里我用文字來簡單描述一下流程:
首次進入函數(shù)時因為 lastCallTime === undefined 并且 timerId === undefined,所以會執(zhí)行 leadingEdge,如果此時 leading 為 true 的話,就會執(zhí)行 func。同時,這里會設置一個定時器,在等待 wait(s) 后會執(zhí)行 timerExpired,timerExpired 的主要作用就是觸發(fā) trailing。
如果在還未到 wait 的時候就再次調(diào)用了函數(shù)的話,會更新 lastCallTime,并且因為此時 isInvoking 不滿足條件,所以這次什么也不會執(zhí)行。
時間到達 wait 時,就會執(zhí)行我們一開始設定的定時器timerExpired,此時因為time-lastCallTime < wait,所以不會執(zhí)行 trailingEdge。
這時又會新增一個定時器,下一次執(zhí)行的時間是 remainingWait,這里會根據(jù)是否有 maxwait 來作區(qū)分:
如果沒有 maxwait,定時器的時間是 wait - timeSinceLastCall,保證下一次 trailing 的執(zhí)行。
如果有 maxing,會比較出下一次 maxing 和下一次 trailing 的最小值,作為下一次函數(shù)要執(zhí)行的時間。
最后,如果不再有函數(shù)調(diào)用,就會在定時器結束時執(zhí)行 trailingEdge。
我的問題出在哪?那么,回到上面的兩個問題,我的代碼究竟是哪里出了問題呢?
為什么順序圖不對研究了一下,lodash是比較穩(wěn)定的在trailing時觸發(fā)前一次函數(shù)調(diào)用的,而我的則是每次在 maxWait 時觸發(fā)的下一次調(diào)用。問題就出在對于定時器的控制上。
因為在編碼時考慮到定時器和 maxwait 會沖突的問題,在函數(shù)每次被調(diào)用的時候都會 clearTimeout(timer),因此我的 trailing 判斷其實只對整個執(zhí)行流的最后一次有效,而非 lodash 所說的 trailing 控制的是函數(shù)在每個 wait 的最后執(zhí)行。
而 lodash 并不會清除定時器,只是每次生成新的定時器的時候都會根據(jù) lastCallTime 來計算下一次該執(zhí)行的時間,不僅保證了定時器的準確性,也保證了對每次 trailing 的控制。
為什么最后會觸發(fā)兩次通過打 log 我發(fā)現(xiàn)這種觸發(fā)兩次的情況非常湊巧,最后一次函數(shù)執(zhí)行的時候,正好滿足前一個時延的 trailing,然后自己這個 wait 的定時器也觸發(fā)了,所以最后又觸發(fā)了一次本次時延的 trailing,所以觸發(fā)了兩次。
理論上 lodash 也會出現(xiàn)這種情況,但是它在每次函數(shù)執(zhí)行的時候都會刪除 lastArgs 和 lastThis,而下次函數(shù)執(zhí)行的時候都會判斷這兩個參數(shù)是否存在,因此避免了這種情況。
總結其實之前就知道 debounce 和 throttle 的用途和含義,但是每次用起來都得去看一眼文檔,通過這次自己實現(xiàn)以及對源碼的閱讀,終于做到了了熟于心,也發(fā)現(xiàn)自己的代碼設計能力還是有缺陷,一開始并沒有想的很到位。
寫代碼的,還是要多寫,多看;慢慢做到會寫,會看;與大家共勉。
文章版權歸作者所有,未經(jīng)允許請勿轉載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉載請注明本文地址:http://systransis.cn/yun/89922.html
摘要:最簡單的案例以最簡單的情景為例在某一時刻點只調(diào)用一次函數(shù),那么將在時間后才會真正觸發(fā)函數(shù)。后續(xù)我們會逐漸增加黑色鬧鐘出現(xiàn)的復雜度,不斷去分析紅色鬧鐘的位置。 序 相比網(wǎng)上教程中的 debounce 函數(shù),lodash 中的 debounce 功能更為強大,相應的理解起來更為復雜; 解讀源碼一般都是直接拿官方源碼來解讀,不過這次我們采用另外的方式:從最簡單的場景開始寫代碼,然后慢慢往源碼...
摘要:防抖函數(shù)防抖和節(jié)流是一對常常被放在一起的場景。同時,這里會設置一個定時器,在等待后會執(zhí)行,的主要作用就是觸發(fā)。最后,如果不再有函數(shù)調(diào)用,就會在定時器結束時執(zhí)行。 函數(shù)節(jié)流和去抖的出現(xiàn)場景,一般都伴隨著客戶端 DOM 的事件監(jiān)聽。比如scroll resize等事件,這些事件在某些場景觸發(fā)非常頻繁。 比如,實現(xiàn)一個原生的拖拽功能(不能用 H5 Drag&Drop API),需要一路監(jiān)聽...
摘要:當函數(shù)被再次觸發(fā)時,清除已設置的定時器,重新設置定時器。函數(shù)設置定時器,并根據(jù)傳參配置決定是否在等待開始時執(zhí)行函數(shù)。函數(shù)取消定時器,并重置內(nèi)部參數(shù)。 throttle函數(shù)與debounce函數(shù) 有時候,我們會對一些觸發(fā)頻率較高的事件進行監(jiān)聽,如果在回調(diào)里執(zhí)行高性能消耗的操作,反復觸發(fā)時會使得性能消耗提高,瀏覽器卡頓,用戶使用體驗差?;蛘呶覀冃枰獙τ|發(fā)的事件延遲執(zhí)行回調(diào),此時可以借助th...
摘要:舉例舉例通過拖拽瀏覽器窗口,可以觸發(fā)很多次事件。不支持,所以不能在服務端用于文件系統(tǒng)事件。總結將一系列迅速觸發(fā)的事件例如敲擊鍵盤合并成一個單獨的事件。確保一個持續(xù)的操作流以每毫秒執(zhí)行一次的速度執(zhí)行。 Debounce 和 Throttle 是兩個很相似但是又不同的技術,都可以控制一個函數(shù)在一段時間內(nèi)執(zhí)行的次數(shù)。 當我們在操作 DOM 事件的時候,為函數(shù)添加 debounce 或者 th...
摘要:背景需要包寫起來爽,然而如果遇到?jīng)]有現(xiàn)成的化的工具函數(shù),就需要自己想辦法弄出一份類型聲明文件了。最為重要的是,這種遷移方面我們可以隨意自定義化中所需要的工具函數(shù),遷移粒度都可以由自己控制。 1、背景 1.1、需要 TS 包 TypeScript 寫起來爽,然而如果遇到?jīng)]有現(xiàn)成的 TS 化的工具函數(shù),就需要自己想辦法弄出一份類型聲明文件了。 前兩天要寫的小工具庫(Typescript 語...
閱讀 3257·2021-11-24 09:39
閱讀 2943·2021-09-09 11:34
閱讀 3208·2021-09-07 09:58
閱讀 2311·2019-08-30 13:07
閱讀 2876·2019-08-29 15:09
閱讀 1574·2019-08-29 13:01
閱讀 2318·2019-08-26 12:18
閱讀 1945·2019-08-26 10:28