探究防抖(debounce)和節流(throttle)

本文來自個人博客,歡迎你們去GitHub上star個人博客css

本文從防抖和節流出發,分析它們的特性,並拓展一種特殊的節流方式requestAnimationFrame,最後對lodash中的debounce源碼進行分析html

防抖和節流是前端開發中常用的一種優化手段,它們都被用來控制一段時間內方法執行的次數,能夠爲咱們節省大量沒必要要的開銷前端

防抖(debounce)

當咱們須要及時獲知窗口大小變化時,咱們會給window綁定一個resize函數,像下面這樣:node

window.addEventListener('resize', () => {
    console.log('resize')
});

咱們會發現,即便是極小的縮放操做,也會打印數十次resize,也就是說,若是咱們須要在onresize函數中搞一些小動做,也會重複執行幾十次。但實際上,咱們只關心鼠標鬆開,窗口中止變化的那一次resize,這時候,就可使用debounce優化這個過程:git

const handleResize = debounce(() => {
    console.log('resize');
}, 500);
window.addEventListener('resize', handleResize);

運行上面的代碼(你得有現成的debounce函數),在中止縮放操做500ms後,默認用戶無繼續操做了,纔會打印resizegithub

這就是防抖的功效,它把一組連續的調用變爲了一個,最大程度地優化了效率後端

再舉一個防抖的常見場景:api

搜索欄經常會根據咱們的輸入,向後端請求,獲取搜索候選項,顯示在搜索欄下方。若是咱們不使用防抖,在輸入「debounce」時前端會依次向後端請求"d"、"de"、"deb"..."debounce"的搜索候選項,在用戶輸入很快的狀況下,這些請求是無心義的,可使用防抖優化瀏覽器

觀察上面這兩個例子,咱們發現,防抖很是適於只關心結果,不關心過程如何的狀況,它能很好地將大量連續事件轉爲單個咱們須要的事件app

爲了更好理解,下面提供了最簡單的debounce實現:返回一個function,第一次執行這個function會啓動一個定時器,下一次執行會清除上一次的定時器並重起一個定時器,直到這個function再也不被調用,定時器成功跑完,執行回調函數

const debounce = function(func, wait) {
    let timer;
    return function() {
        !!timer && clearTimeout(timer);
        timer = setTimeout(func, wait);
    };
};

那若是咱們不只關心結果,同時也關心過程呢?

節流(throttle)

節流讓指定函數在規定的時間裏執行次數不會超過一次,也就是說,在連續高頻執行中,動做會被按期執行。節流的主要目的是將本來操做的頻率下降

實例:

咱們模擬一個可無限滾動的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);

能夠看到,在這個例子中,咱們須要不停地獲取滾動條距離底部的高度,以判斷是否須要增長新的內容。咱們知道,srcoll一樣也是種會高頻觸發的事件,咱們須要減小它有效觸發的次數。若是使用的是防抖,那麼得等咱們中止滾動以後一段時間纔會加載新的內容,沒有那種無限滾動的流暢感。這時候,咱們就可使用節流,將事件有效觸發的頻率下降的同時給用戶流暢的瀏覽體驗。在這個例子中,咱們指定throttle的wait值爲200ms,也就是說,若是你一直在滾動頁面,loadCotent函數也只會每200ms執行一次

一樣,這裏有throttle最簡單的實現,固然,這種實現很粗糙,有很多缺陷(好比沒有考慮最後一次執行),只供初步理解使用:

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函數執行得更加平滑。調用window.requestAnimationFrame(),瀏覽器會在下次刷新的時候執行指定回調函數。一般,屏幕的刷新頻率是60hz,因此,這個函數也就是大約16.7ms執行一次。若是你想讓你的動畫更加平滑,用rAF就再好不過了,由於它是跟着屏幕的刷新頻率來的

rAF的寫法與debounce和throttle不一樣,若是你想用它繪製動畫,須要不停地在回調函數裏調用自身,具體寫法能夠參考mdn

rAF支持ie10及以上瀏覽器,不過由於是瀏覽器自帶的api,咱們也就沒法在node中使用它了

總結

debounce將一組事件的執行轉爲最後一個事件的執行,若是你只關注結果,debounce再適合不過

若是你同時關注過程,可使用throttle,它能夠用來下降高頻事件的執行頻率

若是你的代碼是在瀏覽器上運行,不考慮兼容ie10,而且要求頁面上的變化儘量的平滑,可使用rAF

參考:https://css-tricks.com/debouncing-throttling-explained-examples/

附:lodash源碼解析

lodash的debounce功能十分強大,集debounce、throttle和rAF於一身,因此我特地研讀一下,下面是個人解析(我刪去了一些不重要的代碼,好比debounced的cancel方法):

