JavaScript 手写节流函数(throttle)

267 阅读5分钟

认识节流throttle函数

  • 我们来理解一下节流的过程

    • 当事件触发时,会执行这个事件的响应函数

    • 如果这个事件会被频繁触发,那么节流函数会按照一定的频率来执行函数

    • 不管在这个中间有多少次触发这个事件,执行函数的频繁总是固定的

  • 节流的应用场景:

    • 监听页面的滚动事件
    • 鼠标移动事件
    • 用户频繁点击按钮操作
    • 游戏中的一些设计

节流函数的应用场景

  • 很多人都玩过类似于王者荣耀或者LOL

    • 当玩ADC时,哪怕你疯狂点平A,射手的射速与你点击的频率没有太大关系
    • 因为游戏中它们是由自己的攻速阈值的,那么这种攻速阈值带来的效果就是节流
  • 很多人也都玩过类似于飞机大战的游戏

  • 在飞机大战的游戏中,我们按下空格会发射一个子弹:

    • 很多飞机大战的游戏中会有这样的设定,即使按下的频率非常快,子弹也会保持一定的频率来发射
    • 比如1秒钟只能发射一次,即使用户在这1秒钟按下了10次,子弹会保持发射一颗的频率来发射
    • 但是事件是触发了10次的,响应的函数只触发了一次

Underscore 库的介绍

  • 事实上我们可以通过一些第三方库来实现防抖操作:
    • lodash
    • underscore
  • 这里使用underscore
    • 我们可以理解成lodash是underscore的升级版,它更重量级,功能也更多;
    • 但是目前我看到underscore还在维护,lodash已经很久没有更新了;
  • Underscore的官网: underscorejs.org/
  • Underscore的安装有很多种方式:
    • 下载Underscore,本地引入;
    • 通过CDN直接引入;
    • 通过包管理工具(npm)管理安装;
  • 这里我们直接通过CDN:
<script src="https://cdn.jsdelivr.net/npm/underscore@1.13.1/underscore-umd-min.js"></script>
  • HTML
<input type="text">
  • js代码测试
const inputEl = document.querySelector("input")

let counter = 0

const inputChange = function (event) {
  console.log(`发送了第${++counter}次网络请求`, this, event)
}

inputEl.oninput = _.throttle(inputChange, 1000)

throttle v1 基本实现

  • 这里实现节流函数思路是采用时间戳的方式
    • 采用lastTime来记录每次执行的上一次函数触发的开始时间,默认为0
    • 然后通过传入的时间间隔与当前触发的时间以及上一次触发的开始的时间, 计算出还剩余多长事件需要去触发函数
    • 最后触发函数时, 会将当前触发的时间赋值给用来保存上次触发事件的变量,实现节流效果
function throttle(fn, interval) {
  // 1.记录上一次的开始时间
  let lastTime = 0

  // 2.事件触发时, 执行的函数
  const _throttle = function () {
    // 2.1 获取当前事件触发时的时间
    const nowTime = +new Date()

    // 2.2 余下时间 = 间隔时间 - (触发开始时间 - 上一次的触发开始时间)
    const remainTime = interval - (nowTime - lastTime)

    if (remainTime <= 0) {
      // 2.3 真正触发的函数
      fn()

      // 2.4 保留上次触发的事件
      lastTime = nowTime
    }
  }
  return _throttle
}
  • 那么这就是节流函数的基本实现,接下来还会增加一些功能

throttle v2 leading实现

  • 实现思路就是传入一个对象,对象控制两个变量leadingtrailing
    • leading控制第一次是否执行
    • trailing控制最后一次是否执行
  • 这里先实现leading 功能,那么leading 这里的默认值我设置了true,也就是默认第一次就执行
  • 就下了就涉及到一个小算法,就是当leadingtrue时,那么将nowTime赋值给lastTime
    • 就比如当前触发开始时间(nowTime)是1000000,那么将上一次触发开始时间(lastTime)等于1000000
    • 在执行nowTime - lastTime时,结果必然为0,那么就满足下面的if判断了,就会执行函数了!
  • 但还要一点要注意,立即执行的前提必然是每次节流函数的第一次生效,所以还需判断lastTime是否为0,当lastTime === 0代表是第一次执行,才会进行上面leading的判断
