注册
web

被问了无数次的函数防抖与函数节流,这次你应该学会了吧

前言


本篇文章内容,或许早已是烂大街的解读文章。不过参加几场面试下来发现,不少伙伴们还是似懂非懂地栽倒在(~面试官~)深意的笑容之下,权当温故知新吧。


文章从防抖、节流的原理说起再结合实际开发的场景,分别逐步实现完整的防抖和节流函数。


函数防抖



  • 原理:当持续触发一个事件时,在n秒内,事件没有再次触发,此时才会执行回调;如果n秒内,又触发了事件,就重新计时
  • 适用场景:


    • search远程搜索框:防止用户不断输入过程中,不断请求资源,n秒内只发送1次,用防抖来节约资源
    • 按钮提交场景,比如点赞,表单提交等,防止多次提交
    • 监听resize触发时, 不断的调整浏览器窗口大小会不断触发resize,使用防抖可以让其只执行一次


  • 辅助理解:在你坐电梯时,当一直有人进电梯(连续触发),电梯门不会关闭,在一定时间间隔内没有人进入(停止连续触发)才会关闭。

下面我们先实现一个简单的防抖函数,请看栗子:


// 简单防抖函数
const debounce = (fn, delay) => {
  let timer;
  return function () {
    const context = this;
    const args = arguments;
    clearTimeout(timer);

    timer = setTimeout(function () {
      fn.call(context, ...args);
      //等同于上一句 fn.apply(context, args)
    }, delay);
  };
};

// 请求接口方法
const ajax = (e) => {
  console.log(`send ajax ${new Date()} ${e.target.value}`);
};

// 绑定监听事件
const noneDebounce = document.getElementsByClassName("none_debounce")[0];
const debounceInput = document.getElementsByClassName("debounce")[0];

noneDebounce.addEventListener("keyup", ajax);
debounceInput.addEventListener("keyup"debounce(ajax, 500));

运行效果如下:图片


点击这里,试试效果点击demo


可以很清晰的看到,当我们频繁输入时, 不使用节流就会不断的发送数据请求,但是使用节流后,只有当你在指定间隔时间内没有输入,才会执行发送数据请求的函数。


上面有个注意点:



  • this指向问题,在定时器中如果使用箭头函数()=>{fn.call(this, ...args)} 与上面代码效果一样, 原因时箭头函数的this是**「继承父执行上下文里面的this」**

关于防抖函数的疑问:



  1. 为什么要使用 fn.apply(context, args), 而不是直接调用 fn(args)

如果我们不使用防抖函数debounce时, 在ajax函数中打印this的值为dom节点:


<input class="debounce" type="text">

在使用debounce函数后,如果我们不使用fn.apply(context, args)修改this的指向, this就会指向window(ES6下为undefined)



  1. 为什么要传入arguments参数

我们同样与未使用防抖函数的场景进行对比


const ajax = (e) =>{
    console.log(e)
}


  1. 怎么给ajax函数传参

有的小伙伴就说了, 你的ajax只能接受绑定事件的参数,不是我想要的,我还要传入其他参数,so easy!


const sendAjax = debounce(ajax, 1000)
sendAjax(参数1, 参数2,...)

因为sendAjax 其实就是debounce中return的函数, 所以你传入什么参数,最后都给了fn


在未使用时,调用ajax函数对打印一个KeyboardEvent对象


image.png


使用debounce函数时,如果不传入arguments, ajax中的参数打印为undefined,所以我们需要将接收到的参数,传递给fn


函数防抖的理解:




我个人的理解其实和平时上电梯的原理一样:当一直有人进电梯时(连续触发),电梯门不会关闭,在一定时间间隔内没有人进入(停止连续触发)才会关闭。




从上面的例子,对防抖有了初步的认识,但是在实际开发中,需求往往要更加的复杂,比如我们要提交一个表单按钮,为了防止用户多次提交表单,可以使用节流, 但如果使用上面的节流,就会导致用户停止连续点击才会提交,而我们希望让用户点击时,立即提交, 等到n秒后,才可以重新提交。


