博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
理解并优化函数节流Throttle
阅读量:4086 次
发布时间:2019-05-25

本文共 12201 字,大约阅读时间需要 40 分钟。

一、函数为什么要节流

有如下代码

let n = 1window.onmousemove = () => {  console.log(`第${n}次触发回调`)  n++}复制代码

当我们在PC端页面上滑动鼠标时,一秒可以可以触发约60次事件。大家也可以访问下面的在线例子进行测试。

查看在线例子: by Logan () on .

这里的回调函数只是打印字符串,如果回调函数更加复杂,可想而知浏览器的压力会非常大,可能降低用户体验。

resizescrollmousemove等事件的监听回调会被频繁触发,因此我们要对其进行限制。

二、实现思路

函数节流简单来说就是对于连续的函数调用,每间隔一段时间,只让其执行一次。初步的实现思路有两种:

1. 使用时间戳

设置一个对比时间戳,触发事件时,使用当前时间戳减去对比时间戳,如果差值大于设定的间隔时间,则执行函数,并用当前时间戳替换对比时间戳;如果差值小于设定的间隔时间,则不执行函数。

function throttle(method, wait) {  // 对比时间戳,初始化为0则首次触发立即执行,初始化为当前时间戳则wait毫秒后触发才会执行  let previous = 0  return function(...args) {    let context = this    let now = new Date().getTime()    // 间隔大于wait则执行method并更新对比时间戳    if (now - previous > wait) {      method.apply(context, args)      previous = now    }  }}复制代码

查看在线例子: by Logan () on .

2. 使用定时器

当首次触发事件时,设置定时器,wait毫秒后执行函数并将定时器置为null,之后触发事件时,如果定时器存在则不执行,如果定时器不存在则再次设置定时器。

function throttle(method, wait) {  let timeout  return function(...args) {    let context = this    if (!timeout) {      timeout = setTimeout(() => {        timeout = null        method.apply(context, args)      }, wait)    }  }}复制代码

查看在线例子: by Logan () on .

3. 两种方法对比

  • 首次触发:使用时间戳实现时会立即执行(将previous设为0的情况);使用定时器实现会设置定时器,wait毫秒后执行。
  • 停止触发:使用时间戳实现时,停止触发后不会再执行;使用定时器实现时,由于存在定时器,停止触发后还会执行一次。

三、函数节流 Throttle 应用场景

  • DOM 元素的拖拽功能实现(mousemove
  • 射击游戏的 mousedown/keydown 事件(单位时间只能发射一颗子弹)
  • 计算鼠标移动的距离(mousemove
  • Canvas 模拟画板功能(mousemove
  • 搜索联想(keyup
  • 监听滚动事件判断是否到页面底部自动加载更多:给 scroll 加了 debounce 后,只有用户停止滚动后,才会判断是否到了页面底部;如果是 throttle 的话,只要页面滚动就会间隔一段时间判断一次

四、函数节流最终版

代码说话,有错恳请指出

function throttle(method, wait, {leading = true, trailing = true} = {}) {  // result 记录method的执行返回值  let timeout, result  // 记录上次原函数执行的时间(非每次更新)  let methodPrevious = 0  // 记录上次回调触发时间(每次都更新)  let throttledPrevious = 0  let throttled =  function(...args) {    let context = this    // 使用Promise,可以在触发回调时拿到原函数执行的返回值    return new Promise(resolve => {      let now = new Date().getTime()      // 两次相邻触发的间隔      let interval = now - throttledPrevious      // 更新本次触发时间供下次使用      throttledPrevious = now      // 重置methodPrevious为now,remaining = wait > 0,假装刚执行过,实现禁止立即执行      // 统一条件:leading为false      // 加上以下条件之一      // 1. 首次触发(此时methodPrevious为0)      // 2. trailing为true时,停止触发时间超过wait,定时器内函数执行(methodPrevious被置为0),然后再次触发      // 3. trailing为false时(不设定时器,methodPrevious不会被置为0),停止触发时间超过wait后再次触发(interval > wait)      if (leading === false && (!methodPrevious || interval > wait)) {        methodPrevious = now        // 保险起见,清除定时器并置为null        // 假装刚执行过要假装的彻底XD        if (timeout) {          clearTimeout(timeout)          timeout = null        }      }      // 距离下次执行原函数的间隔      let remaining = wait - (now - methodPrevious)      // 1. leading为true时,首次触发就立即执行      // 2. 到达下次执行原函数时间      // 3. 修改了系统时间      if (remaining <= 0 || remaining > wait) {        if (timeout) {          clearTimeout(timeout)          timeout = null        }        // 更新对比时间戳,执行函数并记录返回值,传给resolve        methodPrevious = now        result = method.apply(context, args)        resolve(result)        // 解除引用,防止内存泄漏        if (!timeout) context = args = null      } else if (!timeout && trailing !== false) {        timeout = setTimeout(() => {          // leading为false时将methodPrevious设为0的目的在于          // 若不将methodPrevious设为0,如果定时器触发后很长时间没有触发回调          // 下次触发时的remaining为负,原函数会立即执行,违反了leading为false的设定          methodPrevious = leading === false ? 0 : new Date().getTime()          timeout = null          result = method.apply(context, args)          resolve(result)          // 解除引用,防止内存泄漏          if (!timeout) context = args = null        }, remaining)      }    })  }  // 加入取消功能,使用方法如下  // let throttledFn = throttle(otherFn)  // throttledFn.cancel()  throttled.cancel = function() {    clearTimeout(timeout)    previous = 0    timeout = null  }  return throttled}复制代码

调用节流后的函数的外层函数也需要使用Async/Await语法等待执行结果返回

使用方法见代码:

function square(num) {  return Math.pow(num, 2)}// let throttledFn = throttle(square, 1000)// let throttledFn = throttle(square, 1000, {leading: false})// let throttledFn = throttle(square, 1000, {trailing: false})let throttledFn = throttle(square, 1000, {leading: false, trailing: false})window.onmousemove = async () => {  try {    let val = await throttledFn(4)    // 原函数不执行时val为undefined    if (typeof val !== 'undefined') {      console.log(`原函数返回值为${val}`)    }  } catch (err) {    console.error(err)  }}// 鼠标移动时,每间隔1S输出:// 原函数的返回值为:16复制代码

查看在线例子: by Logan () on .

具体的实现步骤请往下看

五、函数节流 Throttle 的具体实现步骤

1. 优化第一版:融合两种实现方式

这样实现的效果是首次触发立即执行,停止触发后会再执行一次

function throttle(method, wait) {  let timeout  let previous = 0  return function(...args) {    let context = this    let now = new Date().getTime()    // 距离下次函数执行的剩余时间    let remaining = wait - (now - previous)    // 如果无剩余时间或系统时间被修改    if (remaining <= 0 || remaining > wait) {      // 如果定时器还存在则清除并置为null      if (timeout) {        clearTimeout(timeout)        timeout = null      }      // 更新对比时间戳并执行函数      previous = now      method.apply(context, args)    } else if (!timeout) {      // 如果有剩余时间但定时器不存在,则设置定时器      // remaining毫秒后执行函数、更新对比时间戳      // 并将定时器置为null      timeout = setTimeout(() => {        previous = new Date().getTime()        timeout = null        method.apply(context, args)      }, remaining)    }  }}复制代码

我们来捋一捋,假设连续触发回调:

  1. 第一次触发:对比时间戳为0,剩余时间为负数,立即执行函数并更新对比时间戳
  2. 第二次触发:剩余时间为正数,定时器不存在,设置定时器
  3. 之后的触发:剩余时间为正数,定时器存在,不执行其他行为
  4. 直至剩余时间小于等于0或定时器内函数执行(由于回调触发有间隔,且setTimeout有误差,故哪个先触发并不确定)
  • 若定时器内函数执行,更新对比时间戳,并将定时器置为null,下一次触发继续设定定时器
  • 若定时器内函数未执行,但剩余时间小于等于0,清除定时器并置为null,立即执行函数,更新时间戳,下一次触发继续设定定时器
  1. 停止触发后:若非在上面所述的两个特殊时间点时停止触发,则会存在一个定时器,原函数还会被执行一次

查看在线例子: by Logan () on .

2. 优化第二版:提供首次触发时是否立即执行的配置项

// leading为控制首次触发时是否立即执行函数的配置项function throttle(method, wait, leading = true) {  let timeout  let previous = 0  return function(...args) {    let context = this    let now = new Date().getTime()    // !previous代表首次触发或定时器触发后的首次触发,若不需要立即执行则将previous更新为now    // 这样remaining = wait > 0,则不会立即执行,而是设定定时器    if (!previous && leading === false) previous = now    let remaining = wait - (now - previous)    if (remaining <= 0 || remaining > wait) {      if (timeout) {        clearTimeout(timeout)        timeout = null      }      previous = now      method.apply(context, args)    } else if (!timeout) {      timeout = setTimeout(() => {        // 如果leading为false,则将previous设为0,        // 下次触发时会与下次触发时的now同步,达到首次触发(对于用户来说)不立即执行        // 如果直接设为当前时间戳,若停止触发一段时间,下次触发时的remaining为负值,会立即执行        previous = leading === false ? 0 : new Date().getTime()        timeout = null        method.apply(context, args)      }, remaining)    }  }}复制代码

查看在线例子: by Logan () on .

3. 优化第三版:提供停止触发后是否还执行一次的配置项

// trailing为控制停止触发后是否还执行一次的配置项function throttle(method, wait, {leading = true, trailing = true} = {}) {  let timeout  let previous = 0  return function(...args) {    let context = this    let now = new Date().getTime()    if (!previous && leading === false) previous = now    let remaining = wait - (now - previous)    if (remaining <= 0 || remaining > wait) {      if (timeout) {        clearTimeout(timeout)        timeout = null      }      previous = now      method.apply(context, args)    } else if (!timeout && trailing !== false) {      // 如果有剩余时间但定时器不存在,且trailing不为false,则设置定时器      // trailing为false时等同于只使用时间戳来实现节流      timeout = setTimeout(() => {        previous = leading === false ? 0 : new Date().getTime()        timeout = null        method.apply(context, args)      }, remaining)    }  }}复制代码

查看在线例子: by Logan () on .

4. 优化第四版:提供取消功能

有些时候我们需要在不可触发的这段时间内能够手动取消节流,代码实现如下:

function throttle(method, wait, {leading = true, trailing = true} = {}) {  let timeout  let previous = 0  // 将返回的匿名函数赋值给throttled,以便在其上添加取消方法  let throttled =  function(...args) {    let context = this    let now = new Date().getTime()    if (!previous && leading === false) previous = now    let remaining = wait - (now - previous)    if (remaining <= 0 || remaining > wait) {      if (timeout) {        clearTimeout(timeout)        timeout = null      }      previous = now      method.apply(context, args)    } else if (!timeout && trailing !== false) {      timeout = setTimeout(() => {        previous = leading === false ? 0 : new Date().getTime()        timeout = null        method.apply(context, args)      }, remaining)    }  }  // 加入取消功能,使用方法如下  // let throttledFn = throttle(otherFn)  // throttledFn.cancel()  throttled.cancel = function() {    clearTimeout(timeout)    previous = 0    timeout = null  }  // 将节流后函数返回  return throttled}复制代码

查看在线例子: by Logan () on .

5. 优化第五版:处理原函数返回值

需要节流的函数可能是存在返回值的,我们要对这种情况进行处理,underscore的处理方法是将函数返回值在返回的debounced函数内再次返回,但是这样其实是有问题的。如果原函数执行在setTimeout内,则无法同步拿到返回值,我们使用Promise处理原函数返回值。

function throttle(method, wait, {leading = true, trailing = true} = {}) {  // result记录原函数执行结果  let timeout, result  let previous = 0  let throttled =  function(...args) {    let context = this    // 返回一个Promise,以便可以使用then或者Async/Await语法拿到原函数返回值    return new Promise(resolve => {      let now = new Date().getTime()      if (!previous && leading === false) previous = now      let remaining = wait - (now - previous)      if (remaining <= 0 || remaining > wait) {        if (timeout) {          clearTimeout(timeout)          timeout = null        }        previous = now        result = method.apply(context, args)        // 将函数执行返回值传给resolve        resolve(result)      } else if (!timeout && trailing !== false) {        timeout = setTimeout(() => {          previous = leading === false ? 0 : new Date().getTime()          timeout = null          result = method.apply(context, args)          // 将函数执行返回值传给resolve          resolve(result)        }, remaining)      }    })  }  throttled.cancel = function() {    clearTimeout(timeout)    previous = 0    timeout = null  }  return throttled}复制代码

使用方法一:在调用节流后的函数时,使用then拿到原函数的返回值

function square(num) {  return Math.pow(num, 2)}let throttledFn = throttle(square, 1000, false)window.onmousemove = () => {  throttledFn(4).then(val => {    console.log(`原函数的返回值为:${val}`)  })}// 鼠标移动时,每间隔1S后输出:// 原函数的返回值为:16复制代码

使用方法二:调用节流后的函数的外层函数使用Async/Await语法等待执行结果返回

使用方法见代码:

function square(num) {  return Math.pow(num, 2)}let throttledFn = throttle(square, 1000)window.onmousemove = async () => {  try {    let val = await throttledFn(4)    // 原函数不执行时val为undefined    if (typeof val !== 'undefined') {      console.log(`原函数返回值为${val}`)    }  } catch (err) {    console.error(err)  }}// 鼠标移动时,每间隔1S输出:// 原函数的返回值为:16复制代码

查看在线例子: by Logan () on .

6. 优化第六版:可同时禁用立即执行和后置执行

模仿underscore实现的函数节流有一点美中不足,那就是 leading:falsetrailing: false 不能同时设置。

如果同时设置的话,比如当你将鼠标移出的时候,因为 trailing 设置为 false,停止触发的时候不会设置定时器,所以只要再过了设置的时间,再移入的话,remaining为负数,就会立刻执行,就违反了 leading: false,这里我们优化的思路如下:

计算连续两次触发回调的时间间隔,如果大于设定的间隔值时,重置对比时间戳为当前时间戳,这样就相当于回到了首次触发,达到禁止首次触发(伪)立即执行的效果,代码如下,有错恳请指出:

function throttle(method, wait, {leading = true, trailing = true} = {}) {  let timeout, result  let methodPrevious = 0  // 记录上次回调触发时间(每次都更新)  let throttledPrevious = 0  let throttled =  function(...args) {    let context = this    return new Promise(resolve => {      let now = new Date().getTime()      // 两次触发的间隔      let interval = now - throttledPrevious      // 更新本次触发时间供下次使用      throttledPrevious = now      // 更改条件,两次间隔时间大于wait且leading为false时也重置methodPrevious,实现禁止立即执行      if (leading === false && (!methodPrevious || interval > wait)) {        methodPrevious = now      }      let remaining = wait - (now - methodPrevious)      if (remaining <= 0 || remaining > wait) {        if (timeout) {          clearTimeout(timeout)          timeout = null        }        methodPrevious = now        result = method.apply(context, args)        resolve(result)        // 解除引用,防止内存泄漏        if (!timeout) context = args = null      } else if (!timeout && trailing !== false) {        timeout = setTimeout(() => {          methodPrevious = leading === false ? 0 : new Date().getTime()          timeout = null          result = method.apply(context, args)          resolve(result)          // 解除引用,防止内存泄漏          if (!timeout) context = args = null        }, remaining)      }    })  }  throttled.cancel = function() {    clearTimeout(timeout)    methodPrevious = 0    timeout = null  }  return throttled}复制代码

查看在线例子: by Logan () on .

六、参考文章

 

作者:Logan70
链接:https://juejin.im/post/5be24d76e51d451def13cca2
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

你可能感兴趣的文章
C++ 纯虚函数
查看>>
C++ 容器与继承
查看>>
C++ 句柄类和继承
查看>>
C++ 使用句柄
查看>>
C++ 定义函数模板
查看>>
C++ 定义类模板
查看>>
C++ 模版参数
查看>>
C++ 模板类型参数
查看>>
C++ 非类型模版参数
查看>>
DirectX11 视口
查看>>
DirectX11 屏幕的清除和显示
查看>>
DirectX11 创建顶点缓存
查看>>
DirectX11 图形流水线历史以及着色器简介
查看>>
DirectX11 编译着色器、创建顶点着色器、创建像素着色器
查看>>
DirectX11 创建输入布局
查看>>
DirectX11 渲染——输入装配、绑定着色器、绘制
查看>>
游戏程序员养成计划
查看>>
3D数学 矩阵和线性变换之旋转
查看>>
3D数学 矩阵和线性变换之缩放
查看>>
3D数学 矩阵和线性变换之正交投影
查看>>