function throttle(fn, interval, options = { leading: true, trailing: false}) {
  // 1.1 控制第一次与最后一次是否执行
  const { leading, trailing } = options
  // 1.2 记录上一次的开始时间
  let lastTime = 0

  // 2.事件触发时, 执行的函数
  const _throttle = function () {
    // 2.1 获取当前事件触发时的时间
    const nowTime = +new Date()

    // 2.2 如果 lastTime为0 并且 leading为false  将nowTime赋值给lastTime
    if(!lastTime && !leading) lastTime = nowTime

    // 2.3 使用当前触发的时间和之前的时间间隔以及上一次开始的时间, 计算出还剩余多长事件需要去触发函数
    // 余下时间 = 间隔时间 - (触发开始时间 - 上一次的触发开始时间)
    const remainTime = interval - (nowTime - lastTime)

    if (remainTime <= 0) {
      // 2.4 真正触发的函数
      fn()

      // 2.5 保留上次触发事件的时间戳
      lastTime = nowTime
    }
  }
  return _throttle
}
  • 这里先进行代码测试
  • HTML
<input type="text">
  • JS代码
const inputEl = document.querySelector("input")

let counter = 0
const inputChange = function (event) {
  console.log(`发送了第${++counter}次网络请求`, this, event);
}

// leading这里默认就是true,所以我们测试 false,那么第一次不会即立即触发执
inputEl.oninput = throttle(inputChange, 1000, { leading: false })

throttle v3 traling实现

  • 这里traling默认为false,也就是默认最后一次不会执行

  • 接下来判断如果trailingtrue并且没有执行过定时器时:

    • 开启定时器并将定时器编号给timer,方便取消定时器
    • 执行定时器时,再将timer初始化
    • 判断leadingtrue时,lastTime就需要重新获取时间
  • 比如interval是为10s时

    • 触发事件也是第10s时,就会执行if (remainTime <= 0)里面的代码,然后会终止执行
  • 触发事件的时间大概率不会刚好与interval相等,后面很有可能会有ms的,就比如触发事件是10.1s(10100ms)

    • 首先10 - (10.1 - 0)是小于0的,那么if (remainTime <= 0)里面的代码依旧会执行
    • 但此时 remainTime是为-0.1s的,所以还会根据trailing :true进入函数,再进行最后一次的回调
    • 那么也就是说,会重复执行两次函数
  • 那怎么解决呢?

    • 就是进行一个判断,当trailingtrue
    • lastTime就通过new Date()重新获取下当前的时间戳并赋值,意味着用interval - (nowTime - lastTime)重新求出remain值作为setTimeout延迟时间
    • 否则的话就初始化变量为0
function throttle(fn, interval, options = { leading: true, trailing: false}) {
  // 1.1 控制第一次与最好一次是否执行
  let { leading, trailing } = options

  // 1.2 记录上一次的开始时间
  let lastTime = 0

  // 1.3 用于判断是否存在定时器
  let timer = null

  // 2.事件触发时, 执行的函数
  const _throttle = function () {
    // 2.1 获取当前事件触发时的时间
    const nowTime = +new Date()
    
    // 2.2 如果 lastTime为0 并且 leading为false  将nowTime赋值给lastTime
    if (!lastTime && !leading) lastTime = nowTime

    // 2.3 使用当前触发的时间和之前的时间间隔以及上一次开始的时间, 计算出还剩余多长事件需要去触发函数
    // 余下时间 = 间隔时间 - (触发开始时间 - 上一次的触发开始时间)
    const remainTime = interval - (nowTime - lastTime)

    if (remainTime <= 0) {
      // 3.1 如果有定时器就取消定时器并初始化timer
      if (timer) {
        clearTimeout(timer)
        timer = null
      }

      // 2.4 真正触发的函数
      fn()

      // 2.5 保留上次触发的事件 并终止代码不执行定时器
      return lastTime = nowTime
    }

    // 3.如果trailing为true 并且 没有定时器执行下面代码
    if (trailing && !timer) {
      timer = setTimeout(() => {
        timer = null

        // 3.2 需要重新获取时间
        lastTime = +new Date()
          
        fn()
      }, remainTime)
    }
  }

  return _throttle
}
  • 这里先进行代码测试
  • HTML
