用JavaScript带你一步一步实现节流

648 阅读4分钟

建议先去看我这篇博客带你一步步用Javascript简单实现和优化防抖1、什么是防抖 防抖(debounce):每次触发定时器后,取消上一个 - 掘金,再来看节流实现。

1、什么是节流

节流(throttle):每次触发定时器后,直到这个定时器结束之前无法再次触发。一般用于可预知的用户行为的优化,比如为scroll事件的回调函数添加定时器。

打个比方,相信大家都玩过王者荣耀或者英雄联盟等MOBA游戏,当我们攻速是一定的,不管我们点击普攻键有多快,发射的弹幕也是根据你的攻速来决定的。

假设攻速是一秒射一次,我们一秒钟点击了一万次普攻键(我嘞个手速大王),这个点击事件触发了一万次,但是我给你的响应只有一次。

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

1.滚动加载,加载更多或滚到底部监听

2.搜索框,搜索联想功能

函数初版

需要接受参数

  • 参数1:要执行的回调函数
  • 参数2:要执行的间隔时间
function myThrottle(fn, interval){

}
返回值

返回值为一个新的函数
function myThrottle(fn, interval){
const _throttle = function(){

}
return _throttle
}

实现逻辑: 如果要实现节流函数,利用定时器不太方便管理,可以用时间戳获取当前时间nowTime 参数开始时间 StartTime 和 等待时间waitTime,间隔时间 interval waitTIme = interval - (nowTime - startTime) 得到等待时间waitTime,对其进行判断,如果小于等于0,则可以执行回调函数fn 开始时间可以初始化为0,第一次执行时,waitTime一定是负值(因为nowTime很大),所以第一次执行节流函数,一定会立即执行

具体实现

function MyThrottle(fn, interval) {
      let startTime = 0;
      const _throttle = function () {
        const nowTime = new Data().getTime()
        const waitTime = interval - (nowTime - startTime)
        if (waitTime <= 0) {
          fn()
          startTime = nowTime
        }
      }

      return _throttle
    }

代码解释

function MyThrottle(fn, interval) {
    const nowTime = new Date().getTime();
    //...
}
  • 这里定义了一个名为 MyThrottle 的函数,它接收两个参数:

    • fn:是需要被节流控制调用频率的目标函数,也就是期望在一定时间间隔内限制其执行次数的那个函数。
    • interval:代表时间间隔,单位应该是毫秒,用于指定在多长时间内只允许 fn 执行一次。
  • 在函数内部,首先通过 new Date().getTime() 获取了当前的时间戳(从 1970 年 1 月 1 日 00:00:00 UTC 到当前时刻的毫秒数)并赋值给 nowTime,这个时间戳将用于后续和其他时间进行比较,来判断是否达到了可以再次执行目标函数的时间条件。

return _throttle;
  • MyThrottle 函数最终返回了内部定义的 _throttle 函数,这样外部调用 MyThrottle 并传入相应参数后,得到的返回值(也就是 _throttle 函数)可以被赋值给一个变量,然后通过这个变量去调用,在每次调用时都会执行内部的节流逻辑,来决定是否实际执行传入的目标函数 fn

测试

    const inputEl = document.querySelector("input")
    // 3.自己实现的节流函数
    let counter = 1
    inputEl.oninput = MyThrottle(function() {
      console.log(`发送网络请求${counter++}:`, this.value)
    }, 1000)

image.png

优化this指向和参数

相信大家知道, 打印undefined是因为this指向问题, 在手写防抖函数相信已经知道如何优化了

下面我们优化一下this指向

    function MyThrottle(fn, interval) {
      let startTime = 0
      const _throttle = function (...args) {
      const nowTime = new Data().getTime()
        const waitTime = interval - (nowTime - startTime)
        if (waitTime <= 0) {
          fn.apply(this,args)
          startTime = nowTime
        }
      }

      return _throttle
    }

测试

    const inputEl = document.querySelector("input")

    // 3.自己实现的节流函数
    let counter = 1
    inputEl.oninput = MyThrottle(function(event) {
      console.log(`发送网络请求${counter++}:`, this.value, event)
    }, 1000)

image.png

优化控制立即执行
    function MyThrottle(fn, interval,leading = true) {
      let startTime = 0
      const _throttle = function (...args) {
        // 1.获取当前时间
      const nowTime = new Data().getTime()
        // 对立即执行进行控制

        if(!leading && startTime === 0){
          startTime = nowTime
        }
        // 2.计算需要等待的时间执行函数

        const waitTime = interval - (nowTime - startTime)
        if (waitTime <= 0) {
          fn.apply(this,args)
          startTime = nowTime
        }
      }

      return _throttle
    }

