前端战五渣学JavaScript——防抖、节流和rAF

18,816 阅读12分钟

看了《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逻辑,可能还会引起浏览器崩溃。所以我们需要控制一下触发的次数,来优化一下代码执行情况。

口说无凭,大家可能也不了解到底是怎样操作,那就来个例子:⬇️

<!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 防抖

什么是防抖

啥是防抖呢?我自己的理解就是,当连续触发一个方法的时候,方法并不执行,而是在连续触发结束的时候再执行这个方法。

举个例子:一部直梯,陆续往上上人(连续触发),当不再上人的时候(停止连续触发),电梯才会关门并动起来(执行方法)。

如何实现呢

上面是我模拟电梯上人的例子做出来的,可能这样看的比较直观一些,下面有我实现的代码,大概意思就是当我上人以后,电梯启动,当我一直在上人的时候,电梯不动直到不再上人了,才会关门启动

<!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结束后才去真正执行,但是还有一种情况,那就是我们在连续触发一个方法的时候,第一次触发就执行了,然后后面的连续触发不再执行,等连续触发停止,经过延时以后,再次触发才会真正执行。

我还是盗图吧。。。普遍的形式是下面这种

连续触发结束时执行,而我们现在说的“前摇”则是下面这种情况

在连续触发的一开始就执行了,然后往后的连续触发不执行,连续触发停止后再经过延时时间后触发才会再次执行

下面是我自己写的,大概意思是这样,代码实现也贴出来

<!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秒才会再执行)。

如何实现呢??

时间戳

我们首先用时间戳来判断前后的时间间隔,然后就可以知道我从上次执行完这个方法过了多久,过了这么长时间,是不是已经超过了自己规定的时长,如果时长超过了,我就可以再次执行了

<!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方法

/**
 * @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吧

直接上图

<!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》现阶段的漫威宇宙的结束,《哈利·波特》《火影忍者》一个个完结的电影,虽然在时刻提醒着我们青春再慢慢的消失,正如英雄联盟中的那句话,我们有了新的敌人叫“生活”。当这些完结的并不是真正的结束,《哈利·波特》有《神奇动物在哪里》,《火影忍者》有《博人传》,《钢铁侠》有《蜘蛛侠》,晚辈从前辈手中接过接力棒,继续往后跑,我们也从自己青葱的岁月进入下一阶段,努力奋斗吧!!


我是前端战五渣,一个前端界的小学生。