<input type="text">
  • JS代码
const inputEl = document.querySelector("input")

let counter = 0
const inputChange = function (event) {
  console.log(`发送了第${++counter}次网络请求`, this, event);
}
// 这里将trailing设为true 最后一次会调用
inputEl.oninput = throttle(inputChange, 1000, { leading: false, trailing: true })

throttle v4 this参数

  • this的写法与防抖写法一样,可以用apply 或 call都可以
  • bind不行吗?可以的,在后面加括号调用也可,这里我依旧采用apply
function throttle(fn, interval, options = { leading: true, trailing: false}) {
  // 1.1 控制第一次与最好一次是否执行
  let { leading, trailing } = options

  // 1.2 记录上一次的开始时间
  let lastTime = 0

  // 1.3 用于判断是否存在定时器
  let timer = null

  // 2.事件触发时, 执行的函数
  const _throttle = function (...args) {
    // 2.1 获取当前事件触发时的时间
    const nowTime = new Date().getTime()
    
    // 2.2 如果 lastTime为0 并且 leading为false  将nowTime赋值给lastTime
    if (!lastTime && !leading) lastTime = nowTime

    // 2.3 使用当前触发的时间和之前的时间间隔以及上一次开始的时间, 计算出还剩余多长事件需要去触发函数
    // 余下时间 = 间隔时间 - (触发开始时间 - 上一次的触发开始时间)
    const remainTime = interval - (nowTime - lastTime)
    
    if (remainTime <= 0) {
      // 3.1 如果有定时器就取消定时器并初始化timer
      if (timer) {
        clearTimeout(timer)
        timer = null
      }

      // 2.4 真正触发的函数 并传入this 与 参数
      fn.apply(this, args)

      // 2.5 保留上次触发的事件 并终止代码不执行定时器
      return lastTime = nowTime
    }

    // 3.如果trailing为true 并且 没有定时器执行下面代码
    if (trailing && !timer) {
      timer = setTimeout(() => {
        timer = null

        // 3.2 需要重新获取时间
        lastTime = +new Date()

        // 3.3 传入this 与 参数
        fn.apply(this, args)
      }, remainTime)
    }
  }

  return _throttle
}
  • 测试代码用上面的就行

throttle v5 取消功能

  • 这也是比较简单的,取消定时器就可以了,那么可能会有疑问了,如果tralingfalse怎么要取消呢?
  • 其实这里针对traling功能就好了,因为如果在tralingfalse的情况下,你输入的时间小于节流函数的interval本质上也不会执行
  • 所以针对tralingtrue时,进行取消是没有问题的