测试

   const inputEl = document.querySelector("input")
    // 3.自己实现的节流函数
    let counter = 1
    inputEl.oninput = MyThrottle(function(event) {
      console.log(`发送网络请求${counter++}:`, this.value, event)
    }, 1000)

image.png

优化尾部控制(了解即可)
    function MyThrottle(fn, interval,{ leading = true, trailing = false } = {}) {
      let startTime = 0
      let timer = null

      const _throttle = function (...args) {
        // 1.获取当前时间
      const nowTime = new Data().getTime()
        // 对立即执行进行控制

        if(!leading && startTime === 0){
          startTime = nowTime
        }
        // 2.计算需要等待的时间执行函数

        const waitTime = interval - (nowTime - startTime)
        if (waitTime <= 0) {
          if(timer) clearTimeout(timer)
          fn.apply(this,args)
          startTime = nowTime
          timer = null
          return 
        }
      }


      // 判断是否进行尾部控制
      if(trailing && !timer) {
        timer = setTimeout(() => {
          fn.apply(this,args)
          startTime = new Date().getTime()
          timer = null
        },waitTime)
      }
      return _throttle
    }

代码解释

  • trailing:布尔值,默认值为 false,用于决定在一个连续触发周期的末尾,如果距离上一次执行 fn 已经过了一部分时间间隔但还未完整经过 interval 这么长的时间,是否在时间间隔结束时执行一次 fn 函数。例如在持续触发某个事件但最后停止触发时,若 trailing 为 true,会根据剩余时间判断是否再执行一次 fn
  • timer:用于存储定时器的标识(在 JavaScript 中,setTimeout 函数返回的一个定时器 ID),初始值设为 null,它主要在涉及 trailing(尾部执行控制)逻辑时发挥作用,用于判断是否已经存在等待执行的定时器以及后续取消或重置定时器等操作。
  • 判断 timer 是否存在(即是否已经设置了用于 trailing 逻辑的定时器),如果存在就通过 clearTimeout(timer) 取消这个定时器,避免重复执行或者出现定时器混乱的情况。
if (trailing &&!timer) {
    timer = setTimeout(() => {
        fn.apply(this, args);
        startTime = new Date().getTime();
        timer = null;
    }, waitTime);
}
  • 这部分代码实现了对 trailing 参数所控制的尾部执行逻辑。通过 if (trailing &&!timer) 进行判断,如果 trailing 参数为 true(即配置为允许在时间间隔末尾执行一次)且当前没有正在等待执行的定时器(timer 为 null),则会执行以下操作:

    • 创建一个定时器,使用 setTimeout 函数,它会在 waitTime 毫秒后执行传入的回调函数。这个 waitTime 就是前面计算出来的距离时间间隔结束还需要等待的时间。
    • 在定时器的回调函数中,首先使用 fn.apply(this, args) 调用目标函数 fn,同样通过 apply 方法保证 this 指向和参数传递的正确性;然后将 startTime 更新为当前的时间戳,意味着重新开始计时,准备下一轮的节流判断;最后将 timer 重置为 null,表示这个定时器已经执行完毕,清除相关标识。

注意:在尾部控制中startTime = new Date().getTime();不能写成startTime = nowTime;

1. 时间准确性与逻辑连贯性考虑
  • 不同执行阶段的时间区分nowTime 是在 _throttle 函数一开始就获取的当前时间,它主要用于和 startTime 配合来计算距离上一次执行 fn 函数后经过了多长时间,以此判断是否达到了可以再次执行 fn 的条件(通过 waitTime = interval - (nowTime - startTime) 这个计算)。而在尾部控制逻辑这里,我们所处的时间点已经是等待了一段时间(具体是 waitTime 所代表的时长)后,定时器触发执行回调函数的时刻了。如果直接用之前获取的 nowTime 赋值给 startTime,就忽略了从上次判断到定时器触发这中间经过的时间,导致时间记录不准确,无法正确开启下一轮的节流计时逻辑。
  • 保持逻辑连贯准确:在定时器回调函数中,当执行完 fn 函数后,我们希望重新设置一个准确的起始时间来开启下一轮基于时间间隔的节流判断。通过 startTime = new Date().getTime(); 获取当下这个定时器触发时刻的准确时间戳作为新的起始时间,能保证后续的节流计算(比如下一次再进入 _throttle 函数时计算 waitTime 等)是基于最新的、符合逻辑的时间起点,使得整个节流过程在时间上连贯且符合预期,不会出现时间错乱导致的函数执行频率失控问题。