对上面的代码进行改造,得到立即提交版:


const debounce = (fn, delay, immediate) => {
  let timer;
  return function () {
    const context = this;
    const args = arguments;
    clearTimeout(timer);

    if (immediate) {
      let startNow = !timer;

      timer = setTimeout(function () {
        timer = null;
      }, delay);

      if (startNow) {
        fn.apply(context, args);
      }
    } else {
      timer = setTimeout(function () {
        fn.call(context, ...args);
        //等同于上一句 fn.apply(context, args)
      }, delay);
    }
  };
};

从上面的代码可以看到,通过immediate 参数判断是否是立刻执行。


timer = setTimeout(function () {
    timer = null
}, delay)

立即执行的逻辑中,如果去掉上面这小段代码, 也是立即执行,但是之后就不会再执行提交了,当我们提交失败了怎么办(哭),所以加上上面这段代码,在设定的时间间隔内,将timer设置为null, 过了设定的时间间隔,可以再次触发提交按钮的立即执行,这才是完整的。


这是一个使用立即提交版本的防抖实现的了一个提交按钮demo


目前我们已经实现了包含非立即执行立即执行功能的防抖函数,感兴趣的小伙伴可以和我一起继续探究一下去,完善防抖函数~




做直播功能时,产品的小伙伴给提出这样一个需求:


直播的小窗口可以拖动, 点击小窗口以及拖动时, 显示关闭小窗口按钮,当拖动结束2s后, 隐藏关闭按钮;当点击关闭按钮时, 关闭小窗口




分析需求, 我们可以使用防抖来实现, 用户连续拖动小窗口过程中, 不执行隐藏关闭按钮,拖动结束后2s才执行隐藏关闭按钮;但是点击关闭按钮后,我们希望可以取消防抖, 所以需要继续完善防抖函数, 使其可以被取消。


「可取消版本」


const debounce = (fn, delay, immediate) => {
  let timer, debounced;
  // 修改--
  debounced = function () {
    const context = this;
    const args = arguments;
    clearTimeout(timer);

    if (immediate) {
      let startNow = !timer;

      timer = setTimeout(function () {
        timer = null;
      }, delay);

      if (startNow) {
        fn.apply(context, args);
      }
    } else {
      timer = setTimeout(function () {
        fn.call(context, ...args);
      }, delay);
    }
  };

  // 新增--
  debounced.cancel = function () {
    clearTimeout(timer);
    timer = null;
  };
  return debounced;
};

从上面代码可以看到,修改的地方是将return的函数赋值给debounced对象, 并且给debounced扩展了一个cancel方法, 内部执行了清除定时器timer, 并且将其设置为null; 为什么要将timer设置为null呢? 由于debounce内部形成了闭包, 避免造成内存泄露


上面的需求我写了个小demo, 需要的小伙伴可以看看可取消版本demo, 效果如下所示:图片


这个demo中,在拖拽过程中还可以使用节流,减少页面重新计算位置的次数,在下边学完节流,大家不妨试试


介绍节流原理、区别以及应用


前面学习了防抖,也知道了我们为什么要使用防抖来限制事件触发频率,那我们接下来学习另一种限制的方式节流(throttle)


函数节流



  • 原理:当频繁的触发一个事件,每隔一段时间, 只会执行一次事件。
  • 适用场景:


    • 拖拽场景:固定时间内只执行一次, 防止高频率的的触发位置变动
    • 监听滚动事件:实现触底加载更多功能
    • 屏幕尺寸变化时, 页面内容随之变动,执行相应的逻辑
    • 射击游戏中的mousedown、keydown时间


  • 辅助理解:

下面我们就来实现一个简单的节流函数,由于每隔一段时间执行一次,那么就需要计算时间差,我们有两种方式来计算时间差:一种是使用时间戳,另一种是设置定时器


使用时间戳实现


function throttle(func, delay) {
  let args;
  let lastTime = 0;

  return function () {
    // 获取当前时间
    const currentTime = new Date().getTime();
    args = arguments;
    if (currentTime - lastTime > delay) {
      func.apply(this, args);
      lastTime = currentTime;
    }
  };
}

