從lodash原始碼學習節流與防抖

志如發表於2018-05-24

  之前遇到過一個場景,頁面上有幾個d3.js繪製的圖形。如果調整瀏覽器可視區大小,會引發圖形重繪。當圖中的節點比較多的時候,頁面會顯得異常卡頓。為了限制類似於這種短時間內高頻率觸發的情況,我們可以使用防抖函式。

  實際開發過程中,這樣的情況其實很多,比如:

  • 頁面的scroll事件
  • input框等的輸入事件
  • 拖拽事件用到的mousemove等

  先說說防抖和節流是個啥,有啥區別

防抖:設定一個時間間隔,當某個頻繁觸發的函式執行一次後,在這個時間間隔內不會再次被觸發,如果在此期間嘗試觸發這個函式,則時間間隔會重新開始計算。

節流:設定一個時間間隔,某個頻繁觸發的函式,在這個時間間隔內只會執行一次。也就是說,這個頻繁觸發的函式會以一個固定的週期執行。

debounce(函式防抖)

  大致捋一遍程式碼結構。為了方便閱讀,我們先把原始碼中的Function註釋掉。

function debounce(func, wait, options) {
    // 程式碼一開始,以閉包的形式定義了一些變數
      var lastArgs,  //  最後一次debounce的arguments,它其實起一個標記位的作用,後面會提到
          lastThis,  //  就是last this,用來修正this指向
          maxWait,   //  儲存option裡面傳入的maxWait值,最大等待時間
          result,    //  其實這個result始終都是undefined
          timerId,   // setTimeout賦給它,用於表示當前定時器
          lastCallTime,   // 最後一次呼叫debounce的時刻
          lastInvokeTime = 0,    //  最後一次呼叫使用者傳入函式的時刻
          leading = false,   //  是否在一開始就執行使用者傳入的函式
          maxing = false,    //  是否有最大等待時間
          trailing = true;   //  是否在等待週期結束後執行使用者傳入的函式

    //  使用者傳入的fun必須是個函式,否則報錯
      if (typeof func != 'function') {
        throw new TypeError(FUNC_ERROR_TEXT);
      }
      
    //  toNumber是lodash封裝的一個轉型別的方法
      wait = toNumber(wait) || 0;
      
    //  獲取使用者傳入的配置
      if (isObject(options)) {
        leading = !!options.leading;
        maxing = 'maxWait' in options;
        maxWait = maxing ? nativeMax(toNumber(options.maxWait) || 0, wait) : maxWait;
        trailing = 'trailing' in options ? !!options.trailing : trailing;
      }

    //  執行使用者傳入的函式
      function invokeFunc(time) {
        // ......
      }

    //  防抖開始時執行的操作
      function leadingEdge(time) {)
        // ......
      }

    //  計算仍然需要等待的時間
      function remainingWait(time) {
        // ......
      }

    //  判斷此時是否應該執行使用者傳入的函式
      function shouldInvoke(time) {
        // ......
      }

    //  等待時間結束後的操作
      function timerExpired() {
        // ......
      }

    //  執行使用者傳入的函式
      function trailingEdge(time) {
        // ......
      }

    //  取消防抖
      function cancel() {
        // ......
      }

     //  立即執行使用者傳入的函式
      function flush() {
        // ......
      }

    // 防抖開始的入口
      function debounced() {
        // ......
      }
      
      
      debounced.cancel = cancel;
      debounced.flush = flush;
      return debounced;
    }
複製程式碼

我們先從入口函式開始。函式開始執行後,首先會出現三種情況:

  • 時間上達到了可以執行的條件;
  • 時間上不滿足條件,但是此時的定時器並沒有啟動;
  • 不滿足條件,返回undefined

  說實話,第二種情況沒想到場景,哪位大佬給補充一下呢。

  程式碼中timerId = setTimeout(timerExpired, wait);是用來設定定時器,到時間後觸發trailingEdge這個函式。

function debounced() {
        var time = now(),
            isInvoking = shouldInvoke(time);   // 判斷此時是否可以開始執行使用者傳入的函式
        
        lastArgs = arguments;
        lastThis = this;
        lastCallTime = time;

        if (isInvoking) {
          // 如果此時並沒有定時器存在,就開始進入防抖階段
          if (timerId === undefined) {
            return leadingEdge(lastCallTime);
          }
          //  如果設定了最大等待時間,便立即執行使用者傳入的函式
          if (maxing) {
            // Handle invocations in a tight loop.
            timerId = setTimeout(timerExpired, wait);
            return invokeFunc(lastCallTime);
          }
        }
        if (timerId === undefined) {
          timerId = setTimeout(timerExpired, wait);
        }
        
        //  不滿足條件,return undefined
        return result;
      }