function throttle(fn, interval, options = { leading: true, trailing: false}) {
  // 1.1 控制第一次与最好一次是否执行
  let { leading, trailing } = options

  // 1.2 记录上一次的开始时间
  let lastTime = 0

  // 1.3 用于判断是否存在定时器
  let timer = null

  // 2.事件触发时, 执行的函数
  const _throttle = function (...args) {
    // 2.1 获取当前事件触发时的时间
    const nowTime = new Date().getTime()
    
    // 2.2 如果 lastTime为0 并且 leading为false  将nowTime赋值给lastTime
    if (!lastTime && !leading) lastTime = nowTime

    // 2.3 使用当前触发的时间和之前的时间间隔以及上一次开始的时间, 计算出还剩余多长事件需要去触发函数
    // 余下时间 = 间隔时间 - (触发开始时间 - 上一次的触发开始时间)
    const remainTime = interval - (nowTime - lastTime)
    
    if (remainTime <= 0) {
      // 3.1 如果有定时器就取消定时器并初始化timer
      if (timer) {
        clearTimeout(timer)
        timer = null
      }

      // 2.4 真正触发的函数 并传入this 与 参数
      fn.apply(this, args)

      // 2.5 保留上次触发的事件 并终止代码不执行定时器
      return lastTime = nowTime
    }

    // 3.如果trailing为true 并且 没有定时器执行下面代码
    if (trailing && !timer) {
      timer = setTimeout(() => {
        timer = null

        // 3.2 需要重新获取时间
        lastTime = +new Date()

        // 3.3 传入this 与 参数
        fn.apply(this, args)
      }, remainTime)
    }
  }

  // 4.取消功能
  _throttle.cancel = function () {
    if (timer) clearTimeout(timer)

    // 4.1 取消代表整个函数终结了, 那么建议初始化变量
    timer = null
    lastTime = 0
  }

  return _throttle
}
  • 这里也可以进行代码测试
  • HTML
<input type="text">
<button id="cancel">取消</button>
  • JS代码
const inputEl = document.querySelector("input")
const btnEl = document.querySelector("button")
let counter = 0
const inputChange = function (event) {
  console.log(`发送了第${++counter}次网络请求`, this, event);
}
// 拿到throttle返回值
const _throttle = throttle(inputChange, 1000, { leading: false, trailing: true })
// 这种写法与之前的写法没有区别 都是调用 _throttle
inputEl.oninput = _throttle
// 点击按钮取消
btnEl.onclick = () => _throttle.cancel()

throttle v6 函数返回值

思路一:回调函数

  • 通过外界传入函数回调形式返回返回值
function throttle(fn, interval, options = { leading: true, trailing: false}) {
  // 1.1 控制第一次与最好一次是否执行
  let { leading, trailing, resultCallback } = options

  // 1.2 记录上一次的开始时间
  let lastTime = 0

  // 1.3 用于判断是否存在定时器
  let timer = null

  // 2.事件触发时, 执行的函数
  const _throttle = function (...args) {
    // 2.1 获取当前事件触发时的时间
    const nowTime = new Date().getTime()
    
    // 2.2 如果 lastTime为0 并且 leading为false  将nowTime赋值给lastTime
    if (!lastTime && !leading) lastTime = nowTime

    // 2.3 使用当前触发的时间和之前的时间间隔以及上一次开始的时间, 计算出还剩余多长事件需要去触发函数
    // 余下时间 = 间隔时间 - (触发开始时间 - 上一次的触发开始时间)
    const remainTime = interval - (nowTime - lastTime)
    
    if (remainTime <= 0) {
      // 3.1 如果有定时器就取消定时器并初始化timer
      if (timer) {
        clearTimeout(timer)
        timer = null
      }

      // 2.4 真正触发的函数 并传入this 与 参数  再拿到其返回值
      const result = fn.apply(this, args)

      // 2.5 判断resultCallback是否传入并是否为函数 再将返回值传入回调函数
      if(resultCallback && typeof resultCallback === 'function') resultCallback(result)

      // 2.6 保留上次触发的事件 并终止代码不执行定时器
      return lastTime = nowTime
    }

    // 3.如果trailing为true 并且 没有定时器执行下面代码
    if (trailing && !timer) {
      timer = setTimeout(() => {
        timer = null

        // 3.2 需要重新获取时间
        lastTime = +new Date()

        // 3.3 传入this 与 参数  并拿到其返回值
        const result = fn.apply(this, args)

        // 3.4 判断resultCallback是否传入并是否为函数 再将返回值传入回调函数
        if(resultCallback && typeof resultCallback === 'function') resultCallback(result)
      }, remainTime)
    }
  }

  // 4.取消功能
  _throttle.cancel = function () {
    if (timer) clearTimeout(timer)

    // 4.1 取消代表整个函数终结了, 那么建议初始化变量
    timer = null
    lastTime = 0
  }

  return _throttle
}