2. 避免重复执行与意外情况干扰
  • 防止重复执行干扰:假设我们直接使用 nowTime(之前获取的那个时间值)来赋值给 startTime,如果在定时器触发前,又有新的触发事件导致 _throttle 函数被多次调用,nowTime 的值就一直在变化,而且它并不能反映出定时器触发这个特定时刻的状态。那么在定时器最终触发时,用这个可能已经过时且不符合当下定时器触发场景的 nowTime 来更新 startTime,很可能会扰乱后续的节流逻辑,甚至可能导致在短时间内错误地又满足了执行条件,让 fn 函数被重复执行,违背了节流的本意,即限制函数执行频率的功能就无法准确实现了。
  • 应对异步等复杂情况:在实际应用中,有可能存在异步操作或者其他复杂情况影响函数的执行顺序和时间。比如在定时器等待期间,外部可能有其他代码在运行,或者 fn 函数本身内部有异步逻辑还未执行完等情况。通过获取定时器触发时的实时时间戳来更新 startTime,能够让节流逻辑自适应这些复杂情况,确保不管之前经历了怎样的过程,每次进入新的一轮节流周期都是从一个准确、合适的时间起点开始计算,避免受到之前各种不确定因素的干扰。

测试

const inputEl = document.querySelector("input")
    // 3.自己实现的节流函数
    let counter = 1
    inputEl.oninput = MyThrottle(function(event) {
      console.log(`发送网络请求${counter++}:`, this.value, event)
    }, 3000, { trailing: true })

image.png

优化取消操作

这里与防抖的操作是一样的。

 function MyThrottle(fn, interval,{ leading = true, trailing = false } = {}) {
      let startTime = 0
      let timer = null

      const _throttle = function (...args) {
        // 1.获取当前时间
      const nowTime = new Data().getTime()
        // 对立即执行进行控制

        if(!leading && startTime === 0){
          startTime = nowTime
        }
        // 2.计算需要等待的时间执行函数

        const waitTime = interval - (nowTime - startTime)
        if (waitTime <= 0) {
          if(timer) clearTimeout(timer)
          fn.apply(this,args)
          startTime = nowTime
          timer = null
          return 
        }
      }


      // 判断是否进行尾部控制
      if(trailing && !timer) {
        timer = setTimeout(() => {
          fn.apply(this,args)
          startTime = new Date().getTime()
          timer = null
        },waitTime)
      }


      _throttle.cancel = function () {
        if(timer) clearTimeout(timer)
        startTime = 0
        timer = null
      }
      return _throttle
    }

测试

 const inputEl = document.querySelector("input")
    const cancelBtn = document.querySelector(".cancel")
    // 3.自己实现的节流函数
    let counter = 1

    const throttleFn = MyThrottle(function(event) {
      console.log(`发送网络请求${counter++}:`, this.value, event)
    }, 3000, { trailing: true })

    inputEl.oninput = throttleFn

    cancelBtn.onclick = function() {
      throttleFn.cancel()
    }

image.png

优化获取返回值

这里与防抖的操作是一样的

            function MyThrottle(fn, interval, { leading = true, trailing = false } = {}) {
      let startTime = 0
      let timer = null

      const _throttle = function(...args) {
        return new Promise((resolve, reject) => {
          try {
             // 1.获取当前时间
            const nowTime = new Date().getTime()

            // 对立即执行进行控制
            if (!leading && startTime === 0) {
              startTime = nowTime
            }

            // 2.计算需要等待的时间执行函数
            const waitTime = interval - (nowTime - startTime)
            if (waitTime <= 0) {
              // console.log("执行操作fn")
              if (timer) clearTimeout(timer)
              const res = fn.apply(this, args)
              resolve(res)
              startTime = nowTime
              timer = null
              return
            } 

            // 3.判断是否需要执行尾部
            if (trailing && !timer) {
              timer = setTimeout(() => {
                // console.log("执行timer")
                const res = fn.apply(this, args)
                resolve(res)
                startTime = new Date().getTime()
                timer = null
              }, waitTime);
            }
          } catch (error) {
            reject(error)
          }
        })
      }

      _throttle.cancel = function() {
        if (timer) clearTimeout(timer)
        startTime = 0
        timer = null
      }

      return _throttle
    }

测试

const inputEl = document.querySelector("input")
    const cancelBtn = document.querySelector(".cancel")

    // 3.自己实现的节流函数
    let counter = 1

    const throttleFn = MyThrottle(function(event) {
      console.log(`发送网络请求${counter++}:`, this.value, event)
      return "throttle return value"
    }, 3000, { trailing: true })

    throttleFn("aaaa").then(res => {
      console.log("res:", res)
    })

image.png