javascript函数的防抖(debounce)与节流(throttle)详解

日期:2021年02月22日 阅读次数:3751 分类:javascript

一、序言:

我们在平时开发的时候,会有很多场景会频繁触发事件,比如说搜索框实时发请求,onmousemove, resize, onscroll等等,有些时候,我们并不能或者不想频繁触发事件,怎么办呢?这时候就应该用到函数防抖和函数节流了!

先看一个例子
demo入口

<div id="content" style="height:150px;line-height:150px;text-align:center; color: #fff;background-color:#ccc;font-size:80px;"></div>

<script>
  let num = 1;
  let content = document.getElementById('content');

  var lastDate = new Date().getTime();

  function count() {
      content.innerHTML = num++;

      var nowDate = new Date().getTime();
      // 输出两次相隔执行时间差
      console.log(nowDate-lastDate)
      lastDate = nowDate
  };
  content.onmousemove = count;
</script>

这段代码, 在灰色区域内鼠标随便移动,就会持续触发 count() 函数,导致的效果如下:

函数防抖和函数节流

控制台输出的count()函数执行间隔时间如下:

函数防抖和函数节流

接下来我们通过防抖和节流限制频繁操作。

二、函数防抖(debounce)

短时间内多次触发同一事件,只执行最后一次(非立即执行),或者只执行最开始的一次(立即执行),中间的不执行。

【概念】在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新计时。

【生活中的实例】如果有人进电梯(触发事件),那电梯将在10秒钟后出发(执行事件监听器),这时如果又有人进电梯了(在10秒内再次触发该事件),我们又得等10秒再出发(重新计时)。

有两个版本:非立即执行版和立即执行版。
demo入口

2.1、非立即执行版(延迟执行)

// 非立即执行版(延迟执行)
function debounce(func, wait) {
  var timer;
  return function() {
    var context = this; // 注意 this 指向
    var args = arguments; // arguments中存着event

    if (timer) clearTimeout(timer);

    timer = setTimeout(function() {
      func.apply(this, args)
    }, wait)
  }
}

// 使用方式如下:
content.onmousemove = debounce(count,1000);

非立即执行版的意思是:触发事件后函数不会立即执行,而是在 n 秒后执行,如果在 n 秒内又触发了事件,则会重新计算函数执行时间。只到下个 n 秒内没有再触发事件,才会被执行。效果如下:
demo入口

函数防抖(非立即执行版)

经过“函数防抖”处理后,控制台输出的count()函数执行间隔时间如下:

函数防抖的执行间隔

可以看出执行间隔变大,减少了函数的执行频率,从而实现对性能的优化。

使用事件监听的方式(有时需要对事件绑定和解绑,如在vue和react中)

// 重新声名一个防抖后的函数
var handleMousemove = debounce(count,1000)

// 绑定
window.addEventListener('mousemove', handleMousemove)

// 解绑
window.removeEventListener('mousemove', handleMousemove)

2.2、立即执行版

// 立即执行版
function debounce(func, wait) {
  var timer;
  return function() {
    var context = this; // 注意 this 指向
    var args = arguments; // arguments中存着event

    if (timer) clearTimeout(timer);

    var callNow = !timer;

    timer = setTimeout(function() {
      // 设置为null即下次callNow为true,即可实现下次执行。
      timer = null;
    }, wait)

    if (callNow) func.apply(context, args);
  }
}

// 使用方式如下:
content.onmousemove = debounce(count,1000);

立即执行版的意思是触发事件后函数会立即执行,然后 n 秒内不触发事件才能继续执行函数的效果。用法同上,效果如下:

demo入口

函数防抖(立即执行版)

2.3、合成版

// 合成版
/**
 * @desc 函数防抖
 * @param func 目标函数
 * @param wait 延迟执行毫秒数
 * @param immediate true - 立即执行, false - 延迟执行
 */

function debounce(func, wait, immediate) {
  var timer;
  return function() {
    var context = this; // 注意 this 指向
    var args = arguments; // arguments中存着event

    if (timer) clearTimeout(timer);
    if (immediate) {
      var callNow = !timer;
      timer = setTimeout(function() {
        // 设置为null即下次callNow为true,即可实现下次执行。
        timer = null;
      }, wait);
      if (callNow) func.apply(context, args);
    } else {
      timer  = setTimeout(function() {
        func.apply(context, args);
      }, wait)
    }
  }
}

