「重温JS」防抖与节流分析

352 阅读8分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情

防抖

前端开发中,面对一些高频操作的场景,比如:

  1. window 的 resize、scroll
  2. mousedown、mousemove
  3. keyup、keydown

从开始操作到结束就会产生大量的计算,从而带来不必要的开销

又或者请求接口的操作,每点击一次就会触发一次ajax请求,对性能来说速第一种浪费,甚至会带来一些意象不到的bug。

为了解决这个问题,一般有两种实现:

  1. debounce 防抖
  2. throttle 节流

本节讲解防抖函数的实现

原理:

防抖的原理是:一段时间内多次触发事件,只会执行一次,执行第一次或最后一次

只执行最后一次

根据上述描述,先实现一个简单版本

function debounce(fn, wait){
  let timer
  wait = wait || 300
  return function (){
      timer && timer = null
      timer = setTimeout(fn, wait)
  }
}

代码中使用

<body>  
      <p id="p">1</p>
      <button id="btn">按钮</button>
</body>
  <script>
    let count = 1
    const p = document.querySelector('#p')
    const btn = document.querySelector('#btn')
    function add(){
        p.innerHTML  = count++
    }
    btn.addEventListener('click', debounce(add, 300))
  </script>

此时,300ms内,不管点击多少次按钮,永远只会触发一次

this绑定

如果我们在add函数中console.log(this), 在不使用debounce函数的情况下,this会指向触发事件的按钮👇🏻

<button id="btn">按钮</button>

但是使用了debounce函数后,this会指向全局对象window。

因此我们需要将this绑定找回来

修改代码如下:

function debounce(fn, wait){
  let timer
  wait = wait || 300
  return function (){
    let context = this
    timer && timer = null
   timer = setTimeout(function(){
        fu.apply(context)
    }, wait);
  }
}

此时,this就会指向触发事件的对象

event对象

JavaScript 在事件处理函数中会提供事件对象 event,在使用了debounce函数之后,我们打印event对象会变成undefined, 因为我们在debounce函数中没有将 event对象传递下去,导致找不到了

再来修改下代码

function debounce(fn, wait){
  let timer
  wait = wait || 300
  return function (){
    const context = this
    const args = arguments 
    timer && timer = null
   timer = setTimeout(function(){
        fu.apply(context,args)
    }, wait);
  }
}

至此, 我们为debounce返回的函数绑定了this, 同时找回了event对象, 但是仍存在一个问题,如果我们的事件处理函数fn传递了参数,才如何执行呢?

fn传递参数

向下面这样👇🏻

<body>  
      <p id="p">1</p>
      <button id="btn">按钮</button>
</body>
  <script>
    let count = 1
    const p = document.querySelector('#p')
    const btn = document.querySelector('#btn')
    function add(payload){
        p.innerHTML  = count + payload
    }
    btn.addEventListener('click', debounce(add(12), 300))
  </script>

直接这样写,会得到报错

debounce_.js:36 Uncaught TypeError: Cannot read properties of undefined (reading 'apply')
    at HTMLButtonElement.proxy

大意是,用undefined调用了apply。

我们可以看到, add(12)是函数的返回结果, 而add函数并没有返回任何值,所以是undefined, 因此,修改一下调用方式,让add函数返回一个函数就好了

function add(payload){
  return function (){
    console.log(this)
    //这里是闭包,可以访问到调用add(12)时传入的参数
    console.log(payload)
    // 这里含有event对象, debounce函数传来的
    console.log(arguments)
     p.innerHTML  = count + payload
  }
}

只执行第一次

我们知道,防抖函数可以只执行最后一次,也可以只执行第一次,上面实现了执行最后一次, 现在来实现一下执行第一次。

首先,给我们的debounce函数加一个参数 immediate, 表示是否立即执行

function debounce(fn, wait, immediate){
  let timer;
  wait = wait || 300
  return function(){
    let context = this
    let args = arguments

    timer &&  clearTimeout(timer)
    //需要立即执行的
    if(immediate){
      //如果已经执行过,不再执行
      let callNow = !timer
      //如果timer一直有值,callNow就是false,就不会执行fn
      timer = setTimeout(function(){
        timer = null
      }, wait)
      if(callNow) fn.apply(context, args)
    }else{    //不需要立即执行,就按最后一次执行来处理
      timer = setTimeout(function(){
        fn.apply(context, args)
      }, wait)
    }
  }
}

