前端戰五渣學JavaScript——防抖、節流和rAF

前端戰五渣發表於2019-05-07

看了《JavaScript高階程式設計》和網上的一些部落格,感覺對函式節流和函式防抖的概念是反的,以下我寫的關於防抖和節流的概念取決於多數人的概念吧,並且基於倫敦前端工程師David Corbacho的客座文章。文章寫的很好,並且有對應的程式碼可以操作,更容易理解。其實我覺得叫什麼不重要,這個方法叫節流還是這個方法叫防抖,只要你能說明白,並且在生產中能用上就可以,一個名字,不用太去糾結。

《復仇者聯盟4:終局之戰》代表著一個時代的結束,從2008年高二看300多MB的《鋼鐵俠》開始,漫威電影宇宙也像哈利波特的魔法世界一樣一路伴我前行。一個時代的落幕,必將開始一個新的時代。End Game??No!

I LOVE YOU THREE THOUSANDS TIMES

I AM IRON MAN

banner獻給復仇者聯盟的超級英雄們
banner獻給復仇者聯盟的超級英雄們???

為什麼要防抖和節流??

防抖節流是兩個相似的技術,都是為了減少一個函式無用的觸發次數,以便提高效能或者說避免資源浪費。我們都知道js在操作DOM的時候,代價非常昂貴,相對於非DOM操作需要更多的記憶體和和CPU時間,假如我們一個函式是在滾動滾動條或者更改更改視窗大小的時候頻繁觸發,還是會出現頁面卡頓,如果是一套複雜的操作DOM邏輯,可能還會引起瀏覽器崩潰。所以我們需要控制一下觸發的次數,來優化一下程式碼執行情況。

口說無憑,大家可能也不瞭解到底是怎樣操作,那就來個例子:⬇️

前端戰五渣學JavaScript——防抖、節流和rAF

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>我要節流</title>
  <style>
    body{ height: 3000px; }
    #centerNum { width: 100px; height: 100px; line-height: 100px; text-align: center; position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); }
  </style>
</head>
<body>
  <h1 id="centerNum">0</h1>
  <script>
    var num = 0;
    window.onscroll = function () {
      var root = document.getElementsByTagName('body'),
      h = document.getElementById('centerNum');
      h.innerHTML = num;
      num ++;
    }
  </script>
</body>
</html>
複製程式碼

我們來一個window.onscroll的函式,只要滾動,就改變一次<h1>標籤中的數,在上面的圖中,我們能看到這個觸發是非常頻繁的,如果我們不加以干涉的話,讓這個函式肆意觸發,豈不是要上天了?

Debounce 防抖

什麼是防抖

啥是防抖呢?我自己的理解就是,當連續觸發一個方法的時候,方法並不執行,而是在連續觸發結束的時候再執行這個方法。

舉個例子:一部直梯,陸續往上上人(連續觸發),當不再上人的時候(停止連續觸發),電梯才會關門並動起來(執行方法)。

如何實現呢

前端戰五渣學JavaScript——防抖、節流和rAF
上面是我模擬電梯上人的例子做出來的,可能這樣看的比較直觀一些,下面有我實現的程式碼,大概意思就是當我上人以後,電梯啟動,當我一直在上人的時候,電梯不動直到不再上人了,才會關門啟動

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>電梯上人</title>
  <style>

  </style>
</head>
<body>
  <button id="addBtn">電梯上人,人數+1</button><button id="resetBtn">重置</button>
  <p id="personNum">電梯人數:0(假設電梯可以無限裝人)</p>
  <p id="elevatorStatus">電梯停靠</p>
  <script>
    var personNum = 0; // 電梯人數
    var closeDoor = null; // 電梯啟動延時程式
    var addBtn = document.getElementById('addBtn'); // 獲取新增人數按鈕
    var personNumP = document.getElementById('personNum'); // 獲取顯示人數的標籤
    var resetBtn = document.getElementById('resetBtn'); // 獲取重置按鈕
    var elevatorStatus = document.getElementById('elevatorStatus'); // 獲取電梯狀態標籤
    /**
     * @method 電梯內新增人數
     * @description 點選一次電梯內增加一人,增加完人數電梯啟動初始化
     */
    function addPerson() {
      personNum ++;
      personNumP.innerHTML = `電梯人數:${personNum}(假設電梯可以無限裝人)`
      initElevatorStart();
    }
    /**
     * @method 電梯啟動
     * @description 電梯啟動,置灰新增人數按鈕,禁止上人
     */
    function elevatorStart() {
      elevatorStatus.innerHTML = '電梯啟動';
      addBtn.disabled = true;
    }
    /**
     * @method 電梯啟動初始化
     * @description 清除之前的關門延時,並重新計算關門延時500ms,意思是當不在觸發電梯啟動初始化函式時,500ms後啟動電梯
     */
    function initElevatorStart() {
      clearTimeout(closeDoor);
      closeDoor = setTimeout(function () {
        elevatorStart();
      }, 500);
    }
    /**
     * @method 重置電梯
     */
    function reset() {
      personNum = 0;
      personNumP.innerHTML = `電梯人數:${personNum}(假設電梯可以無限裝人)`
      elevatorStatus.innerHTML = '電梯停靠';
      addBtn.disabled = false;
    }

    addBtn.addEventListener('click', addPerson);
    resetBtn.addEventListener('click', reset);
  </script>
</body>
</html>
複製程式碼

上面的程式碼意思就是我電梯上一個人,就需要關閉電梯門(觸發initElevatorStart()方法),然後電梯啟動。但是我一直在點選上人的按鈕,電梯是不會觸發關門啟動電梯的elevatorStart()方法。

程式碼的核心是initElevatorStart()方法,這個方法在實際需要執行的關門啟動電梯方法elevatorStart()外面新增了一層setTimeout方法,也就是為了在呼叫這個方法的時候我們過500毫秒再去執行真正需要執行的方法。如果這500毫秒之內,又重新觸發了initElevatorStart()方法,就需要重新計時,要不不就夾到人了嘛,要賠錢的。。。。

這是防抖最粗糙的實現了???

基本形式

下面是這個防抖實現的最基本的形式,也是我們在《JavaScript高階程式設計》中看到的樣子⬇️

var processor = {
  timeoutId: null, // 相當於延時setTimeout的一個標記,方便清除的時候使用

  // 實際進行處理的方法
  // 連續觸發停止以後需要觸發的程式碼
  performProcessiong: function () {
    // 實際執行的程式碼
    // 這裡實際就是需要在停止觸發的時候執行的程式碼
  },

  // 初始處理呼叫的方法
  // 在實際需要觸發的程式碼外面包一層延時clearTimeout方法,以便控制連續觸發帶來的無用呼叫
  process: function () {
    clearTimeout(this.timeoutId); // 先清除之前的延時,並在下面重新開始計算時間

    var that = this; // 我們需要儲存作用域,因為下面的setTimeout的作用域是在window,呼叫不要我們需要執行的this.performProcessiong方法
    this.timeoutId = setTimeout(function () { // 100毫秒以後執行performProcessiong方法
      that.performProcessiong();
    }, 100) // 如果還沒有執行就又被觸發,會根據上面的clearTimeout來清除並重新開始計算
  }
};

// 嘗試開始執行
processor.process(); // 需要重新繫結在一個觸發條件裡
複製程式碼

上面這段程式碼就是最基本的實現方式,包在一個物件中,然後在物件中互相呼叫,裡面的註釋應該可以很清楚的說明每一步是幹什麼呢,最下面的processor.process()我們在實際使用的時候肯定是需要繫結在一個觸發條件上的,比如之前的上電梯問題上,我們就需要把processor.process()方法繫結在增加人數的裡面,這樣才會有多次呼叫的情況發生

上面再怎麼說都是很簡單的實現,在實際生產環境中,邏輯會相對複雜很多,但是萬變不離其宗,參透了最基礎的,再舉一反三就不是什麼問題了

應該叫“前搖”??

具體我也不知道應該叫啥,英文叫“Leading edge”,甭管中文叫啥了,知道是什麼意思就行了。之前我們寫的程式碼很明顯可以看出來,在我們連續觸發一個方法的時候,是在setTimeout結束後才去真正執行,但是還有一種情況,那就是我們在連續觸發一個方法的時候,第一次觸發就執行了,然後後面的連續觸發不再執行,等連續觸發停止,經過延時以後,再次觸發才會真正執行。

我還是盜圖吧。。。普遍的形式是下面這種

前端戰五渣學JavaScript——防抖、節流和rAF
連續觸發結束時執行,而我們現在說的“前搖”則是下面這種情況

前端戰五渣學JavaScript——防抖、節流和rAF
在連續觸發的一開始就執行了,然後往後的連續觸發不執行,連續觸發停止後再經過延時時間後觸發才會再次執行

下面是我自己寫的,大概意思是這樣,程式碼實現也貼出來

前端戰五渣學JavaScript——防抖、節流和rAF

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>電梯上人</title>
  <style>

  </style>
