阅读 1800

节流与防抖

Debounce 和 throttle 是我们在 JavaScript 中使用的两个概念,用于增强对函数执行的控制,这在事件处理程序中特别有用。这两种技术都回答了同一个问题“一段时间内某个函数的调用频率是多少?”

📚 相关链接

文中内容多数来自以下文章,侵删!

📚 Debounce

1. 概念

  • 本是机械开关的“去弹跳”概念,弹簧开关按下后,由于簧片的作用,接触点会连续接触断开好多次,如果每次接触都通电对用电器不好,所以就要控制按下到稳定的这段时间不通电

  • 前端开发中则是一些频繁的事件触发

    • 鼠标(mousemove...)键盘(keydown...)事件等
    • 表单的实时校验(频繁发送验证请求)
  • 在 debounce 函数没有再被调用的情况下经过 delay 毫秒后才执行回调函数,例如

    • mousemove事件中,确保多次触发只调用一次监听函数
    • 在表单校验的时候,不加防抖,依次输入user,就会分成uususe,user四次发出请求;而添加防抖,设置好时间,可以实现完整输入user才发出校验请求

2. 思路

  • 由 debounce 的功能可知防抖函数至少接收两个参数(流行类库中都是 3 个参数)

    • 回调函数fn
    • 延时时间delay
  • debounce 函数返回一个闭包,闭包被频繁的调用

    • debounce 函数只调用一次,之后调用的都是它返回的闭包函数
    • 在闭包内部限制了回调函数fn的执行,强制只有连续操作停止后执行一次
  • 使用闭包是为了使指向定时器的变量不被gc回收

    • 实现在延时时间delay内的连续触发都不执行回调函数fn,使用的是在闭包内设置定时器setTimeOut
    • 频繁调用这个闭包,在每次调用时都要将上次调用的定时器清除
    • 被闭包保存的变量就是指向上一次设置的定时器

3. 实现

  • 符合原理的简单实现

    function debounce(fn, delay) {
      var timer;
      return function() {
        // 清除上一次调用时设置的定时器
        // 计时器清零
        clearTimeout(timer);
        // 重新设置计时器
        timer = setTimeout(fn, delay);
      };
    }
    复制代码
  • 简单实现的代码,可能会造成两个问题

    • this指向问题。debounce 函数在定时器中调用回调函数fn,所以fn执行的时候this指向全局对象(浏览器中window),需要在外层用变量将this保存下来,使用apply进行显式绑定

      function debounce(fn, delay) {
        var timer;
        return function() {
          // 保存调用时的this
          var context = this;
          clearTimeout(timer);
          timer = setTimeout(function() {
            // 修正 this 的指向
            fn.apply(this);
          }, delay);
        };
      }
      复制代码
    • event对象。JavaScript 的事件处理函数中会提供事件对象event,在闭包中调用时需要将这个事件对象传入

      function debounce(fn, delay) {
        var timer;
        return function() {
          // 保存调用时的this
          var context = this;
          // 保存参数
          var args = arguments;
          clearTimeout(timer);
          timer = setTimeout(function() {
            console.log(context);
            // 修正this,并传入参数
            fn.apply(context, args);
          }, delay);
        };
      }
      复制代码