取消防抖

最后又有一个小需求, 我希望能取消 debounce 函数,比如说我 debounce 的时间间隔是 10 秒钟,immediate 为 true,这样的话,我只有等 10 秒后才能重新触发事件,现在我希望有一个按钮,点击后,取消防抖,这样我再去触发,就可以又立刻执行啦,思考一下这个需求如何实现呢?

其实我们的防抖函数都是基于timer是否有值来实现的,如果timer有值,就不去触发。那么我们的取消函数,直接将timer置位null,fn就又可以跑起来了,最后改造一下代码

代码如下:

function debounce1(fn, wait, immediate){
  let timer;
  wait = wait || 300
  function proxy (){
    let context = this
    let args = arguments

    timer &&  clearTimeout(timer)
    if(immediate){
      let callNow = !timer
      timer = setTimeout(function(){
        timer = null
      }, wait)
      if(callNow) fn.apply(context, args)
    }else{
      timer = setTimeout(function(){
        fn.apply(context, args)
      }, wait)
    }
  }
  proxy.cancel = function(){
    clearTimeout(timer)
    timer = null
  }
  return proxy
}

使用:

<body>  
      <p id="p">1</p>
      <button id="btn">按钮</button>
      <button id="btn_cancel">取消防抖</button>
</body>
  <script>
    let count = 1
    const p = document.querySelector('#p')
    const btn = document.querySelector('#btn')
    const cancel = document.querySelector('#btn_cancel')
    function add(payload){
      return function (){
        console.log(this)
        console.log(payload)
        console.log(arguments)
        p.innerHTML  = count++
      }
    }
    let clickHandler = debounce1(add(12), 300,true)
    btn.addEventListener('click', clickHandler)
    cancel.addEventListener('click', () => {
      clickHandler.cancel()
    })
  </script>

节流

节流也是为限制频繁触发事件而产生的,节流意味着尽管持续触发事件,但是会根据我们设定的wait,实现每隔一段时间,执行一次操作

原理:

节流同样有两种实现效果:

  • 首次是否执行
  • 结束后是否执行最后一次

示例:

比如说我们监听body的scroll事件

document.addEventListener('scroll',function(){
          console.log('scrolll')
        })

函数执行情况:

正常滚动.gif

首次执行

要让事件首次触发就会执行,我们会使用时间戳,具体步骤如下:

  • 触发事件时,取出当前的时间戳,减去之前的时间戳(一开始为0)
  • 如果差值 大于 设置的时间周期,就执行函数,同时更新时间戳
  • 如果 小于,就不执行

上代码:

function throttle(fn, wait){
  wait = wait || 100
  let context, args,
      prev = 0

  return function(){
    let now = new Date().valueOf(),
    context = this,
    args = arguments
    if(now - prev > wait){
      console.log(now, 'now')
      fn.apply(context, args)
      prev = now
    }
  }
}

使用:

document.addEventListener('scroll',throttle(function(){
          console.log('scrolll')
        },1000))

此时函数执行情况:

节流之后.gif

我们设置的时间间隔为1S,可以看到此时函数执行的频率被大幅降低。

由于初始开始值为0,所以开始触发事件的时候,事件立即执行,并保持每1S执行一次的频率,如果我们在4.9S时停止触发,便不会有第五次触发

结束后仍执行最后一次

使用定时器

  • 触发事件时,设置一个定时器
  • 再次触发时,如果定时器存在就不执行
  • 直到定时器执行,然后执行函数,并清空定时器
  • 设置下一个定时器

代码如下:

function throttle(fn, wait){
  wait = wait || 100
  let timer
  
  return function (){
    let context = this,
        args = arguments
    if(!timer){
      timer = setTimeout(function(){
        timer = null
        fn.apply(context, args)
      }, wait)
    }
  }
}

解析:

同样是上一个例子中的监听scroll事件,假设我们设置的时间间隔为1S

使用定时器的执行逻辑是:

  • 每过1S就向任务队列中放入一个setTimeout
  • 首次触发,timer为undefined, 向任务队列放入setTimeout
  • 1S 之内多次触发,timer不为空,什么都不会做
  • 1S过后,setTimeout开始执行,将timer置为null,同时执行函数
  • 下次进来,再次向任务队列放入setTimeout
  • 此时,如果我们4.5S 后停止滚动,timer为null,依旧会向任务队列中放入SetTimeout,会执行最后一次