使用时间搓的方式来实现的思路比较简单,当触发事件时,获取当前时间戳,然后减去之前的时间戳(第一次设置为0), 如果差值大于设置的等待时间, 就执行函数,然后更新上一次执行时间为为当前的时间戳,如果小于设置的等待时间,就不执行。


使用定时器实现


下面我们来看使用定时器实现的方式:与时间戳实现的思路是有差别的, 我们在事件触发时设置一个定时器, 当再次触发事件时, 如果定时器存在,就不执行;等过了设置的等待时间,定时器执行,我们需要在定时器执行时,清空定时器,这样就可以设置下一个定时器了


function throttle1(fn, delay) {
  let timer;
  return function () {
    const context = this;
    let args = arguments;

    if (timer) return;
    timer = setTimeout(function () {
      console.log("hahahhah");
      fn.apply(context, args);

      clearTimeout(timer);
      timer = null;
    }, delay);
  };
}

虽然两种方式都实现了节流, 但是他们达到的效果还是有一点点差别的,第一种实现方式,事件触发时,会立即执行函数,之后每隔指定时间执行,最后一次触发事件,事件函数不一定会执行;假设你将等待时间设置为1s, 当3.2s时停止事件的触发,那么函数只会被执行3次,以后不会再执行。


第二种实现方式,事件触发时,函数不会立即执行, 需要等待指定时间后执行,最后一次事件触发会被执行;同样假设等待时间设置为1s, 在3.2秒是停止事件的触发,但是依然会在第4秒时执行事件函数


总结


对两种实现方式比较得出:



  1. 第一种方式, 事件会立即执行, 第二种方式事件会在n秒后第一次执行
  2. 第一种方式,事件停止触发后,就不会在执行事件函数, 第二种方式停止触发后仍然会再执行一次

接下来我们写一个下拉加载更多的小demo来验证上面两个节流函数:点击查看代码


let state = 0 // 0: 加载已完成  1:加载中  2:没有更多
let page = 1
let list =[{...},{...},{...}]

window.addEventListener('scroll'throttle(scrollEvent, 1000))

function scrollEvent() {
    // 当前窗口高度
    let winHeight =
        document.documentElement.clientHeight || document.body.clientHeight

    // 滚动条滚动的距离
    let scrollTop = Math.max(
        document.body.scrollTop,
        document.documentElement.scrollTop
    )

    // 当前文档高度
    let docHeight = Math.max(
        document.body.scrollHeight,
        document.documentElement.scrollHeight
    )
    console.log('执行滚动')

    if (scrollTop + winHeight >= docHeight - 50) {
        console.log('滚动到底部了!')
        if (state == 1 || state == 2) {
            return
        }
        getMoreList()
    }
}

function getMoreList() {
    state = 1
    tipText.innerHTML = '加载数据中'
    setTimeout(() => {
        renderList()
        page++

        if (page > 5) {
            state = 2
            tipText.innerHTML = '没有更多数据了'
            return
        }
        state = 0
        tipText.innerHTML = ''
    }, 2000)
}

function renderList() {
    // 渲染元素
    ...
}

使用第一种方式效果如下:图片


一开始滚动便会触发滚动事件, 但是在滚动到底部时停止, 不会打印"滚动到底部了"; 这就是由于事件停止触发后,就不会在执行事件函数


使用第二种方式, 为了看到效果,将事件设置为3s, 这样更能直观感受到事件函数是否立即执行:


// window.addEventListener('scroll', throttle(scrollEvent, 1000))
window.addEventListener('scroll'throttle1(scrollEvent, 3000))

图片


一开始滚动事件函数并不会被触发,而是等到3s后才触发;而当我们快速的滚动到底部后停止滚动事件, 最后还是会执行一次


上面的这个例子是为了辅助理解这两种实现不方式的不同。


时间戳 + 定时器实现版本


在实际开发中, 上面两种实现方案都不满足我们的需求,我们希望一开始滚动就立即执行,停止触发的时候也还能执行一次。结合时间搓方式和定时器方式实现如下:


function throttle(fn, delay) {
  let timer, context, args;
  let lastTime = 0;

  return function () {
    context = this;
    args = arguments;

    let currentTime = new Date().getTime();

    if (currentTime - lastTime > delay) {
      // 防止时间戳和定时器重复
      // -----------
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      // -----------
      fn.apply(context, args);
      lastTime = currentTime;
    }
    if (!timer) {
      timer = setTimeout(() => {
        // 更新执行时间, 防止重复执行
        // -----------
        lastTime = new Date().getTime();
        // -----------
        fn.apply(context, args);
        clearTimeout(timer);
        timer = null;
      }, delay);
    }
  };
}

使用演示效果如下:


图片


实现思路是结合两种实现方式,同时避免两种方式重复执行, 所以当调用时间戳执行函数时,需要将定时器清空;当使用到定时器执行函数时,需要增加修改执行记录的时间lastTime


我们可以看到,开始滚动立即会打印页面滚动了,停止滚动后,时间会再执行一次,滚动到底部时停止,也会执行到滚动到底部了


最终完善版


上面的节流函数满足了我们的基本需求, 但是我们可以进一步对节流函数进行优化,使得节流函数可以满足下面三种情况:



  • 事件函数立即执行,并且事件停止后再执行一次(以满足)
  • 事件函数立即执行,但是事件停止后不再执行(待探究)
  • 事件函数不立即执行,但是事件停止后再执行一次(待探究)



注意点:事件函数不立即执行,事件停止不再执行一次 这种情况不能满足,在后面从代码角度会做分析。




我们设置两个参数startlast分别控制是否立即执行与最后是否执行;修改上一版代码, 实现如下:


function throttle(fn, delay, option = {}) {
  let timer, context, args;
  let lastTime = 0;

  return function () {
    context = this;
    args = arguments;

    let currentTime = new Date().getTime();

    // 增加是否立即执行判断
    if (option.start == false && !lastTime) lastTime = currentTime;

    if (currentTime - lastTime > delay) {
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      fn.apply(context, args);
      lastTime = currentTime;
    }
    // 增加最后是否再执行一次判断
    if (!timer && option.last == true) {
      timer = setTimeout(() => {
        // 确保再次触发事件时, 仍然不立即执行
        lastTime = option.start == false ? 0 : new Date().getTime();
        fn.apply(context, args);
        clearTimeout(timer);
        timer = null;
      }, delay);
    }
  };
}

上面代码就修改了三个地方,一个是立即执行之前增加一个判断:


if (option.start == false && !lastTime) lastTime = currentTime

如果传入参数是非立即执行, 并且lastTime为0, 将当前时间戳赋值给lastTime, 这样就不会进入 if (currentTime - lastTime > delay)


第二个修改地方, 增加最后一次是否执行的判断:


// 原来
// if (!timer) {...}

// 修改后
if (!timer && option.last == true) {
   ...
}

当传入last为true时,才使用定时器计时方式, 反之通过时间戳实现逻辑即可满足


第三个修改的地方, 也是容易被忽视的点, 如果start传入false,last传入true(即不立即执行,但最后还会执行一次), 需要在执行定时器逻辑调用事件函数时, 将lastTime设置为0:


 // 确保再次触发事件时, 仍然不立即执行
lastTime = option.start ==false0 : new Date().getTime()

这里解决的是再次触发事件时, 也能保证不立即执行。


疑问点


相信有的小伙伴会存在疑问,为什么没有讨论不立即执行, 最后一次也不执行的情况呢(即 start为true, last为true), 因为这种情况满足不了。


当最后一次不执行, 也就不会进入到 定时器执行逻辑,也就无法对 lastTime重置为0,所以,当再一次触发事件时,就会立即执行,与我们的需求矛盾了。关于这一点,大家了解即可了。


到这里,我们的节流函数功能就差不多了, 如果有兴趣的小伙伴可以自己实现一下可取消功能, 与防抖函数实现方式一致, 这里就不赘述了。


作者:白哥学前端
来源:juejin.cn/post/7230419964300951613

0 个评论

要回复文章请先登录注册