# 1.前言

在一次面试中被问到:“谈一谈 js 中函数的防抖和节流。”,当时菜鸡如我的内心:

只能弱弱的说一句没怎么了解过。后来找到工作后就将这件事抛在脑后,也没在深究。

就在前几天维护公司内部代码的时候,发现这样一个场景:当用户在创建东西时,会把用户输入的名字发往服务端校验是否重名,而当时的代码是监听了input输入框的onchange事件,只要用户一输入字符,就立即发出请求校验,这能忍?如果名字有 100 个字符发 100 次请求?用户没输完你校验个毛线啊!

不能忍!优化!必须优化!首先想到的优化思路是:当用户输完后我再发请求校验,但是我又不知道用户什么时候输完。那么可以这样,用户一直在输入时,我不请求,当用户停止输入 3 秒后我就认为此时用户已经输入完成,这时候再发请求校验,这样即可大大的降低请求次数,提高性能。

就在我沾沾自喜的拿着优化方案给 Leader 看的时候,Leader 听完淡淡的说了一句:函数防抖和节流了解一下。

此时回过神来,原来这就是防抖啊。

# 2.概念

函数防抖和节流,都是控制事件触发频率的方法,通常用户优化性能。

# 2.1 函数防抖(debounce)最后一个人说了算

函数防抖,就是指触发事件后在 n秒内函数只能执行一次,如果在n 秒内又触发了事件,则会重新计算函数执行时间。

函数防抖,这里的抖动就是执行的意思,而一般的抖动都是持续的,多次的。假设函数持续多次执行,我们希望让它冷静下来再执行。也就是当持续触发事件的时候,函数是完全不执行的,等最后一次触发结束的一段时间之后,再去执行。

防抖的中心思想在于:我会等你到底。在某段时间内,不管你触发了多少次回调,我都只认最后一次。

简单的说,当一个动作连续触发,则只执行最后一次。

常见应用场景:

连续的事件,只需触发一次回调的场景有:

  • 搜索框搜索输入。只需用户最后一次输入完,再发送请求
  • 手机号、邮箱验证输入检测
  • 窗口大小 Resize。只需窗口调整完成后,计算窗口大小。防止重复渲染。

# 2.2 函数节流(throttle) 第一个人说了算

函数节流,就是限制一个函数在一定时间内只能执行一次。

节流的意思是让函数有节制地执行,而不是毫无节制的触发一次就执行一次。什么叫有节制呢?就是在一段时间内,只执行一次。

节流中心思想在于:在某段时间内,不管你触发了多少次回调,我都只认第一次,并在计时结束时给予响应。

常见应用场景:

间隔一段时间执行一次回调的场景有:

  • 滚动加载,加载更多或滚到底部监听
  • 谷歌搜索框,搜索联想功能
  • 高频点击提交,表单重复提交

# 2.3 直观理解

为了方便理解,我们首先通过画图感受一下三种环境(正常情况、函数防抖情况 debounce、函数节流 throttle)下,对于mousemove事件回调的执行情况。

竖线的疏密代表事件执行的频繁程度。可以看到,正常情况下,竖线非常密集,函数执行的很频繁。而debounce(函数防抖)则很稀疏,只有当鼠标停止移动时才会执行一次。throttle(函数节流)分布的较为均已,每过一段时间就会执行一次。

# 3.代码实现

为了说明问题,假设一个场景:鼠标滑过一个 div,触发 onmousemove 事件,它内部的文字会显示当前鼠标的坐标。

<style>
  ##box {
    width: 1000px;
    height: 500px;
    background: ##ccc;
    font-size: 40px;
    text-align: center;
    line-height: 500px;
  }
</style>

<div id="box"></div>

<script>
  const box = document.getElementById("box");
  box.onmousemove = function (e) {
    box.innerHTML = `${e.clientX}, ${e.clientY}`;
  };
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

效果如下:

# 3.1 函数防抖(debounce)

我们想要这样的效果:当鼠标持续移动时,不显示鼠标坐标,当鼠标停止移动 1 秒后再显示鼠标坐标。

分解一下需求:

  • 持续触发不执行
  • 不触发的一段时间之后再执行

那么怎么实现上述的目标呢?我们先看这一点:在不触发的一段时间之后再执行,那就需要个定时器呀,定时器里面调用我们要执行的函数,将 arguments 传入。

封装一个函数,让持续触发的事件监听是我们封装的这个函数,将目标函数作为回调(func)传进去,等待一段时间过后执行目标函数。

function debounce(func, delay) {
  return function () {
    setTimeout(() => {
      func.apply(this, arguments);
    }, delay);
  };
}
1
2
3
4
5
6
7

第二点实现了,再看第一点:持续触发不执行。我们先思考一下,是什么让我们的函数执行了呢?是上边的 setTimeout。OK,那现在的问题就变成了持续触发,不能有 setTimeout。这样直接在事件持续触发的时候,清掉定时器就好了。

// func是我们需要包装的事件回调, delay是每次推迟执行的等待时间
function debounce(func, delay) {
  // 定时器
  let timeout = null;
  return function() {
    // 每次事件被触发时,都去清除之前的旧定时器,旧定时器的回调就不会执行。
    if(timer) {
        clearTimeout(timeout)
    }
    timeout = setTimeout(() => {
      func.apply(this, arguments)
    }, delay)
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

用法:

box.onmousemove = debounce(function (e) {
  box.innerHTML = `${e.clientX}, ${e.clientY}`;
}, 1000);
1
2
3

效果:

说明:

这里debounce函数执行的结果是其内部 return 的 function 的调用。也就是说鼠标经过的事件监听实际上是这个被 return 的 function,不断持续触发的是它,而debounce函数内部用闭包声明了一个 timeout 的定时器,由于闭包的存在,timeout 会被挂载在 window 对象上,每次鼠标经过,都会先清除掉上次声明的 timeout,直到最后一次鼠标经过,而它的 timeout 没有被清除,所以最后一次的定时器才会执行。

# 3.2 函数节流(throttle)

我们想要这样的效果:当鼠标持续移动时,不显示鼠标坐标,每隔一定的时间再显示鼠标坐标。

同样,我们再分解一下需求:

  • 持续触发并不会执行多次
  • 到一定时间再去执行

持续触发,并不会执行,但是到时间了就会执行。抓取一个关键的点:就是执行的时机。要做到控制执行的时机,我们可以通过一个开关,与定时器 setTimeout 结合完成。

函数执行的前提条件是开关打开,持续触发时,持续关闭开关,等到 setTimeout 到时间了,再把开关打开,函数就会执行了。

function throttle(func, delay) {
  let run = true;
  return function () {
    if (!run) {
      return; // 如果开关关闭了,那就直接不执行下边的代码
    }
    run = false; // 持续触发的话,run一直是false,就会停在上边的判断那里
    setTimeout(() => {
      func.apply(this, arguments);
      run = true; // 定时器到时间之后,会把开关打开,我们的函数就会被执行
    }, delay);
  };
}
1
2
3
4
5
6
7
8
9
10
11
12
13

用法:

box.onmousemove = throttle(function (e) {
  box.innerHTML = `${e.clientX}, ${e.clientY}`;
}, 1000);
1
2
3

效果:

# 4.总结

防抖和节流巧妙地用了 setTimeout,来控制函数执行的时机,优点很明显,可以节约性能,不至于多次触发复杂的业务逻辑而造成页面卡顿。

函数防抖,在一段连续操作结束后,处理回调,利用 clearTimeout 和 setTimeout 实现。函数节流,在一段连续操作中,每一段时间只执行一次,频率较高的事件中使用来提高性能。

函数防抖关注一定时间连续触发,只在最后执行一次,而函数节流侧重于一段时间内只执行一次。