</head>
<body>
  <button id="addBtn">電梯上人,人數+1</button><button id="resetBtn">重置</button>
  <p id="personNum">電梯人數:0(假設電梯可以無限裝人)</p>
  <script>
    var personNum = 0; // 電梯人數
    var okNext = true; // 是否可進行下次執行
    var timeoutFn = null;
    var addBtn = document.getElementById('addBtn'); // 獲取新增人數按鈕
    var personNumP = document.getElementById('personNum'); // 獲取顯示人數的標籤
    var resetBtn = document.getElementById('resetBtn'); // 獲取重置按鈕
    /**
     * @method 電梯新增人數
     * @description 電梯可以上人,但是上人以後就不能再上了,不管怎麼觸發都不行,除非停止觸發500毫秒以後,再觸發的時候才可以繼續執行
     */
    function addPerson() {
      if (okNext) {
        okNext = false;
        personNum ++
        personNumP.innerHTML = `電梯人數:${personNum}(假設電梯可以無限裝人)`
      }
      clearTimeout(timeoutFn);
      timeoutFn = setTimeout(function () {
        okNext = true;
      }, 500)
    }
    /**
     * @method 重置
     */
    function reset() {
      personNum = 0;
      personNumP.innerHTML = '電梯人數:0(假設電梯可以無限裝人)';
    }

    addBtn.addEventListener('click', addPerson);
    resetBtn.addEventListener('click', reset);
  </script>
</body>
</html>
複製程式碼

上面程式碼要是看不太明白,可以直接粘下去自己執行以下看看是什麼感覺,就知道是什麼意思了。

程式碼純我自己寫的,要是有不對的地方,請大佬指正啊

Throttle 節流

什麼是節流

節流呢,也是我自己的理解,在連續觸發一個方法的某一時間段中,控制方法的執行次數。

同樣舉個例子吧,一個地鐵進站閘口,10秒進一個人(10秒內執行一個方法),管這10秒中來了是5個人、10個人還是20個人,都只是進一個人(從第一次觸發後10秒不管被觸發多少次都不會執行,直到下一個10秒才會再執行)。

如何實現呢??

時間戳

我們首先用時間戳來判斷前後的時間間隔,然後就可以知道我從上次執行完這個方法過了多久,過了這麼長時間,是不是已經超過了自己規定的時長,如果時長超過了,我就可以再次執行了

前端戰五渣學JavaScript——防抖、節流和rAF

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>地鐵進站</title>
</head>
<body>
  <button id="addBtn">進站人數+1</button><button id="resetBtn">重置</button>
  <p id="personTotal">旅客總人數:0</p>
  <p id="personNum">進站人數:0</p>
  <script>
    var personNum = 0; // 進站人數
    var personTotal = 0; // 一共來了多少人
    var addBtn = document.getElementById('addBtn'); // 獲取新增人數按鈕
    var personNumP = document.getElementById('personNum'); // 獲取顯示人數的標籤
    var personTotalP = document.getElementById('personTotal'); // 獲取顯示總人數的標籤
    var resetBtn = document.getElementById('resetBtn'); // 獲取重置按鈕
    /**
     * @method 增加進站人數
     * @description 每個時間間隔執行的方法
     */
    function addPerson() {
      personNum ++;
      personNumP.innerHTML = `進站人數:${personNum}`;
    }
    /**
     * @method 節流方法(時間戳)
     * @param {Function} fn 需要節流的實際方法
     * @param {Number} wait 需要控制的時間長度
     * @description 根據上一次執行的時間,和這一次執行的時間做比較,如果大於控制的時間,就可以執行
     */
    function throttle(fn, wait) {
      var prev = 0; // 第一次執行的時候是0,所以第一次點選的時候肯定大於這個數,所以會立馬執行
      return function () {
        var context = this;
        var args = arguments;
        var now = Date.now(); // 實際執行的時間
        personTotal ++;
        personTotalP.innerHTML = `旅客總人數:${personTotal}`;
        if (now - prev >= wait) { // 執行的時間是不是比上次執行的時間大於需要延遲的時間,大於,我們就執行
          fn.apply(context, args);
          prev = now; // 執行了以後,重置上一次執行的時間為剛剛執行這次函式的時間,下次執行就用這個時間為基準
        }
      }
    }
    /**
     * @method 重置
     */
    function reset() {
      personNum = 0;
      personTotal = 0;
      personNumP.innerHTML = '進站人數:0';
      personTotalP.innerHTML = `旅客總人數:0`;
    }

    addBtn.addEventListener('click', throttle(addPerson, 1000));
    resetBtn.addEventListener('click', reset);
  </script>
</body>
</html>
複製程式碼

節流函式throttle用到了作用域,call、apply和閉包等相關的知識,看不懂的可以看我之前的文章

  1. 《前端戰五渣學JavaScript——閉包》
  2. 《前端戰五渣學JavaScript——call、apply以及bind》

上面的程式碼中我感覺可以很直觀的看出來是根據判斷前後兩次的時間,來得知可不可以進行下一次函式的執行。參考著程式碼中的註釋我覺得應該可以看明白吧???

setTimeout

如果我們用setTimeout的話,我們只需要更改一下throttle方法

