节流与防抖

本文可以配合本人录制的视频一起食用

作用

节流和防抖是前端开发中常用的优化技术,主要用于优化一些高频触发的事件。

字面理解

节流与防抖,先从字面上理解一下,节流就是节制流入或流出,在前端方面我个人理解一下,指的是节制功能或请求的触发次数,所以节流函数字面上的意思就是防止功能或请求被频繁触发的函数;防抖呢,更好理解,防止抖动,它的字面意思更贴近前端的需求,就是防止页面抖动,以达到更好的用户体验。

适用场景

从字面上的理解可以联想到分别适合这两个功能的场景

先看节流,比如我们打开搜索引擎页面,百度或者Google,当我们在搜索框输入内容,会出现自动补全的下拉框,下拉框里的数据是请求接口获取的,如果不加以限制,就会在频繁输入的时候发送出大量请求,所以节流就可以应用在这类场景中。

再看防抖,当我们在快速上下滚动页面的过程中,如果页面滚动行为绑定了事件监听器,就可能频繁触发回调导致大量的计算从而引发页面的抖动甚至卡顿,防抖函数就可以应用在这类场景中。

所以总体来说,节流和防抖都是用于控制事件触发的频率,只是控制的点不同

防抖更适合于反馈较快的场景,就是说用户操作之后很快就会有反馈,我们不希望反馈太快,并且不希望频繁操作导致要去处理太多的反馈(合并处理);而节流更适合耗时较久的场景,就仿佛某个人在说省点流量吧,我不是没反馈,只是需要多点时间来处理,不要频繁给我发送相同的操作指令。

实现

根据以上理解,我们可以分别来实现这两个函数。

节流

首先是节流。

节流是在某次事件触发时执行指定操作后,再次触发事件时,若两次事件的触发时间点的间隔不小于给定的时间间隔,就再次执行指定操作,否则就不执行。

function throttle(fn, interval) {
  // fn是待执行的操作,interval是给定的时长,在给定的时长内只发送一次操作指令,也就是说只执行一次fn
  // 设置一个变量用于记录
  let last = 0; // 记录上次动作的执行时间
  return function() {
    // 首先保留调用时的this上下文和传入的参数
    let context = this;
    let args = arguments;
    // 记录当前事件触发的时间点
    let now = Date.now();
    // 检查当前时间点与上次执行操作的时间点之间的间隔
    if (now - last >= interval) {
      // 如果当前时间与上次触发动作的时间间隔大于或等于interval
      // 就触发操作
      fn.apply(context, args);
      // 并且更新last为当前时间
      last = now;
    }
    // 否则就不做任何操作,即两次事件触发的时间间隔小于interval时,就不触发fn执行,保证在interval设置的时长内只执行一次fn
  }
}

我们可以在页面上测试一下

<button id="requestButton">
  点我请求
</button>
<script>
// 用throttle包装click的回调,防止频繁请求
const better_request = throttle(() => {
  console.log(Date.now());
}, 3000);
document.querySelector('#requestButton').addEventListener('click', better_request);
</script>

防抖

然后是防抖。

防抖就是在频繁触发事件后,等不再触发事件时合并执行动作。

function debounce(fn, delay) {
  // fn是待执行的操作,delay是指延迟的时长,我们希望在给定的延时之后再执行fn
  // 在防抖函数中需要设置一个定时器,用于延迟执行fn
  let timer = null;
  
  return function() {
    // 保留调用时的this上下文和传入的参数
    let context = this;
    let args = arguments;
    
    // 每次事件被触发时,都去清除之前的旧定时器
    if (timer) clearTimeout(timer);
    // 设定新定时器
    // 在给定的delay延时之后,fn才会被执行
    // 当事件首次被触发,fn会在delay毫秒后执行
    timer = setTimeout(() => {
      fn.apply(context, args);
    }, delay);
  }
}
  1. 使用防抖后,在事件触发时,fn在delay毫秒的延迟后才会执行,可以保证回调反馈不会太快
  2. 如果在delay毫秒内,比如第x毫秒时第二次事件回调被触发,此时前一个fn还未被执行,若不清理计时,第二个fn操作会在delay毫秒后被执行,这样就会导致delay毫秒内有两个fn会被触发;
    1. 第一个fn在delay-x毫秒后执行
    2. 第二个fn在delay毫秒后执行
    3. 两个fn的执行间隔理论上为x毫秒,x小于delay
  3. 所以为了保证fn不被频繁执行,我们要将前一个计时清理掉,使得delay延时内只有一个fn将被执行,相当于将多个反馈合并处理
  4. 如果delay延时内再无事件触发,则延时结束后fn就被执行

这样做看上去似乎没有问题,但实际上是存在问题的,问题就在于如果用户操作过于频繁,就会导致fn的执行被无限推迟,因为新的事件触发总会清除掉上一次的计时器,这样用户的操作需要很久才得到反馈,或者根本得不到反馈,比如用户在频繁滚动页面后,没等到fn执行就跳转其他页面了。

合并版

为了保证在给定的时间内必须执行一次fn,我们可以使用throttle来优化防抖,也可以说是两者的合并。

最终要达到的目标:

  1. 将多次事件触发的fn操作合并执行
  2. 在给定的时间间隔内一定会执行一次fn
function enhanceThrottle(fn, delay) {
  // 设置两个变量
  // last用于记录上一次fn执行的时间
  // timer用于延迟执行fn
  let last = 0, timer = null;
  
  return function() {
    // 保留回调时的this上下文和传入的参数
    let context = this;
    let args = arguments;
    // 记录当前事件触发的时间点
    let now = Date.now();
    if (now - last < delay) {
      // 如果当前时间点与上一次fn执行时间的间隔小于给定的时间间隔
      // 不执行fn操作
      // 重置定时器,在delay延时后执行fn
      // 这样执行两次fn预计的时间差就是now - last + delay,也就是说时间差会大于delay
      clearTimeout(timer);
      timer = setTimeout(() => {
        last = Date.now();
        fn.apply(context, args);
      }, delay);
    } else {
      // 当前时间点与上一次fn执行时间的间隔超出给定的时间间隔
      // 就立即执行一次fn
      fn.apply(context, args);
      // 并更新last的值
      last = now;
    }
  }
}

优化之后,在第一次触发事件时,就会立即执行一次fn。

但是这样优化之后依旧存在问题:

就是在else语句这个分支,当前时间点与上一次fn执行时间的间隔超出给定的时间间隔,就立即执行一次fn,假设此次事件的触发时间点是now2,上一次事件的触发时间点是now1,如果经过now1-last+delay这个延迟之后刚好是now2,就会在立即执行fn的同时,有个延迟的fn也要执行。

可以继续优化,在立即执行fn这个分支里,也去重置计时,clearTimeout(timer),当然实践中可能还是会有问题,比如在清理计时器之前这个延迟的fn操作已经进入任务队列了。

对比

两个初始版的节流和防抖。

看上去,节流函数就像在一段时间间隔的开始时间点执行操作,防抖函数像是在一段时间间隔内最后一次事件触发后执行操作。两者似乎是一个头一个尾,但其实上并没有很相似,节流的时间间隔是给定的,而防抖的时间间隔是不确定的,而是视用户的操作而定。

也就是说节流直接丢掉后面的操作,防抖更类似于合并了前面的操作

posted @ 2023-08-05 12:15  beckyye  阅读(206)  评论(0编辑  收藏  举报