三、函数节流(throttle)

连续触发事件但是在n秒中只执行一次函数。即 2n 秒内执行 2 次… 。节流如字面意思,会稀释函数的执行频率。

【概念】规定一个单位时间,在这个单位时间内,只能有一次触发事件的回调函数执行,如果在同一个单位时间内某事件被触发多次,只有一次能生效。

【生活中的实例】一种说法是当 1 秒内连续播放 24 张以上的图片时,在人眼的视觉中就会形成一个连贯的动画,所以在电影的播放(以前是,现在不知道)中基本是以每秒 24 张的速度播放的,为什么不 100 张或更多是因为 24 张就可以满足人类视觉需求的时候,100 张就会显得很浪费资源。

同样有两个版本,立即执行版(时间戳版)和延迟执行版(定时器版)。
demo入口

3.1、立即执行版(时间戳版)

// 立即执行版(时间戳版)
function throttle(func, wait) {
  var previous = 0;
  return function() {
    var now = Date.now();
    var context = this; // 注意 this 指向
    var args = arguments; // arguments中存着event
    if (now - previous > wait) {
      func.apply(context, args);
      previous = now;
    }
  }
}

// 使用方式如下:
content.onmousemove = throttle(count,1000);

效果如下:
demo入口

函数节流-立即执行版(时间戳)

可以看到,在持续触发事件的过程中,函数会立即执行,并且每 1s 执行一次。

经过“函数防抖”处理后,控制台输出的count()函数执行间隔时间如下:

函数节流后函数执行频率

可以看出执行频率约等于1000ms,但又都大于1000ms。从而实现指定频率并节流的效果。

3.2、延迟执行版(定时器版)

// 延迟执行版(定时器版)
function throttle(func, wait) {
  var timer;
  return function() {
    var context = this; // 注意 this 指向
    var args = arguments; // arguments中存着event
    if (!timer) {
      timer = setTimeout(() => {
        timer = null;
        func.apply(context, args)
      }, wait)
    }
  }
}

// 使用方式如下:
content.onmousemove = throttle(count,1000);

用法同上,效果如下:
demo入口

函数节流-延迟执行(定时器版)

可以看到,在持续触发事件的过程中,函数不会立即执行,并且每 1s 执行一次,在停止触发事件后,函数还会再执行一次。

我们应该可以很容易的发现,其实时间戳版和定时器版的节流函数的区别就是,时间戳版的函数触发是在时间段内“开始”的时候,而定时器版的函数触发是在时间段内“结束”的时候。

同样地,我们也可以将时间戳版和定时器版的节流函数结合起来,实现合成版的节流函数。

3.3、合成版

// 合成版
/**
 * @desc 函数节流
 * @param func 函数
 * @param wait 延迟执行毫秒数
 * @param immediate true - 立即执行(时间戳版), false - 延迟执行(定时器版)
 */
function throttle(func, wait, immediate) {
  if (immediate) {
    var previous = 0;
  } else {
    var timer;
  }
  return function() {
    var context = this; // 注意 this 指向
    var args = arguments; // arguments中存着event
    if (immediate) {
      var now = Date.now();

      if (now - previous > wait) {
        func.apply(context, args);
        previous = now;
      }
    } else {
      if (!timer) {
        timer = setTimeout(() => {
          timer = null;
          func.apply(context, args)
        }, wait)
      }
    }
  }
}

四、函数防抖和函数节流各自应用的场景

4.1、对于函数防抖,有以下几种应用场景:

  1. 对于输入框连续输入进行AJAX验证时,用函数防抖能有效减少请求次数。
  2. 判断scroll是否滑到底部。
  3. 给按钮加函数防抖防止表单多次提交。

总的来说,适合多次事件一次响应的情况

4.2、对于函数节流,有如下几个场景:

  1. DOM元素拖拽
  2. Canvas画笔功能
  3. 游戏中的刷新率
  4. onmousemove, resize, onscroll等事件

总的来说,适合大量事件按时间做平均分配触发。

五、附录:

5.1、关于节流/防抖函数中“this的指向”和“arguments”解析:

我们来分析一下以下代码

function throttle(func, wait) {
  var timer;
  return function() {
    var context = this; // 注意 this 指向
    var args = arguments; // arguments中存着event
    if (!timer) {
      timer = setTimeout(() => {
        timer = null;
        func.apply(context, args)
      }, wait)
    }
  }
}

// 使用方式如下:
content.onmousemove = throttle(count,1000);

  首先,在执行throttle(count, 1000)这行代码的时候,会有一个返回值,这个返回值是一个新的匿名函数,因此content.onmousemove = throttle(count,1000)这句话最终可以这样理解:

content.onmousemove = function() {
  var context = this; // 注意 this 指向
  var args = arguments; // arguments中存着event

  // var args = Array.prototype.slice.call(arguments, 1); // 这样可以去掉arguments中的event

  console.log('this', this); // 输出contentDOM元素
  console.log('arguments', arguments); // 输出带有event的数组

  if (!timer) {
    timer = setTimeout(() => {
      timer = null;
      func.apply(context, args)
    }, wait)
  }
}

  到这边为止,只是绑定了事件函数,还没有真正执行,而this的具体指向需要到真正运行时才能够确定下来。

  其次,当我们触发onmousemove事件的时候,才真正执行了上述的匿名函数,即content.onmousemove()。此时,上述的匿名函数的执行是通过对象.函数名()来完成的,那么函数内部的this自然指向对象(content)

  最后,匿名函数内部的func的调用方式如果是最普通的直接执行func(),那么func内部的this必然指向window,这将会是一个隐藏bug!所以,我们通过匿名函数捕获this,然后通过func.apply()的方式修改this指向。

5.2、函数节流的最佳时间间隔

以上的例子我们的时间间隔都是使用的1000ms(1秒),但是实际在DOM元素拖拽或页面滚动时,1秒就太慢了,很影响用户体验。但是如果时间间隔太短又失去了节流的意义。那时间间隔我们应该设置多少更合适呢?

下面我们再看一个页面滚动事件的例子。

<script>
var num = 1;
var content = document.getElementById('content');
var lastDate = new Date().getTime();

function count() {
  content.innerHTML = num++;

  var nowDate = new Date().getTime();
  // 输出两次相隔执行时间差
  console.log(nowDate-lastDate)
  lastDate = nowDate
};


window.onscroll = throttle(count, 10);

我们把节流时间间隔设置为10ms,按道理输出的时间间隔最小的应该有10的。但是实现输出基本上都是不小于16。
demo入口

页面滚动执行间隔-60Hz时

而鼠标移动事件的触发时间间隔却很小。(下图为上面例子中的鼠标移动事件未做防抖和节流的输出结果)

函数防抖和函数节流

为什么会这样子呢?

在说原因之前,我们先说一下另一个东西——显示器刷新频率。一般显示器的刷新频率是60Hz,我的显示器的刷新频率也是60Hz。这个60Hz的意思是1秒内显示器画面刷新的频率。也就可以计算出60Hz的显示器每次刷新的间隔是1000/60(约等于16)。

如果我们把显示器的刷新频率改掉,如改成48Hz。每次刷新间隔则是1000/48(约等于20)。

修改显示器的刷新频率

输出结果如下图:

页面滚动执行间隔-48Hz时

所以滚动事件时,函数执行的间隔最小值取绝于显示器的刷新频率。大部分显示器的刷新频率都是60Hz,所以一般最小时间间隔为16ms。有一些玩游戏的朋友显示器可能会要求高一些,刷新频率也就高一些。

所以如果是鼠标移动事件,触发时间间隔即使小于16ms,但是用户看到的最快也只会是16ms。这样子就会造成部分计算的浪费。所以函数节流的最值时间间隔应该设置为16ms。如果是执行的计算量过大,明显影响性能,也可以适当调大。具体以不影响用户体验为标准,建议不要大于30ms。

源代码事例

  1. javascript函数防抖(debounce)源代码事例
  2. javascript函数节流(throttle)源代码事例
  3. 函数节流最佳时间间隔测试

扩展阅读

  1. js 函数的防抖(debounce)与节流(throttle)
  2. 详解JS函数柯里化
  3. JS函数防抖和函数节流
文章标签: