探究防抖(debounce)和节流(throttle)
本文來自我的博客,歡迎大家去GitHub上star我的博客
本文從防抖和節(jié)流出發(fā),分析它們的特性,并拓展一種特殊的節(jié)流方式requestAnimationFrame,最后對lodash中的debounce源碼進行分析
防抖和節(jié)流是前端開發(fā)中經(jīng)常使用的一種優(yōu)化手段,它們都被用來控制一段時間內(nèi)方法執(zhí)行的次數(shù),可以為我們節(jié)省大量不必要的開銷
防抖(debounce)
當(dāng)我們需要及時獲知窗口大小變化時,我們會給window綁定一個resize函數(shù),像下面這樣:
window.addEventListener('resize', () => {console.log('resize') });我們會發(fā)現(xiàn),即使是極小的縮放操作,也會打印數(shù)十次resize,也就是說,如果我們需要在onresize函數(shù)中搞一些小動作,也會重復(fù)執(zhí)行幾十次。但實際上,我們只關(guān)心鼠標(biāo)松開,窗口停止變化的那一次resize,這時候,就可以使用debounce優(yōu)化這個過程:
const handleResize = debounce(() => {console.log('resize'); }, 500); window.addEventListener('resize', handleResize);運行上面的代碼(你得有現(xiàn)成的debounce函數(shù)),在停止縮放操作500ms后,默認(rèn)用戶無繼續(xù)操作了,才會打印resize
這就是防抖的功效,它把一組連續(xù)的調(diào)用變?yōu)榱艘粋€,最大程度地優(yōu)化了效率
再舉一個防抖的常見場景:
搜索欄常常會根據(jù)我們的輸入,向后端請求,獲取搜索候選項,顯示在搜索欄下方。如果我們不使用防抖,在輸入“debounce”時前端會依次向后端請求"d"、"de"、"deb"..."debounce"的搜索候選項,在用戶輸入很快的情況下,這些請求是無意義的,可以使用防抖優(yōu)化
觀察上面這兩個例子,我們發(fā)現(xiàn),防抖非常適于只關(guān)心結(jié)果,不關(guān)心過程如何的情況,它能很好地將大量連續(xù)事件轉(zhuǎn)為單個我們需要的事件
為了更好理解,下面提供了最簡單的debounce實現(xiàn):返回一個function,第一次執(zhí)行這個function會啟動一個定時器,下一次執(zhí)行會清除上一次的定時器并重起一個定時器,直到這個function不再被調(diào)用,定時器成功跑完,執(zhí)行回調(diào)函數(shù)
const debounce = function(func, wait) {let timer;return function() {!!timer && clearTimeout(timer);timer = setTimeout(func, wait);}; };那如果我們不僅關(guān)心結(jié)果,同時也關(guān)心過程呢?
節(jié)流(throttle)
節(jié)流讓指定函數(shù)在規(guī)定的時間里執(zhí)行次數(shù)不會超過一次,也就是說,在連續(xù)高頻執(zhí)行中,動作會被定期執(zhí)行。節(jié)流的主要目的是將原本操作的頻率降低
實例:
我們模擬一個可無限滾動的feed流
html:
<div id="wrapper"><div class="feed"></div><div class="feed"></div><div class="feed"></div><div class="feed"></div><div class="feed"></div> </div>css:
#wrapper {height: 500px;overflow: auto; } .feed {height: 200px;background: #ededed;margin: 20px; }js:
const wrapper = document.getElementById("wrapper"); const loadContent = () => {const {scrollHeight,clientHeight,scrollTop} = wrapper;const heightFromBottom = scrollHeight - scrollTop - clientHeight;if (heightFromBottom < 200) {const wrapperCopy = wrapper.cloneNode(true);const children = [].slice.call(wrapperCopy.children);children.forEach(item => {wrapper.appendChild(item);})} } const handleScroll = throttle(loadContent, 200); wrapper.addEventListener("scroll", handleScroll);可以看到,在這個例子中,我們需要不停地獲取滾動條距離底部的高度,以判斷是否需要增加新的內(nèi)容。我們知道,srcoll同樣也是種會高頻觸發(fā)的事件,我們需要減少它有效觸發(fā)的次數(shù)。如果使用的是防抖,那么得等我們停止?jié)L動之后一段時間才會加載新的內(nèi)容,沒有那種無限滾動的流暢感。這時候,我們就可以使用節(jié)流,將事件有效觸發(fā)的頻率降低的同時給用戶流暢的瀏覽體驗。在這個例子中,我們指定throttle的wait值為200ms,也就是說,如果你一直在滾動頁面,loadCotent函數(shù)也只會每200ms執(zhí)行一次
同樣,這里有throttle最簡單的實現(xiàn),當(dāng)然,這種實現(xiàn)很粗糙,有不少缺陷(比如沒有考慮最后一次執(zhí)行),只供初步理解使用:
const throttle = function (func, wait) {let lastTime;return function () {const curTime = Date.now();if (!lastTime || curTime - lastTime >= wait) {lastTime = curTime;return func();}} }requestAnimationFrame(rAF)
rAF在一定程度上和throttle(func,16)的作用相似,但它是瀏覽器自帶的api,所以,它比throttle函數(shù)執(zhí)行得更加平滑。調(diào)用window.requestAnimationFrame(),瀏覽器會在下次刷新的時候執(zhí)行指定回調(diào)函數(shù)。通常,屏幕的刷新頻率是60hz,所以,這個函數(shù)也就是大約16.7ms執(zhí)行一次。如果你想讓你的動畫更加平滑,用rAF就再好不過了,因為它是跟著屏幕的刷新頻率來的
rAF的寫法與debounce和throttle不同,如果你想用它繪制動畫,需要不停地在回調(diào)函數(shù)里調(diào)用自身,具體寫法可以參考mdn
rAF支持ie10及以上瀏覽器,不過因為是瀏覽器自帶的api,我們也就無法在node中使用它了
總結(jié)
debounce將一組事件的執(zhí)行轉(zhuǎn)為最后一個事件的執(zhí)行,如果你只關(guān)注結(jié)果,debounce再適合不過
如果你同時關(guān)注過程,可以使用throttle,它可以用來降低高頻事件的執(zhí)行頻率
如果你的代碼是在瀏覽器上運行,不考慮兼容ie10,并且要求頁面上的變化盡可能的平滑,可以使用rAF
參考:https://css-tricks.com/debouncing-throttling-explained-examples/
附:lodash源碼解析
lodash的debounce功能十分強大,集debounce、throttle和rAF于一身,所以我特意研讀一下,下面是我的解析(我刪去了一些不重要的代碼,比如debounced的cancel方法):
function debounce(func, wait, options) {/*** lastCallTime是上一次執(zhí)行debounced函數(shù)的時間* lastInvokeTime是上一次調(diào)用func的時間*/let lastArgs, lastThis, maxWait, result, timerId, lastCallTime;let lastInvokeTime = 0;let leading = false;let maxing = false;let trailing = true;/*** 如果沒設(shè)置wait且raf可用 則默認(rèn)使用raf*/const useRAF =!wait && wait !== 0 && typeof root.requestAnimationFrame === "function";if (typeof func !== "function") {throw new TypeError("Expected a function");}wait = +wait || 0;if (isObject(options)) {leading = !!options.leading;maxing = "maxWait" in options;maxWait = maxing ? Math.max(+options.maxWait || 0, wait) : maxWait;trailing = "trailing" in options ? !!options.trailing : trailing;}/*** 執(zhí)行func*/function invokeFunc(time) {const args = lastArgs;const thisArg = lastThis;lastArgs = lastThis = undefined;/*** 更新lastInvokeTime*/lastInvokeTime = time;result = func.apply(thisArg, args);return result;}/*** 調(diào)用定時器*/function startTimer(pendingFunc, wait) {if (useRAF) {root.cancelAnimationFrame(timerId);return root.requestAnimationFrame(pendingFunc);}return setTimeout(pendingFunc, wait);}/*** 在每輪debounce開始調(diào)用*/function leadingEdge(time) {lastInvokeTime = time;timerId = startTimer(timerExpired, wait);return leading ? invokeFunc(time) : result;}/*** 計算剩余時間* 1是 wait 減去 距離上次調(diào)用debounced時間(lastCallTime)* 2是 maxWait 減去 距離上次調(diào)用func時間(lastInvokeTime)* 1和2取最小值*/function remainingWait(time) {const timeSinceLastCall = time - lastCallTime;const timeSinceLastInvoke = time - lastInvokeTime;const timeWaiting = wait - timeSinceLastCall;return maxing? Math.min(timeWaiting, maxWait - timeSinceLastInvoke): timeWaiting;}/*** 判斷是否需要執(zhí)行*/function shouldInvoke(time) {const timeSinceLastCall = time - lastCallTime;const timeSinceLastInvoke = time - lastInvokeTime;/*** 4種情況返回true,否則返回false* 1.第一次調(diào)用* 2.距離上次調(diào)用debounced時間(lastCallTime)>=wait* 3.系統(tǒng)時間倒退* 4.設(shè)置了maxWait,距離上次調(diào)用func時間(lastInvokeTime)>=maxWait*/return (lastCallTime === undefined ||timeSinceLastCall >= wait ||timeSinceLastCall < 0 ||(maxing && timeSinceLastInvoke >= maxWait));}/*** 通過shouldInvoke函數(shù)判斷是否執(zhí)行* 執(zhí)行:調(diào)用trailingEdge函數(shù)* 不執(zhí)行:調(diào)用startTimer函數(shù)重新開始timer,wait值通過remainingWait函數(shù)計算*/function timerExpired() {const time = Date.now();if (shouldInvoke(time)) {return trailingEdge(time);}// Restart the timer.timerId = startTimer(timerExpired, remainingWait(time));}/*** 在每輪debounce結(jié)束調(diào)用*/function trailingEdge(time) {timerId = undefined;/*** trailing為true且lastArgs不為undefined時調(diào)用*/if (trailing && lastArgs) {return invokeFunc(time);}lastArgs = lastThis = undefined;return result;}function debounced(...args) {const time = Date.now();const isInvoking = shouldInvoke(time);lastArgs = args;lastThis = this;/*** 更新lastCallTime*/lastCallTime = time;if (isInvoking) {/*** 第一次調(diào)用*/if (timerId === undefined) {return leadingEdge(lastCallTime);}/*** 【注1】*/if (maxing) {timerId = startTimer(timerExpired, wait);return invokeFunc(lastCallTime);}}/*** 【注2】*/if (timerId === undefined) {timerId = startTimer(timerExpired, wait);}return result;}return debounced; }推薦是從返回的方法debounced開始,順著執(zhí)行順序閱讀,理解起來更輕松
【注1】一開始我沒看明白if(maxing)里面這段代碼的作用,按理說,是不會執(zhí)行這段代碼的,后來我去lodash的倉庫里看了test文件,發(fā)現(xiàn)對這段代碼,專門有一個case對其測試。我剝除了一些代碼,并修改了測試用例以便展示,如下:
var limit = 320,withCount = 0var withMaxWait = debounce(function () {console.log('invoke');withCount++; }, 64, {'maxWait': 128 });var start = +new Date; while ((new Date - start) < limit) {withMaxWait(); }執(zhí)行代碼,打印了3次invoke;我又將if(maxing){}這段代碼注釋,再執(zhí)行代碼,結(jié)果只打印了1次。結(jié)合源碼的英文注釋Handle invocations in a tight loop,我們不難理解,原本理想的執(zhí)行順序是withMaxWait->timer->withMaxWait->timer這種交替進行,但由于setTimeout需等待主線程的代碼執(zhí)行完畢,所以這種短時間快速調(diào)用就會導(dǎo)致withMaxWait->withMaxWait->timer->timer,從第二個timer開始,由于lastArgs被置為undefined,也就不會再調(diào)用invokeFunc函數(shù),所以只會打印一次invoke。
同時,由于每次執(zhí)行invokeFunc時都會將lastArgs置為undefined,在執(zhí)行trailingEdge時會對lastArgs進行判斷,確保不會出現(xiàn)執(zhí)行了if(maxing){}中的invokeFunc函數(shù)又執(zhí)行了timer的invokeFunc函數(shù)
這段代碼保證了設(shè)置maxWait參數(shù)后的正確性和時效性
【注2】執(zhí)行過一次trailingEdge后,再執(zhí)行debounced函數(shù),可能會遇到shouldInvoke返回false的情況,需單獨處理
【注3】對于lodash的debounce來說,throttle是一種leading為true且maxWait等于wait的特殊debounce
總結(jié)
以上是生活随笔為你收集整理的探究防抖(debounce)和节流(throttle)的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 组件生命周期管理和通信方案
- 下一篇: HTTP代理ip的这些误区你知道吗?