前端戰五渣學JavaScript——防抖、節流和rAF

/**
 * @method 節流方法(setTimeout)
 * @param {Function} fn 需要節流的實際方法
 * @param {Number} wait 需要控制的時間長度
 * @description 這個方法就很類似防抖了,就是判斷當前函式有沒有延遲setTimeout函式,有的話就不執行了
 */
function throttle(fn, wait) {
  var timeout = null; 
  return function () {
    var context = this;
    var args = arguments;
    personTotal ++;
    personTotalP.innerHTML = `旅客總人數:${personTotal}`;
    if (!timeout) { 
      var that = this;
      timeout = setTimeout(() => {
        timeout = null;
        fn.apply(context, args)
      }, wait)
    }
  }
}
複製程式碼

雖然我們只需要更改幾行程式碼就實現了用setTimeout實現節流的這個方法,但是我們仔細看上面的圖,我們可以發現,當我點選第一次的時候,進站旅客是沒有增加的,這跟我們實際情況不一樣,我們先來的,我不用等啊,我直接就能進站,對不對。還有當我結束增加人數的時候,進站旅客過去等待時間以後還會加一個人,這當然也不是我們想看到的。

使用時間戳還是setTimeout,取決於業務場景了

rAF(requestAnimationFrame)

誒??rAF是什麼?什麼是requestAnimationFrame?這在我沒有寫這篇部落格的時候,我根本不知道window下還有個這個方法,神奇吧,那這個方法是幹什麼的呢??

告訴瀏覽器——你希望執行一個動畫,並且要求瀏覽器在下次重繪之前呼叫指定的回撥函式更新動畫。該方法需要傳入一個回撥函式作為引數,該回撥函式會在瀏覽器下一次重繪之前執行。————《MDN Web Docs》

就是在用這個可以一直重繪動畫,然後讓人看起來是個動畫,重繪的這個過程是個很頻繁的操作,所以如果我們自己寫,不加以干涉,在效能和資源上會造成嚴重的浪費,所以我們可以使用requestAnimationFrame來使用我們的動畫看起來很流暢,又不會頻繁呼叫

優點

  1. 目標是60fps(16毫秒的一幀),瀏覽器將決定如何安排渲染的最佳時間。
  2. 相對簡單和標準的API,未來不會改變,減少維護成本。

缺點

  1. rAF是內部api,所以我們並不方便修改
  2. 如果瀏覽器選項卡沒有啟用,就用不了
  3. 相容性不好,在IE9,Opera Mini和舊Android中仍然不支援
  4. node中不能使用

讓我們來使用rAF吧

直接上圖

前端戰五渣學JavaScript——防抖、節流和rAF

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>rAF使用</title>
  <style>
    #SomeElementYouWantToAnimate {
      width: 100px;
      height: 100px;
      background-color: #000;
    }
  </style>
</head>
<body>
  <div id="SomeElementYouWantToAnimate"></div>
  <script>
    var start = null;
    var element = document.getElementById('SomeElementYouWantToAnimate');
    element.style.position = 'absolute';
    /**
     * @method 移動我們的小黑方塊
     */
    function step(timestamp) {
      if (!start) start = timestamp;
      var progress = timestamp - start;
      element.style.left = Math.min(progress / 10, 200) + 'px';
      if (progress < 2000) {
        window.requestAnimationFrame(step);
      }
    }

    window.requestAnimationFrame(step);
  </script>
</body>
</html>
複製程式碼

總結

rAF是一個內部api,固定的16毫秒執行一次,因為人眼接受60fps的動畫就會感到很流暢了,如果我們需要改變rAF的執行時間,那我們只能自己去寫動畫的方法,節流還是防抖,看個人愛好了

收官

防抖:連續觸發一個函式,不管是觸發開始執行還是結束執行,只要在連續觸發,就只執行一次

節流:規定時間內只執行一次,不管是規定時間內被觸發了多少次

rAF:也算是一種節流手段,原生api,旨在使動畫在儘量少佔用資源的情況下使動畫流暢

lodash中相對應的_.throttle和_.debounce,在我看來是最佳實踐了,推薦使用

End Game

《復仇者聯盟4》現階段的漫威宇宙的結束,《哈利·波特》《火影忍者》一個個完結的電影,雖然在時刻提醒著我們青春再慢慢的消失,正如英雄聯盟中的那句話,我們有了新的敵人叫“生活”。當這些完結的並不是真正的結束,《哈利·波特》有《神奇動物在哪裡》,《火影忍者》有《博人傳》,《鋼鐵俠》有《蜘蛛俠》,晚輩從前輩手中接過接力棒,繼續往後跑,我們也從自己青蔥的歲月進入下一階段,努力奮鬥吧!!

前端戰五渣學JavaScript——防抖、節流和rAF

相關文章