【進階 6-3 期】深入淺出節流函式 throttle

Jack2k發表於2021-09-09

高階前端進階(id:FrontendGaoji)

作者:木易楊,資深,前網易工程師,13K star Daily-Interview-Question 作者

圖片描述

引言

上一節我們詳細聊了聊高階函式之柯里化,透過介紹其定義和三種柯里化應用,並在最後實現了一個通用的 currying 函式。這一小節會繼續之前的篇幅聊聊函式節流 throttle,給出這種高階函式的定義、實現原理以及在 underscore 中的實現,歡迎大家拍磚。

有什麼想法或者意見都可以在評論區留言,下圖是本文的思維導圖,高畫質思維導圖和更多文章請看我的 Github。

圖片描述

定義及解讀

函式節流指的是某個函式在一定時間間隔內(例如 3 秒)只執行一次,在這 3 秒內 無視後來產生的函式呼叫請求,也不會延長時間間隔。3 秒間隔結束後第一次遇到新的函式呼叫會觸發執行,然後在這新的 3 秒內依舊無視後來產生的函式呼叫請求,以此類推。

圖片描述

舉一個小例子,不知道大家小時候有沒有養過小金魚啥的,養金魚肯定少不了接水,剛開始接水時管道中水流很大,水到半滿時開始擰緊水龍頭,減少水流的速度變成 3 秒一滴,透過滴水給小金魚增加氧氣。

此時「管道中的水」就是我們頻繁操作事件而不斷湧入的回撥任務,它需要接受「水龍頭」安排;「水龍頭」就是節流閥,控制水的流速,過濾無效的回撥任務;「滴水」就是每隔一段時間執行一次函式,「3 秒」就是間隔時間,它是「水龍頭」決定「滴水」的依據。

如果你還無法理解,看下面這張圖就清晰多了,另外點選 這個頁面 檢視節流和防抖的視覺化比較。其中 Regular 是不做任何處理的情況,throttle 是函式節流之後的結果,debounce 是函式防抖之後的結果(下一小節介紹)。

圖片描述

原理及實現

函式節流非常適用於函式被頻繁呼叫的場景,例如:window.onresize() 事件、mousemove 事件、上傳進度等情況。使用 throttle API 很簡單,那應該如何實現 throttle 這個函式呢?

實現方案有以下兩種

  • 第一種是用時間戳來判斷是否已到執行時間,記錄上次執行的時間戳,然後每次觸發事件執行回撥,回撥中判斷當前時間戳距離上次執行時間戳的間隔是否已經達到時間差(Xms) ,如果是則執行,並更新上次執行的時間戳,如此迴圈。

  • 第二種方法是使用定時器,比如當 scroll 事件剛觸發時,列印一個 hello world,然後設定個 1000ms 的定時器,此後每次觸發 scroll 事件觸發回撥,如果已經存在定時器,則回撥不執行方法,直到定時器觸發,handler 被清除,然後重新設定定時器。

這裡我們採用第一種方案來實現,透過閉包儲存一個 previous 變數,每次觸發 throttle 函式時判斷當前時間和 previous 的時間差,如果這段時間差小於等待時間,那就忽略本次事件觸發。如果大於等待時間就把 previous 設定為當前時間並執行函式 fn。

我們來一步步實現,首先實現用閉包儲存 previous 變數。

const throttle = (fn, wait) => {
    // 上一次執行該函式的時間
  let previous = 0
  return function(...args) {
    console.log(previous)
    ...
  }
}

執行 throttle 函式後會返回一個新的 function,我們命名為 betterFn。

const betterFn = function(...args) {
  console.log(previous)
    ...
}

betterFn 函式中可以獲取到 previous 變數值也可以修改,在回撥監聽或事件觸發時就會執行 betterFn,即 betterFn(),所以在這個新函式內判斷當前時間和 previous 的時間差即可。

const betterFn = function(...args) {
  let now = +new Date();
  if (now - previous > wait) {
    previous = now
    // 執行 fn 函式
    fn.apply(this, args)
  }
}

結合上面兩段程式碼就實現了節流函式,所以完整的實現如下。

// fn 是需要執行的函式
// wait 是時間間隔
const throttle = (fn, wait = 50) => {
  // 上一次執行 fn 的時間
  let previous = 0
  // 將 throttle 處理結果當作函式返回
  return function(...args) {
    // 獲取當前時間,轉換成時間戳,單位毫秒
    let now = +new Date()
    // 將當前時間和上一次執行函式的時間進行對比
    // 大於等待時間就把 previous 設定為當前時間並執行函式 fn
    if (now - previous > wait) {
      previous = now
      fn.apply(this, args)
    }
  }
}

// DEMO
// 執行 throttle 函式返回新函式
const betterFn = throttle(() => console.log('fn 函式執行了'), 1000)
// 每 10 秒執行一次 betterFn 函式,但是隻有時間差大於 1000 時才會執行 fn
setInterval(betterFn, 10)

underscore 原始碼解讀

上述程式碼實現了一個簡單的節流函式,不過 underscore 實現了更高階的功能,即新增了兩個功能

  • 配置是否需要響應事件剛開始的那次回撥( leading 引數,false 時忽略)

  • 配置是否需要響應事件結束後的那次回撥( trailing 引數,false 時忽略)

配置 { leading: false } 時,事件剛開始的那次回撥不執行;配置 { trailing: false } 時,事件結束後的那次回撥不執行,不過需要注意的是,這兩者不能同時配置。

所以在 underscore 中的節流函式有 3 種呼叫方式,預設的(有頭有尾),設定 { leading: false } 的,以及設定 { trailing: false } 的。上面說過實現 throttle 的方案有 2 種,一種是透過時間戳判斷,另一種是透過定時器建立和銷燬來控制。

第一種方案實現這 3 種呼叫方式存在一個問題,即事件停止觸發時無法響應回撥,所以 { trailing: true } 時無法生效。

第二種方案來實現也存在一個問題,因為定時器是延遲執行的,所以事件停止觸發時必然會響應回撥,所以 { trailing: false } 時無法生效。

underscore 採用的方案是兩種方案搭配使用來實現這個功能。

const throttle = function(func, wait, options) {
  var timeout, context, args, result;

  // 上一次執行回撥的時間戳
  var previous = 0;

  // 無傳入引數時,初始化 options 為空物件
  if (!options) options = {};

  var later = function() {
    // 當設定 { leading: false } 時
    // 每次觸發回撥函式後設定 previous 為 0
    // 不然為當前時間
    previous = options.leading === false ? 0 : _.now();

    // 防止記憶體洩漏,置為 null 便於後面根據 !timeout 設定新的 timeout
    timeout = null;

    // 執行函式
    result = func.apply(context, args);
    if (!timeout) context = args = null;
  };

  // 每次觸發事件回撥都執行這個函式
  // 函式內判斷是否執行 func
  // func 才是我們業務層程式碼想要執行的函式
  var throttled = function() {

    // 記錄當前時間
    var now = _.now();

    // 第一次執行時(此時 previous 為 0,之後為上一次時間戳)
    // 並且設定了 { leading: false }(表示第一次回撥不執行)
    // 此時設定 previous 為當前值,表示剛執行過,本次就不執行了
    if (!previous && options.leading === false) previous = now;

    // 距離下次觸發 func 還需要等待的時間
    var remaining = wait - (now - previous);
    context = this;
    args = arguments;

    // 要麼是到了間隔時間了,隨即觸發方法(remaining <= 0)
    // 要麼是沒有傳入 {leading: false},且第一次觸發回撥,即立即觸發
    // 此時 previous 為 0,wait - (now - previous) 也滿足 <= 0
    // 之後便會把 previous 值迅速置為 now
    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        clearTimeout(timeout);

        // clearTimeout(timeout) 並不會把 timeout 設為 null
        // 手動設定,便於後續判斷
        timeout = null;
      }

      // 設定 previous 為當前時間
      previous = now;

      // 執行 func 函式
      result = func.apply(context, args);
      if (!timeout) context = args = null;
    } else if (!timeout && options.trailing !== false) {
      // 最後一次需要觸發的情況
      // 如果已經存在一個定時器,則不會進入該 if 分支
      // 如果 {trailing: false},即最後一次不需要觸發了,也不會進入這個分支
      // 間隔 remaining milliseconds 後觸發 later 方法
      timeout = setTimeout(later, remaining);
    }
    return result;
  };

  // 手動取消
  throttled.cancel = function() {
    clearTimeout(timeout);
    previous = 0;
    timeout = context = args = null;
  };

  // 執行 _.throttle 返回 throttled 函式
  return throttled;
};

小結

  • 函式節流指的是某個函式在一定時間間隔內(例如 3 秒)只執行一次,在這 3 秒內 無視後來產生的函式呼叫請求

  • 節流可以理解為養金魚時擰緊水龍頭放水,3 秒一滴

  • 「管道中的水」就是我們頻繁操作事件而不斷湧入的回撥任務,它需要接受「水龍頭」安排

  • 「水龍頭」就是節流閥,控制水的流速,過濾無效的回撥任務

  • 「滴水」就是每隔一段時間執行一次函式

  • 「3 秒」就是間隔時間,它是「水龍頭」決定「滴水」的依據

  • 節流實現方案有 2 種

  • 第一種是用時間戳來判斷是否已到執行時間,記錄上次執行的時間戳,然後每次觸發事件執行回撥,回撥中判斷當前時間戳距離上次執行時間戳的間隔是否已經達到時間差(Xms) ,如果是則執行,並更新上次執行的時間戳,如此迴圈。

  • 第二種方法是使用定時器,比如當 scroll 事件剛觸發時,列印一個 hello world,然後設定個 1000ms 的定時器,此後每次觸發 scroll 事件觸發回撥,如果已經存在定時器,則回撥不執行方法,直到定時器觸發,handler 被清除,然後重新設定定時器。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/36/viewspace-2823642/,如需轉載,請註明出處,否則將追究法律責任。

相關文章