4. 完善(underscore的实现)

  • 立刻执行。增加第三个参数,两种情况

    • 先执行回调函数fn,等到停止触发后的delay毫秒,才可以再次触发(先执行
    • 连续的调用 debounce 函数不触发回调函数,停止调用经过delay毫秒后才执行回调函数(后执行
    • clearTimeout(timer)后,timer并不会变成null,而是依然指向定时器对象
    function debounce(fn, delay, immediate) {
      var timer;
      return function() {
        var context = this;
        var args = arguments;
        // 停止定时器
        if (timer) clearTimeout(timer);
        // 回调函数执行的时机
        if (immediate) {
          // 是否已经执行过
          // 执行过,则timer指向定时器对象,callNow 为 false
          // 未执行,则timer 为 null,callNow 为 true
          var callNow = !timer;
          // 设置延时
          timer = setTimeout(function() {
            timer = null;
          }, delay);
          if (callNow) fn.apply(context, args);
        } else {
          // 停止调用后delay时间才执行回调函数
          timer = setTimeout(function() {
            fn.apply(context, args);
          }, delay);
        }
      };
    }
    复制代码
  • 返回值与取消 debounce 函数

    • 回调函数可能有返回值。
      • 后执行情况可以不考虑返回值,因为在执行回调函数前的这段时间里,返回值一直是undefined
      • 先执行情况,会先得到返回值
    • 能取消 debounce 函数。一般当immediatetrue的时候,触发一次后要等待delay时间后才能再次触发,但是想要在这个时间段内想要再次触发,可以先取消掉之前的 debounce 函数
    function debounce(fn, delay, immediate) {
      var timer, result;
      var debounced = function() {
        var context = this;
        var args = arguments;
        // 停止定时器
        if (timer) clearTimeout(timer);
        // 回调函数执行的时机
        if (immediate) {
          // 是否已经执行过
          // 执行过,则timer指向定时器对象,callNow 为 false
          // 未执行,则timer 为 null,callNow 为 true
          var callNow = !timer;
          // 设置延时
          timer = setTimeout(function() {
            timer = null;
          }, delay);
          if (callNow) result = fn.apply(context, args);
        } else {
          // 停止调用后delay时间才执行回调函数
          timer = setTimeout(function() {
            fn.apply(context, args);
          }, delay);
        }
        // 返回回调函数的返回值
        return result;
      };
    
      // 取消操作
      debounced.cancel = function() {
        clearTimeout(timer);
        timer = null;
      };
    
      return debounced;
    }
    复制代码
  • ES6 写法

    function debounce(fn, delay, immediate) {
      let timer, result;
      // 这里不能使用箭头函数,不然 this 依然会指向 Windows对象
      // 使用rest参数,获取函数的多余参数
      const debounced = function(...args) {
        if (timer) clearTimeout(timer);
        if (immediate) {
          const callNow = !timer;
          timer = setTimeout(() => {
            timer = null;
          }, delay);
          if (callNow) result = fn.apply(this, args);
        } else {
          timer = setTimeout(() => {
            fn.apply(this, args);
          }, delay);
        }
        return result;
      };
    
      debounced.cancel = () => {
        clearTimeout(timer);
        timer = null;
      };
    
      return debounced;
    }
    复制代码

📚 throttle

1. 概念

  • 固定函数执行的速率

  • 如果持续触发事件,每隔一段时间,执行一次事件

    • 例如监听mousemove事件时,不管鼠标移动的速度,【节流】后的监听函数会在 wait 秒内最多执行一次,并以此【匀速】触发执行
  • windowresizescroll事件的优化等

2. 思路

  • 有两种主流实现方式

    • 使用时间戳
    • 设置定时器
  • 节流函数 throttle 调用后返回一个闭包

    • 闭包用来保存之前的时间戳或者定时器变量(因为变量被返回的函数引用,所以无法被垃圾回收机制回收
  • 时间戳方式

    • 当触发事件的时候,取出当前的时间戳,然后减去之前的时间戳(初始设置为 0)
    • 结果大于设置的时间周期,则执行函数,然后更新时间戳为当前时间戳
    • 结果小于设置的时间周期,则不执行函数
  • 定时器方式

    • 当触发事件的时候,设置一个定时器
    • 再次触发事件的时候,如果定时器存在,就不执行,知道定时器执行,然后执行函数,清空定时器
    • 设置下个定时器
  • 将两种方式结合,可以实现兼并立刻执行和停止触发后依然执行一次的效果

3. 实现

  • 时间戳实现

    function throttle(fn, wait) {
      var args;
      // 前一次执行的时间戳
      var previous = 0;
      return function() {
        // 将时间转为时间戳
        var now = +new Date();
        args = arguments;
        // 时间间隔大于延迟时间才执行
        if (now - previous > wait) {
          fn.apply(this, args);
          previous = now;
        }
      };
    }
    复制代码
    • 触发监听事件,回调函数会立刻执行(初始的previous为 0,除非设置的时间间隔大于当前时间的时间戳,否则差值肯定大于时间间隔)
    • 停止触发后,无论停止时间在哪,都不会再执行。例如,1 秒执行 1 次,在 4.2 秒停止,则第 5 秒不会再执行 1 次
  • 定时器实现

    function throttle(fn, wait) {
      var timer, context, args;
      return function() {
        context = this;
        args = arguments;
        // 如果定时器存在,则不执行
        if (!timer) {
          timer = setTimeout(function() {
            // 执行后释放定时器变量
            timer = null;
            fn.apply(context, args);
          }, wait);
        }
      };
    }
    复制代码
    • 回调函数不会立刻执行,要在 wait 秒后第一次执行,停止触发闭包后,如果停止时间在两次执行之间,则还会执行一次
  • 结合时间戳和定时器实现

    function throttle(fn, wait) {
      var timer, context, args;
      var previous = 0;
      // 延时执行函数
      var later = function() {
        previous = +new Date();
        // 执行后释放定时器变量
        timer = null;
        fn.apply(context, args);
        if (!timeout) context = args = null;
      };
      var throttled = function() {
        var now = +new Date();
        // 距离下次执行 fn 的时间
        // 如果人为修改系统时间,可能出现 now 小于 previous 情况
        // 则剩余时间可能超过时间周期 wait
        var remaining = wait - (now - previous);
        context = this;
        args = arguments;
        // 没有剩余时间 || 修改系统时间导致时间异常,则会立即执行回调函数fn
        // 初次调用时,previous为0,除非wait大于当前时间的时间戳,否则剩余时间一定小于0
        if (remaining <= 0 || remaining > wait) {
          // 如果存在延时执行定时器,将其取消掉
          if (timer) {
            clearTimeout(timer);
            timer = null;
          }
          previous = now;
          fn.apply(context, args);
          if (!timeout) context = args = null;
        } else if (!timer) {
          // 设置延时执行
          timer = setTimeout(later, remaining);
        }
      };
      return throttled;
    }
    复制代码
    • 过程中的节流功能是由时间戳的原理实现,同时实现了立刻执行
    • 定时器只是用来设置在最后退出时增加一个延时执行
    • 定时器在每次触发时都会重新计时,但是只要不停止触发,就不会去执行回调函数 fn

4. 优化完善

  • 增加第三个参数,让用户可以自己选择模式

    • 忽略开始边界上的调用,传入{ leading: false }
    • 忽略结尾边界上的调用,传入{ trailing: false }
  • 增加返回值功能

  • 增加取消功能

    function throttle(func, wait, options) {
      var context, args, result;
      var timeout = null;
      // 上次执行时间点
      var previous = 0;
      if (!options) options = {};
      // 延迟执行函数
      var later = function() {
        // 若设定了开始边界不执行选项,上次执行时间始终为0
        previous = options.leading === false ? 0 : new Date().getTime();
        timeout = null;
        // func 可能会修改 timeout 变量
        result = func.apply(context, args);
        // 定时器变量引用为空,表示最后一次执行,则要清除闭包引用的变量
        if (!timeout) context = args = null;
      };
      var throttled = function() {
        var now = new Date().getTime();
        // 首次执行时,如果设定了开始边界不执行选项,将上次执行时间设定为当前时间。
        if (!previous && options.leading === false) previous = now;
        // 延迟执行时间间隔
        var remaining = wait - (now - previous);
        context = this;
        args = arguments;
        // 延迟时间间隔remaining小于等于0,表示上次执行至此所间隔时间已经超过一个时间窗口
        // remaining 大于时间窗口 wait,表示客户端系统时间被调整过
        if (remaining <= 0 || remaining > wait) {
          if (timeout) {
            clearTimeout(timeout);
            timeout = null;
          }
          previous = now;
          result = func.apply(context, args);
          if (!timeout) context = args = null;
        } else if (!timeout && options.trailing !== false) {
          timeout = setTimeout(later, remaining);
        }
        // 返回回调函数执行后的返回值
        return result;
      };
      throttled.cancel = function() {
        clearTimeout(timeout);
        previous = 0;
        timeout = context = args = null;
      };
      return throttled;
    }
    复制代码
    • 有个问题,leading: falsetrailing: false 不能同时设置
      • 第一次开始边界不执行,但是,第一次触发时,previous为 0,则remaining值和wait相等。所以,if (!previous && options.leading === false)为真,改变了previous的值,而if (remaining <= 0 || remaining > wait)为假
      • 以后再触发就会导致if (!previous && options.leading === false)为假,而if (remaining <= 0 || remaining > wait)为真。就变成了开始边界执行。这样就和leading: false冲突了

📚 总结

  • 至此,完整实现了一个underscore中的 debounce 函数和 throttle 函数
  • lodash中 debounce 函数和 throttle 函数的实现更加复杂,封装更加彻底
  • 推荐两个可视化执行过程的工具
  • 自己实现是为了学习其中的思想,实际开发中尽量使用 lodash 或 underscore 这样的类库。

对比

  • throttle 和 debounce 是解决请求和响应速度不匹配问题的两个方案。二者的差异在于选择不同的策略

  • 电梯超时现象解释两者区别。假设电梯设定为 15 秒,不考虑容量限制

    • throttle策略:保证如果电梯第 1 个人进来后,15 秒后准时送一次,不等待。如果没有人,则待机、
    • debounce策略:如果电梯有人进来,等待 15 秒,如果又有人进来,重新计时 15 秒,直到 15 秒超时都没有人再进来,则开始运送