手写节流防抖

183 阅读9分钟

防抖

应用场景

在日常开发过程中经常遇到点击某个按钮触发事件,搜索框输入查询,滚动事件等场景,假设如果没有对此类事件加上一定的限制,比如在搜索框输入查询,每当我们输入一个或者更改值,就去请求后台数据,当我们快速的输入删除搜索框内的值,这样的触发事件的方式,可能会在请求的过程中由于返回数据的前后问题导致显示的值并非是正确的结果,同时也会加剧后台的压力,从各方面考虑,都不是一种较好的方式,所以经常性的需要我们在触发事件之间添加一个防抖机制。

什么是防抖

所谓防抖,就是设置一个时间段,事件处理函数在该时间段内只能执行一次,如果在设定时间段内没有重复执行,则在该时间段计时结束后执行,如果设定的时间到来之前,又一次触发了事件,就重新开始计算延时。 其原理就是:

  1. 利用定时器,将需要触发的事件延后触发
  2. 在事件触发后,创建一个定时器(即设置的时间段),在定时器结束时间内没有重复触发该事件,则执行,并销毁定时器
  3. 若在在定时器结束时间内重复触发该事件,则重置定时器(所以在重复触发事件时,会一致重置定时器,使得事件没有执行,等到不在重复触发时在执行)

简单实现

先定义好场景和函数

  <body>
    <div id="div1">
      <button id="add">+1按钮</button>
      <p>实际点击次数:<span id="span1">0</span></p>
      <p>事件触发次数:<span id="span2">0</span></p>
    </div>
  </body>

  <script>
    const add = document.getElementById('add');
    const span1 = document.getElementById('span1');
    const span2 = document.getElementById('span2');
    let count1 = 0;
    let count2 = 0;

    const fn1 = function() {
      span1.innerHTML = ++count1;
    };
    const fn2 = function() {
      span2.innerHTML = ++count2;
    };

    add.addEventListener('click', fn1);
    add.addEventListener('click', fn2);
  </script>

快速点击+1按钮 页面得到的情况如图所示

将fn2直接改为添加了防抖机制的函数

    let timer;
    const fn2 = function() {
      timer && clearTimeout(timer);
      timer = setTimeout(function() {
        span2.innerHTML = ++count2;
      }, 2000);
    };

在连续点击5次+1按钮后,如图所示,事件触发次数只为1,并且该事件执行的事件为定时器设置的2秒之后才执行

开始优化

在以上代码中,虽然可以实现了防抖机制,但是也存在比较明显的问题:

  1. 需要手动修改原函数
  2. 需要自己去维护定时器

所以,我们是否可以实现一个高阶函数,将原函数传入,然后返回一个添加了防抖机制的函数

    const fn2 = function() {
      span2.innerHTML = ++count2;
    };

    const myDebounce= function(fn) {
      let timer;
      return function() {
        timer && clearTimeout(timer);
        timer = setTimeout(function() {
          fn();
        }, 2000);
      };
    };

    const fn3 = myDebounce(fn2);
    // 使用 fn3 替换 fn2 
    add.addEventListener('click', fn1);
    add.addEventListener('click', fn3);

再次执行,效果与前面直接改原函数得到的效果是一致。

再次优化

我们在原函数上打印一下this指向

    const fn1 = function() {
      console.log('fn1', this);
      span1.innerHTML = ++count1;
    };
    
    const fn2 = function() {
      console.log('fn2', this);
      span2.innerHTML = ++count2;
    };

会发现,this指向被修改了

所以,我们对myDebounce函数进行进一步的优化

    const myDebounce = function(fn) {
      let timer;
      return function() {
        const _self = this;
        timer && clearTimeout(timer);
        timer = setTimeout(function() {
          // 利用apply直接将this指向修改过来
          fn.apply(_self);
        }, 1000);
      };
    };

再次去控制台查看,发现this指向已经修改过来了

进一步优化

以该场景为例,假设我们需要获取到触发的event,(以此衍生出其他需要参数的场景)

    const fn1 = function(e) {
      console.log('fn1', e);
      span1.innerHTML = ++count1;
    };

    const fn2 = function(e) {
      console.log('fn2', e);
      span2.innerHTML = ++count2;
    };

发现,没有myDebounce函数并没有对参数进行接收

所以需要我们继续优化

    const myDebounce = function(fn) {
      let timer;
      return function(...args) {
        const _self = this;
        timer && clearTimeout(timer);
        timer = setTimeout(function() {
          fn.apply(_self, args);
        }, 1000);
      };
    };

再次来到控制台查看,参数问题也得到了修复