複製程式碼

  我們先來看看shouldInvoke是如何判斷函式是否可以執行的。

      function shouldInvoke(time) {
        //  lastCallTime初始值是undefined,lastInvokeTime初始值是0,
        //  防抖函式被手動取消後,這兩個值會被設為初始值
        var timeSinceLastCall = time - lastCallTime,
            timeSinceLastInvoke = time - lastInvokeTime;

        // Either this is the first call, activity has stopped and we're at the
        // trailing edge, the system time has gone backwards and we're treating
        // it as the trailing edge, or we've hit the `maxWait` limit.
        return (
                lastCallTime === undefined ||   //  初次執行
                (timeSinceLastCall >= wait) ||  //  上次呼叫時刻距離現在已經大於wait值
                (timeSinceLastCall < 0) ||      //  當前時間-上次呼叫時間小於0,應該只可能是手動修改了系統時間吧
                (maxing && timeSinceLastInvoke >= maxWait)  //  設定了最大等待時間,且已超時
            );
      }
複製程式碼

  我們繼續分析函式開始的階段leadingEdge。首先重置防抖函式最後呼叫時間,然後去觸發一個定時器,保證wait後接下來的執行。最後判斷如果leadingtrue的話,立即執行使用者傳入的函式:

      function leadingEdge(time) {
        // Reset any `maxWait` timer.
        lastInvokeTime = time;
        // Start the timer for the trailing edge.
        timerId = setTimeout(timerExpired, wait);
        // Invoke the leading edge.
        return leading ? invokeFunc(time) : result;
      }
複製程式碼

  我們已經不止一次去設定觸發器了,來我們探究一下里面到底做了啥。其實很簡單,判斷時間是否符合執行條件,符合的話觸發trailingEdge,也就是後續操作,否則計算需要等待的時間,並重新呼叫這個函式,其實這裡就是防抖的核心所在了。

      function timerExpired() {
        var time = now();
        if (shouldInvoke(time)) {
          return trailingEdge(time);
        }
        // Restart the timer.
        timerId = setTimeout(timerExpired, remainingWait(time));
      }
複製程式碼

  至於如何重新計算剩餘時間的,這裡不作過多解釋,大家一看便知。

      function remainingWait(time) {
        var timeSinceLastCall = time - lastCallTime,
            timeSinceLastInvoke = time - lastInvokeTime,
            timeWaiting = wait - timeSinceLastCall;

        return maxing
          ? nativeMin(timeWaiting, maxWait - timeSinceLastInvoke)
          : timeWaiting;
      }
複製程式碼

  我們說說等待時間到了以後的操作。重置了一些本週期的變數。並且,如果trailingtrue而且lastArgs存在時,才會再次執行使用者傳入的引數。這裡解釋了文章開頭提到的lastArgs只是個標記位,如註釋所說,他表示debounce至少執行了一次。

      function trailingEdge(time) {
        timerId = undefined;

        // Only invoke if we have `lastArgs` which means `func` has been
        // debounced at least once.
        if (trailing && lastArgs) {
          return invokeFunc(time);
        }
        lastArgs = lastThis = undefined;
        return result;
      }
複製程式碼

  執行使用者傳入的函式比較簡單,我們知道callapply是會立即執行的,其實最後的result還是undefined

    function invokeFunc(time) {
        var args = lastArgs,
            thisArg = lastThis;
        //  重置了一些條件
        lastArgs = lastThis = undefined;
        lastInvokeTime = time;
        //  執行使用者傳入函式
        result = func.apply(thisArg, args);
        return result;
      }
複製程式碼

  最後就是取消防抖和立即執行使用者傳入函式的過程了,程式碼一目瞭然,不作過多解釋。

      function cancel() {
        if (timerId !== undefined) {
          clearTimeout(timerId);
        }
        lastInvokeTime = 0;
        lastArgs = lastCallTime = lastThis = timerId = undefined;
      }

      function flush() {
        return timerId === undefined ? result : trailingEdge(now());
      }
複製程式碼

throttle(函式節流)

  節流其實原理跟防抖是一樣的,只不過觸發條件不同而已,其實就是maxWaitwait的防抖函式。

    function throttle(func, wait, options) {
      var leading = true,
          trailing = true;

      if (typeof func != 'function') {
        throw new TypeError(FUNC_ERROR_TEXT);
      }
      if (isObject(options)) {
        leading = 'leading' in options ? !!options.leading : leading;
        trailing = 'trailing' in options ? !!options.trailing : trailing;
      }
      return debounce(func, wait, {
        'leading': leading,
        'maxWait': wait,
        'trailing': trailing
      });
    }
複製程式碼

總結

  我們發現,其實lodash除了在cancle函式中使用了清除定時器的操作外,其他地方並沒有去關心定時器,而是很巧妙的在定時器里加了一個判斷條件來判斷後續函式是否可以執行。這就避免了手動管理定時器。

  lodash替我們考慮到了一些比較少見的情景,而且還有一定的容錯性。即便ES6實現了很多目前常用的工具函式,但是面對複雜的情景,我們依然可以以按需引入的方式使用lodash的一些函式來提升開發效率,同時使得我們的程式更加健壯。

相關文章