同时执行第一次与最后一次

第一次会立即执行,同时会触发最后一次

function throttle (fn,wait){
  let context, args, timer,
      prev = 0

  function later(){
    prev = new Date().valueOf()
    timer = null
    fn.apply(context, args)
  }
  const throttled = function (){
    context = this,
    args = arguments
    let now = new Date().valueOf()
    //下次触发fn剩余的时间
    let remaining = wait - (now - prev)
    // 1、 第一次触发肯定走进这里
    //该执行了 || 用户修改了系统时间
    if(remaining <= 0 || remaining > wait){
      //如果还可以执行立即触发的动作,就清掉定时器
      if(timer){    
        clearTimeout(timer)
        timer = null
      }
      prev = now   //更新prev
      fn.apply(context, args)
    }else if(!timer){
      //不满足下一次触发时间,任务队列放入setTimeout,保证触发最后一次
      timer = setTimeout(later, remaining)
    }
  }
  return throttled
}

假设我们设定 Wait为 1000ms,那么该函数执行过程会是这样:

  1. 0s , 走进if(remaining <= 0 || remaining > wait),立即执行一次,更新prev
  2. 时间间隔在(0,1), 没到执行时间,向任务队列放入setTimeout,假使后续不再触发,保证最后一次执行
  3. 1s, 走进if(remaining <= 0 || remaining > wait),立即执行一次,更新prev,同时有上次保存的定时器,为避免重复触发,清掉上次的定时器
  4. 循环执行1-3步,直到不再触发

\

可配置执行第一次或最后一次

为我们的函数添加一个参数options,其中包含两个参数 leadingtrailing

  • leading: false 表示禁用第一次触发
  • trailing: fasle 表示禁用最后一次触发的回调

改造代码如下:

function throttle3(fn, wait, options) {
  let timer, context, args
  let prev = 0;
  if (!options) options = {};

  //只有触发最后一次回调时才会执行later
  let later = function() {
      prev = options.leading === false ? 0 : new Date().valueOf();
      timer = null;
      fn.apply(context, args);
      if (!timer) context = args = null;
  };

  let throttled = function() {
      let now = new Date().valueOf();
      //不需要立即执行,只有第一次进来prev为0, !prev才成立。所以第一次进来就不会出现 remaining <= 0的情况
      if (!prev && options.leading === false) prev = now;
      let remaining = wait - (now - prev);
      context = this;
      args = arguments;
      if (remaining <= 0 || remaining > wait) {
          if (timer) {
              cleartimer(timer);
              timer = null;
          }
          prev = now;
          fn.apply(context, args);
          if (!timer) context = args = null;
      } else if (!timer && options.trailing !== false) {    //trailing:false表示不需要结束后的回调,因此不用向任务队列添加setTimeout
          timer = settimer(later, remaining);
      }
  };
  return throttled;
}

注意:

无法同时设置两个属性都为false

如果同时设置的话,比如当你开始滚动屏幕的时候,因为 trailing 设置为 false,停止触发的时候不会设置定时器,所以只要再过了设置的时间,再开始滚动的话,就会立刻执行,就违反了 leading: false,bug 就出来了,所以,这个 throttle 只有三种用法:

//同时执行第一次与最后一次回调
document.addEventListener('scroll',throttle(function(){
          console.log('scrolll')
 },3000))
//不执行第一次
document.addEventListener('scroll',throttle(function(){
          console.log('scrolll')
 },3000, {leading:false}))
//不执行最后一次回调
document.addEventListener('scroll',throttle(function(){
          console.log('scrolll')
 },3000, {trailing:false}))

总结:

总结下个人理解的防抖与节流的异同之处:

  • 相同:
    • 都可以用于处理需要频繁触发的事件
  • 不同:
    • 防抖:虽然像每n秒触发一次,但是n秒内多次触发的话,执行时间会向后推,最终结果是,停止触发后的n秒执行处理函数(对于第一次触发的情况,可以说是,n秒后才可以再次执行)
    • 节流:持续触发的时候,会记录本次触发的时间戳与上次执行的时间戳,保证每n秒执行一次处理函数

参考文章:

跟冴羽大佬学JS