继续优化

有时,为了提高用户体验等,需要我们在事件触发时先立即执行一次,在进行防抖,我们可以在myDebounce函数中添加一个参数immediate,对是否立即执行进行设置,同时优化到这里的时候,已经添加了参数wait用于控制定时器执行事件

  const myDebounce = function(fn, wait = 0, immediate) {
    // 判断fn是否为函数,不是则提示
    if (typeof fn !== 'function') {
      console.log('传入的参数并不是函数');
      return
    }
    let timer;
    return function(...args) {
      const _self = this;
      timer && clearTimeout(timer);
      // 首先,需判断时候需要立即执行,不需要则走上面已经实现的代码即可
      if (immediate) { // 需要立即执行
        // 需要解决的问题是,在重复触发的过程中,什么时候需要执行函数,什么时候不执行
        // 所以需要有一个变量老进行控制
        // 当第一次,tiemr 为空,后续触发时,timer已经有值了
        // 所以根据这个值,就可以来控制是否执行函数了
        let callNew = !timer
        timer = setTimeout(function() {
          fn.apply(_self, args);
          clearTimeout(timer)
          timer = null
        }, wait);
        // 即第一次进来的时候,执行
        if (callNew) {
          fn.apply(_self, args);
        }
      } else { // 不需要立即执行
        timer = setTimeout(function() {
          fn.apply(_self, args);
        }, wait); // wait控制定时器时间
      }
    };
  };

  const fn3 = myDebounce(fn2, 500, true);

在页面执行,会先执行一次,然后再一秒后再次执行一次

最终优化

最终优化中,处理了立即执行函数的返回值,以及拓展了函数取消事件

    function myDebounce(fn, wait = 0, immediate) {
      if (typeof fn !== 'function') {
        console.log('传入的参数并不是函数');
        return;
      }
      // 定义result,用于返回立即执行函数的返回值
      let timer, result;
      const debounce = function(...args) {
        const _self = this;
        timer && clearTimeout(timer);
        if (immediate) {
          let callNew = !timer;
          timer = setTimeout(function() {
            fn.apply(_self, args);
            clearTimeout(timer);
            timer = null;
          }, wait);
          if (callNew) {
            result = fn.apply(_self, args);
          }
        } else {
          timer = setTimeout(function() {
            fn.apply(_self, args);
          }, wait);
        }
        return result;
      };
      // 拓展了取消事件,其实就是清除定时器
      debounce.cancel = function() {
        clearTimeout(timer);
        timer = null;
      };
      return debounce;
    }

可以看到,当我们点击取消按钮时,事件就不在触发

测试的完整代码

  <body>
    <div id="div1">
      <button id="add">+1按钮</button>
      <button id="cancel">取消按钮</button>
      <p>实际点击次数:<span id="span1">0</span></p>
      <p>事件触发次数:<span id="span2">0</span></p>
    </div>
  </body>

  <script>
    const add = document.getElementById('add');
    const cancel = document.getElementById('cancel');
    const span1 = document.getElementById('span1');
    const span2 = document.getElementById('span2');
    let count1 = 0;
    let count2 = 0;

    const fn1 = function(e) {
      span1.innerHTML = ++count1;
    };

    const fn2 = function(e) {
      span2.innerHTML = ++count2;
      return '返回fn2';
    };

    function myDebounce(fn, wait = 0, immediate) {
      if (typeof fn !== 'function') {
        console.log('传入的参数并不是函数');
        return;
      }
      let timer, result;
      const debounce = function(...args) {
        const _self = this;
        timer && clearTimeout(timer);
        if (immediate) {
          let callNew = !timer;
          timer = setTimeout(function() {
            fn.apply(_self, args);
            clearTimeout(timer);
            timer = null;
          }, wait);
          if (callNew) {
            result = fn.apply(_self, args);
          }
        } else {
          timer = setTimeout(function() {
            fn.apply(_self, args);
          }, wait);
        }
        return result;
      };
      debounce.cancel = function() {
        clearTimeout(timer);
        timer = null;
      };
      return debounce;
    }

    const fn3 = myDebounce(fn2, 2500, true);

    add.addEventListener('click', fn1);
    add.addEventListener('click', fn3);
    cancel.onclick = fn3.cancel;
  </script>

节流

应用场景

在前面的基础上,相较于防抖,节流应多应用于需要事件持续的触发,但又不需要过于频繁的触发,比如鼠标移动式持续的获取x、y坐标等。

什么是节流