function debounce(func, wait, options) {
    /**
     * lastCallTime是上一次執行debounced函數的時間
     * lastInvokeTime是上一次調用func的時間
     */
    let lastArgs, lastThis, maxWait, result, timerId, lastCallTime;

    let lastInvokeTime = 0;
    let leading = false;
    let maxing = false;
    let trailing = true;

    /**
     * 若是沒設置wait且raf可用 則默認使用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;
    }

    /**
     * 執行func
     */
    function invokeFunc(time) {
        const args = lastArgs;
        const thisArg = lastThis;

        lastArgs = lastThis = undefined;
        /**
         * 更新lastInvokeTime
         */
        lastInvokeTime = time;
        result = func.apply(thisArg, args);
        return result;
    }

    /**
     * 調用定時器
     */
    function startTimer(pendingFunc, wait) {
        if (useRAF) {
            root.cancelAnimationFrame(timerId);
            return root.requestAnimationFrame(pendingFunc);
        }
        return setTimeout(pendingFunc, wait);
    }

    /**
     * 在每輪debounce開始調用
     */
    function leadingEdge(time) {
        lastInvokeTime = time;
        timerId = startTimer(timerExpired, wait);
        return leading ? invokeFunc(time) : result;
    }

    /**
     * 計算剩餘時間
     * 1是 wait 減去 距離上次調用debounced時間(lastCallTime)
     * 2是 maxWait 減去 距離上次調用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;
    }

    /**
     * 判斷是否須要執行
     */
    function shouldInvoke(time) {
        const timeSinceLastCall = time - lastCallTime;
        const timeSinceLastInvoke = time - lastInvokeTime;
        /**
         * 4種狀況返回true,不然返回false
         * 1.第一次調用
         * 2.距離上次調用debounced時間(lastCallTime)>=wait
         * 3.系統時間倒退
         * 4.設置了maxWait,距離上次調用func時間(lastInvokeTime)>=maxWait
         */
        return (
            lastCallTime === undefined ||
            timeSinceLastCall >= wait ||
            timeSinceLastCall < 0 ||
            (maxing && timeSinceLastInvoke >= maxWait)
        );
    }

    /**
     * 經過shouldInvoke函數判斷是否執行
     * 執行:調用trailingEdge函數
     * 不執行:調用startTimer函數從新開始timer,wait值經過remainingWait函數計算
     */
    function timerExpired() {
        const time = Date.now();
        if (shouldInvoke(time)) {
            return trailingEdge(time);
        }
        // Restart the timer.
        timerId = startTimer(timerExpired, remainingWait(time));
    }

    /**
     * 在每輪debounce結束調用
     */
    function trailingEdge(time) {
        timerId = undefined;

        /**
         * trailing爲true且lastArgs不爲undefined時調用
         */
        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) {
            /**
             * 第一次調用
             */
            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開始,順着執行順序閱讀,理解起來更輕鬆

【注1】一開始我沒看明白if(maxing)裏面這段代碼的做用,按理說,是不會執行這段代碼的,後來我去lodash的倉庫裏看了test文件,發現對這段代碼,專門有一個case對其測試。我剝除了一些代碼,並修改了測試用例以便展現,以下:

var limit = 320,
    withCount = 0

var withMaxWait = debounce(function () {
    console.log('invoke');
    withCount++;
}, 64, {
    'maxWait': 128
});

var start = +new Date;
while ((new Date - start) < limit) {
    withMaxWait();
}

執行代碼,打印了3次invoke;我又將if(maxing){}這段代碼註釋,再執行代碼,結果只打印了1次。結合源碼的英文註釋Handle invocations in a tight loop,咱們不難理解,本來理想的執行順序是withMaxWait->timer->withMaxWait->timer這種交替進行,但因爲setTimeout需等待主線程的代碼執行完畢,因此這種短期快速調用就會致使withMaxWait->withMaxWait->timer->timer,從第二個timer開始,因爲lastArgs被置爲undefined,也就不會再調用invokeFunc函數,因此只會打印一次invoke。

同時,因爲每次執行invokeFunc時都會將lastArgs置爲undefined,在執行trailingEdge時會對lastArgs進行判斷,確保不會出現執行了if(maxing){}中的invokeFunc函數又執行了timer的invokeFunc函數

這段代碼保證了設置maxWait參數後的正確性和時效性

【注2】執行過一次trailingEdge後,再執行debounced函數,可能會遇到shouldInvoke返回false的狀況,需單獨處理

【注3】對於lodash的debounce來講,throttle是一種leading爲true且maxWait等於wait的特殊debounce

相關文章
相關標籤/搜索