思路二:Promise

  • 通过返回Promise外部进行then方法调用来获取返回值
function throttle(fn, interval, options = { leading: true, trailing: false}) {
  // 1.1 控制第一次与最好一次是否执行
  let { leading, trailing, resultCallback } = options

  // 1.2 记录上一次的开始时间
  let lastTime = 0

  // 1.3 用于判断是否存在定时器
  let timer = null

  // 2.事件触发时, 执行的函数
  const _throttle = function (...args) {
    return new Promise((resolve, reject) => {
      // 2.1 获取当前事件触发时的时间
    const nowTime = new Date().getTime()
    
    // 2.2 如果 lastTime为0 并且 leading为false  将nowTime赋值给lastTime
    if (!lastTime && !leading) lastTime = nowTime

    // 2.3 使用当前触发的时间和之前的时间间隔以及上一次开始的时间, 计算出还剩余多长事件需要去触发函数
    // 余下时间 = 间隔时间 - (触发开始时间 - 上一次的触发开始时间)
    const remainTime = interval - (nowTime - lastTime)
    
    if (remainTime <= 0) {
      // 3.1 如果有定时器就取消定时器并初始化timer
      if (timer) {
        clearTimeout(timer)
        timer = null
      }

      // 2.4 真正触发的函数 并传入this 与 参数  再拿到其返回值
      const result = fn.apply(this, args)

      // 2.5 判断resultCallback是否传入并是否为函数 再将返回值传入回调函数
      try {
        if(resultCallback && typeof resultCallback === 'function') resolve(result)
      } catch (err) {
        reject(err)
      }

      // 2.6 保留上次触发的事件 并终止代码不执行定时器
      return lastTime = nowTime
    }

    // 3.如果trailing为true 并且 没有定时器执行下面代码
    if (trailing && !timer) {
      timer = setTimeout(() => {
        timer = null

        // 3.2 需要重新获取时间
        lastTime = +new Date()

        // 3.3 传入this 与 参数  并拿到其返回值
        const result = fn.apply(this, args)

        // 3.4 判断resultCallback是否传入并是否为函数 再将返回值传入回调函数
        try {
          if(resultCallback && typeof resultCallback === 'function') resolve(result)
        } catch (err) {
          reject(err)
        }

      }, remainTime)
    }
    })
  }

  // 4.取消功能
  _throttle.cancel = function () {
    if (timer) clearTimeout(timer)

    // 4.1 取消代表整个函数终结了, 那么建议初始化变量
    timer = null
    lastTime = 0
  }

  return _throttle
}
  • 这里我依旧使用代码对Promise实现的返回值功能进行简单测试:
  • HTML测试代码
<input type="text">
<button id="cancel">取消</button>
  • js测试代码
const inputEl = document.querySelector("input")
const btnEl = document.querySelector("button")

let counter = 0
const inputChange = function (event) {
  console.log(`发送了第${++counter}次网络请求`, this, event)
    
  // 返回值:返回 0-99 随机一个s
  return ~~(Math.random() * 100)
}

// 拿到throttle返回值
const _throttle = throttle(inputChange, 1000, {
  leading: false,
  trailing: false,
  resultCallback() { }
})

// 通过临时函数获得_throttle返回值 -> Promsie 通过then方法拿到返回值
const tempCallback = function () {
  const res = _throttle().then(res => {
    console.log("Promise的返回值结果:", res)
  })
}

// 调用tempCallback函数这种写法与之前的写法没有区别 也都是调用 _throttle
inputEl.oninput = tempCallback

// 点击按钮取消
btnEl.onclick = () => _throttle.cancel()