所谓节流,就是指连续触发事件但是在 n 秒中只执行一次函数。 节流会稀释函数的执行频率,假设原函数会在5秒的时间内触发1000次,就可以设置一个节流使得函数在0.2秒内只执行一次,最后5秒内只触发25次。 其原理就是:

  1. 利用时间戳,将当前触发的时间与上一次触发的事件进行比较。
  2. 当时间间隔小于设置的时间时,则不触发
  3. 当时间间隔大于设置时间时,则执行函数,并把当前时间记录下来,作为下一次执行的时间对比。

第一版代码实现

在前面的基础上,此时就直接运用高阶函数对函数添加节流。

定义场景:

  <body>
    <div id="div1">
      <div id="div2"></div>
      <p>实际点击次数:<span id="span1">0</span></p>
      <p>事件触发次数:<span id="span2">0</span></p>
    </div>
  </body>

  <script>
    const div = document.getElementById('div2');
    const span1 = document.getElementById('span1');
    const span2 = document.getElementById('span2');
    let count1 = 0;
    let count2 = 0;

    const fn1 = function(e) {
      span1.innerHTML = ++count1;
    };

    const fn2 = function(e) {
      span2.innerHTML = ++count2;
    };

    div.addEventListener('mousemove', fn1);
    div.addEventListener('mousemove', fn2);
  </script>

当鼠标在div2中移动,就会触发事件,对span的值进行+1

第一版的实现

    const fn2 = function(e) {
      span2.innerHTML = ++count2;
    };
    
    // 第一版 使用时间戳,对比事件触发的时间与上一次触发的时间,是否大于设置的值
    function throttle(fn, wait) {
      let old = 0
      return function(...args) {
        const _self = this
        let now = new Date().valueOf()
        if (now - old > wait) {
          fn.apply(_self, args)
          old = now
        }
      }
    }

    const fn3 = throttle(fn2, 1000);
    div.addEventListener('mousemove', fn1);
    div.addEventListener('mousemove', fn3);

经过设置节流,明显可以看到比原函数执行的次数好了很多,同时,当鼠标进入div就立即执行了函数

第二版的实现(通过定时器)

    // 定时器版,主要利用了定时器
    // 当第一次进入时,定时器不存在,则创建定时器,
    // 当定时器存在时,则不在触发事件,等待定时器执行
    // 当定时器执行完成后,清除定时器,又回到第一步
    function throttle(fn, wait) {
      let timer
      return function(...args) {
        const _self = this
        // 当定时器不存在时,在执行定时器
        if (!timer) {
          timer = setTimeout(function() {
              // 定时器触发后清除定时器
            fn.apply(_self, args)
            clearTimeout(timer)
            timer = null
          }, wait)
        }
      }
    }

同样的,实际触发事件的次数表少了,但是该版本在一次进去时,并不会触发事件,因为定时器将其延后执行了,但是在最后离开时,因为定时器还是存在的,所以在离开之后的那一次事件还是会依旧执行

两个版本之间的区别就是在于是否需要函数立即执行和结束之后的那一次事件是否执行。

两个版本进行合并优化

    function throttle(fn, wait) {
      let old = 0;
      let timer;
      return function(...args) {
        const _self = this;
        let now = new Date().valueOf()
        // 一开始进来就要立即执行
        if (now - old > wait) {
          timer && clearTimeout(timer);
          timer = null
          old = now
          fn.apply(_self, args)
        } else if (!timer) { // 如果处于等待间隔中,在判断是否有定时器,没有则创建
          timer = setTimeout(function() {
            old = now
            clearTimeout(timer);
            timer = null;
            fn.apply(_self, args);
          }, wait);
        }
      };
    }
  </script>

就实现了,进入就触发了事件,出来后还保持最后一次事件的触发

最后完成代码

添加了leading和trailing两个参数,用于控制shis是否立即执行和是否执行最后一次,同时添加了结果返回和事件取消

  function throttle(fn, wait, leading = true, trailing = false) {
    let old = 0
    let timer, result;
    let throttle = function(...args) {
      const _self = this;
      let now = new Date().valueOf()
      // 一开始进来就要立即执行
      if (old === 0 && !leading) {
        old = now;
      }
      if (now - old > wait) {
        timer && clearTimeout(timer);
        timer = null
        old = now
        result = fn.apply(_self, args)

      } else if (trailing && !timer) { // 如果处于等待间隔中,在判断是否有定时器,没有则创建
        timer = setTimeout(function() {
          old = now
          clearTimeout(timer);
          timer = null;
          fn.apply(_self, args);
        }, wait);
      }
      return result
    };
    throttle.cancel = function() {
      clearTimeout(timer);
      timer = null;
    };
    return throttle
  }