前端效能優化之節流-throttle

Codeeeee發表於2018-11-19

上次介紹了前端效能優化之防抖-debounce,這次來聊聊它的兄弟-節流。

再拿乘電梯的例子來說:坐過電梯的都知道,在電梯關門但未上升或下降的一小段時間內,如果有人從外面按開門按鈕,電梯是會再開門的。要是電梯空間沒有限制的話,那裡面的人就一直在等。。。後來電梯工程師收到了好多投訴,於是他們就改變了方案,設定每隔一定時間,比如30秒,電梯就會關門,下一節電梯會繼續等待30秒。

專業術語概括就是:每隔一定時間,執行一次函式。

最簡易版的程式碼實現:

function throttle(fn, delay) {
    let timer = null;

    return function() {
        const context = this;
        const args = arguments;

        if (!timer) {
            timer = setTimeout(() => {
                fn.apply(context, args);
                timer = null;
            }, delay);
        }
    };
}
複製程式碼

很好理解,返回一個匿名函式形成閉包,並維護了一個區域性變數timer。只有在timer不為null才開啟定時器,而timer為null的時機則是定時器執行完畢。

除了定時器,還可以用時間戳實現:

function throttle(fn, delay) {
    let last = 0;

    return function() {
        const context = this;
        const args = arguments;

        const now = +new Date();
        const offset = now - last;

        if (offset > delay) {
            last = now;
            fn.apply(context, args);
        }
    };
}
複製程式碼

last代表上次執行fn的時刻,每次執行匿名函式都會計算當前時刻與last的間隔,是否比我們設定的時間間隔大,若大於,則執行fn,並更新last的值。

比較上述兩種實現方式,其實是有區別的: 定時器方式,第一次觸發並不會執行fn,但停止觸發之後,還會再次執行一次fn 時間戳方式,第一次觸發會執行fn,停止觸發後,不會再次執行一次fn

兩種方式是可以互補的,可以將其結合起來,即能第一次觸發會執行fn,又能在停止觸發後,再次執行一次fn:

function throttle(fn, delay) {
    let last = 0;
    let timer = null;

    return function() {
        const context = this;
        const args = arguments;

        const now = +new Date();
        const offset = now - last;

        if (offset > delay) {
            if (timer) {
                clearTimeout(timer);
                timer = null;
            }

            last = now;
            fn.apply(context, args);
        }
        else if (!timer) {
            timer = setTimeout(() => {
                last = +new Date();
                timer = null;
                fn.apply(context, args);
            }, delay - offset);
        }
    };
}
複製程式碼

匿名函式內有個if...else,第一個是判斷時間戳,第二個是判斷定時器,對比下前面兩種實現方式。 首先是時間戳方式的簡易版:

if (offset > delay) {
  last = now;
  fn.apply(context, args);
}
複製程式碼

混合版:

if (offset > delay) {
  if (timer) {      // 注意這裡
    clearTimeout(timer);
    timer = null;
  }

  last = now;
  fn.apply(context, args);
}
複製程式碼

可以發現,混合版比簡易版多了對timer不為null的判斷,並清除了定時器、將timer置為null。 再是定時器實現方式的簡易版:

if (!timer) {
  timer = setTimeout(() => {
    fn.apply(context, args);
    timer = null;
  }, delay);
}
複製程式碼

混合版:

else if (!timer) {
  timer = setTimeout(() => {
    last = +new Date();   // 注意這裡
    timer = null;
    fn.apply(context, args);
  }, delay - offset);
}
複製程式碼

可以看到,混合版比簡易版多了對last變數的重置,而last變數是時間戳實現方式中判斷的重要因素。這裡要注意下,因為是在定時器的回撥中,所以last的重置值要重新獲取當前時間戳,而不能使用變數now。

通過以上對比,我們可以發現,混合版是綜合了兩種不同實現方式的作用,但除去開始和結束階段的不同,兩者的共同作用是一致的--執行fn函式。所以,同一個時刻,執行fn函式的語句只能存在一個!在混合版的實現中,時間戳判斷裡,去除了定時器的影響,定時器判斷裡,去除了時間戳的影響。

對於立即執行和停止觸發後的再次執行,我們可以通過引數來控制,適應需求的變化。 假設規定{ immediate: false } 阻止立即執行,{ trailing: false } 阻止停止觸發後的再次觸發:

function throttle(fn, delay, options = {}) {
    let timer = null;
    let last = 0;

    return function() {
        const context = this;
        const args = arguments;

        const now = +new Date();
        
        if (last === 0 && options.immediate === false) {    // 這個條件語句是新增的
            last = now;
        }

        const offset = now - last;

        if (offset > delay) {
            if (timer) {
                clearTimeout(timer);
                timer = null;
            }

            last = now;
            fn.apply(context, args);
        }
        else if (!timer && options.trailing !== false) {  // options.trailing !== false 是新增的
            timer = setTimeout(() => {
                last = options.immediate === false ? 0 : +new Date();;
                timer = null;
                fn.apply(context, args);
            }, delay - offset);
        }
    };
}
複製程式碼

相對於混合版,除了新增了一個引數options,其它不同之處已在程式碼中標明。 思考下,立即執行是時間戳方式實現的,那麼想要阻止立即執行的話,只要阻止第一次觸發時,offset > delay 條件的成立就行了!如何判斷是第一次觸發?last變數只有初始化時,值才會是0,再加上我們手動傳入的引數,阻止立即執行的條件就滿足了:

if (last === 0 && options.immediate === false) {    
  last = now;
}
複製程式碼

條件滿足後,我們重置last變數的初始值為當前時間戳,那麼第一次 offset > delay 就不會成立了! 然後想阻止停止觸發後的再次執行,仔細一想,要是不需要這個功能的話,時間戳的實現不就可以滿足了?對!我們只要變相地去除定時器就好了:

!timer && options.trailing !== false
複製程式碼

如果我們不手動傳入{ trailing: false } ,這個條件是永遠不會成立的,即定時器永遠不會開啟。

不過有個問題在於,immediate和trailing不能同時設定為false,原因在於,{ trailing: false } 的話,停止觸發後不會再次執行,然後關鍵的last變數也就不會被重置為0,下一次再次觸發又會立即執行,這樣就有衝突了。

相關文章