JavaScript 防抖和节流遇见的奇怪问题及解决


Posted in Javascript onNovember 20, 2020

场景

网络上已经存在了大量的有关 防抖 和 节流 的文章,为何吾辈还要再写一篇呢?事实上,防抖和节流,吾辈在使用中发现了一些奇怪的问题,并经过了数次的修改,这里主要分享一下吾辈遇到的问题以及是如何解决的。

为什么要用防抖和节流?

因为某些函数触发/调用的频率过快,吾辈需要手动去限制其执行的频率。例如常见的监听滚动条的事件,如果没有防抖处理的话,并且,每次函数执行花费的时间超过了触发的间隔时间的话 ? 页面就会卡顿。

演进

初始实现

我们先实现一个简单的去抖函数

function debounce(delay, action) {
 let tId
 return function(...args) {
  if (tId) clearTimeout(tId)
  tId = setTimeout(() => {
   action(...args)
  }, delay)
 }
}

测试一下

// 使用 Promise 简单封装 setTimeout,下同
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
;(async () => {
 let num = 0
 const add = () => ++num

 add()
 add()
 console.log(num) // 2

 const fn = debounce(10, add)
 fn()
 fn()
 console.log(num) // 2
 await wait(20)
 console.log(num) // 3
})()

好了,看来基本的效果是实现了的。包装过的函数 fn 调用了两次,却并没有立刻执行,而是等待时间间隔过去之后才最终执行了一次。

this 怎么办?

然而,上面的实现有一个致命的问题,没有处理 this!当你用在原生的事件处理时或许还不觉得,然而,当你使用了 ES6 class 这类对 this 敏感的代码时,就一定会遇到 this 带来的问题。

例如下面使用 class 来声明一个计数器

class Counter {
 constructor() {
  this.i = 0
 }
 add() {
  this.i++
 }
}

我们可能想在 constructor 中添加新的属性 fn

class Counter {
 constructor() {
  this.i = 0
  this.fn = debounce(10, this.add)
 }
 add() {
  this.i++
 }
}

但很遗憾,这里的 this 绑定是有问题的,执行以下代码试试看

const counter = new Counter()
counter.fn() // Cannot read property 'i' of undefined

会抛出异常 Cannot read property 'i' of undefined,究其原因就是 this 没有绑定,我们可以手动绑定 this .bind(this)

class Counter {
 constructor() {
  this.i = 0
  this.fn = debounce(10, this.add.bind(this))
 }
 add() {
  this.i++
 }
}

但更好的方式是修改 debounce,使其能够自动绑定 this

function debounce(delay, action) {
 let tId
 return function(...args) {
  if (tId) clearTimeout(tId)
  tId = setTimeout(() => {
   action.apply(this, args)
  }, delay)
 }
}

然后,代码将如同预期的运行

;(async () => {
 class Counter {
  constructor() {
   this.i = 0
   this.fn = debounce(10, this.add)
  }
  add() {
   this.i++
  }
 }

 const counter = new Counter()
 counter.add()
 counter.add()
 console.log(counter.i) // 2

 counter.fn()
 counter.fn()
 console.log(counter.i) // 2
 await wait(20)
 console.log(counter.i) // 3
})()

返回值呢?

不知道你有没有发现,现在使用 debounce 包装的函数都没有返回值,是完全只有副作用的函数。然而,吾辈还是遇到了需要返回值的场景。
例如:输入停止后,使用 Ajax 请求后台数据判断是否已存在相同的数据。

修改 debounce 成会缓存上一次执行结果并且有初始结果参数的实现

function debounce(delay, action, init = undefined) {
 let flag
 let result = init
 return function(...args) {
  if (flag) clearTimeout(flag)
  flag = setTimeout(() => {
   result = action.apply(this, args)
  }, delay)
  return result
 }
}

调用代码变成了

;(async () => {
 class Counter {
  constructor() {
   this.i = 0
   this.fn = debounce(10, this.add, 0)
  }
  add() {
   return ++this.i
  }
 }

 const counter = new Counter()

 console.log(counter.add()) // 1
 console.log(counter.add()) // 2

 console.log(counter.fn()) // 0
 console.log(counter.fn()) // 0
 await wait(20)
 console.log(counter.fn()) // 3
})()

看起来很完美?然而,没有考虑到异步函数是个大失败!

尝试以下测试代码

;(async () => {
 const get = async i => i

 console.log(await get(1))
 console.log(await get(2))
 const fn = debounce(10, get, 0)
 fn(3).then(i => console.log(i)) // fn(...).then is not a function
 fn(4).then(i => console.log(i))
 await wait(20)
 fn(5).then(i => console.log(i))
})()

会抛出异常 fn(...).then is not a function,因为我们包装过后的函数是同步的,第一次返回的值并不是 Promise 类型。

除非我们修改默认值

;(async () => {
 const get = async i => i

 console.log(await get(1))
 console.log(await get(2))
 // 注意,修改默认值为 Promise
 const fn = debounce(10, get, new Promise(resolve => resolve(0)))
 fn(3).then(i => console.log(i)) // 0
 fn(4).then(i => console.log(i)) // 0
 await wait(20)
 fn(5).then(i => console.log(i)) // 4
})()

支持有返回值的异步函数

支持异步有两种思路

  1. 将异步函数包装为同步函数
  2. 将包装后的函数异步化

第一种思路实现

function debounce(delay, action, init = undefined) {
 let flag
 let result = init
 return function(...args) {
  if (flag) clearTimeout(flag)
  flag = setTimeout(() => {
   const temp = action.apply(this, args)
   if (temp instanceof Promise) {
    temp.then(res => (result = res))
   } else {
    result = temp
   }
  }, delay)
  return result
 }
}

调用方式和同步函数完全一样,当然,是支持异步函数的

;(async () => {
 const get = async i => i

 console.log(await get(1))
 console.log(await get(2))
 // 注意,修改默认值为 Promise
 const fn = debounce(10, get, 0)
 console.log(fn(3)) // 0
 console.log(fn(4)) // 0
 await wait(20)
 console.log(fn(5)) // 4
})()

第二种思路实现

const debounce = (delay, action, init = undefined) => {
 let flag
 let result = init
 return function(...args) {
  return new Promise(resolve => {
   if (flag) clearTimeout(flag)
   flag = setTimeout(() => {
    result = action.apply(this, args)
    resolve(result)
   }, delay)
   setTimeout(() => {
    resolve(result)
   }, delay)
  })
 }
}

调用方式支持异步的方式

;(async () => {
 const get = async i => i

 console.log(await get(1))
 console.log(await get(2))
 // 注意,修改默认值为 Promise
 const fn = debounce(10, get, 0)
 fn(3).then(i => console.log(i)) // 0
 fn(4).then(i => console.log(i)) // 4
 await wait(20)
 fn(5).then(i => console.log(i)) // 5
})()

可以看到,第一种思路带来的问题是返回值永远会是 旧的 返回值,第二种思路主要问题是将同步函数也给包装成了异步。利弊权衡之下,吾辈觉得第二种思路更加正确一些,毕竟使用场景本身不太可能必须是同步的操作。而且,原本 setTimeout 也是异步的,只是不需要返回值的时候并未意识到这点。

避免原函数信息丢失

后来,有人提出了一个问题,如果函数上面携带其他信息,例如类似于 jQuery 的 $,既是一个函数,但也同时含有其他属性,如果使用 debounce 就找不到了呀

一开始吾辈立刻想到了复制函数上面的所有可遍历属性,然后想起了 ES6 的 Proxy 特性 ? 这实在是太魔法了。使用 Proxy 解决这个问题将异常的简单 ? 因为除了调用函数,其他的一切操作仍然指向原函数!

const debounce = (delay, action, init = undefined) => {
 let flag
 let result = init
 return new Proxy(action, {
  apply(target, thisArg, args) {
   return new Promise(resolve => {
    if (flag) clearTimeout(flag)
    flag = setTimeout(() => {
     resolve((result = Reflect.apply(target, thisArg, args)))
    }, delay)
    setTimeout(() => {
     resolve(result)
    }, delay)
   })
  },
 })
}

测试一下

;(async () => {
 const get = async i => i
 get.rx = 'rx'

 console.log(get.rx) // rx
 const fn = debounce(10, get, 0)
 console.log(fn.rx) // rx
})()

实现节流

以这种思路实现一个节流函数 throttle

/**
 * 函数节流
 * 节流 (throttle) 让一个函数不要执行的太频繁,减少执行过快的调用,叫节流
 * 类似于上面而又不同于上面的函数去抖, 包装后函数在上一次操作执行过去了最小间隔时间后会直接执行, 否则会忽略该次操作
 * 与上面函数去抖的明显区别在连续操作时会按照最小间隔时间循环执行操作, 而非仅执行最后一次操作
 * 注: 该函数第一次调用一定会执行,不需要担心第一次拿不到缓存值,后面的连续调用都会拿到上一次的缓存值
 * 注: 返回函数结果的高阶函数需要使用 {@link Proxy} 实现,以避免原函数原型链上的信息丢失
 *
 * @param {Number} delay 最小间隔时间,单位为 ms
 * @param {Function} action 真正需要执行的操作
 * @return {Function} 包装后有节流功能的函数。该函数是异步的,与需要包装的函数 {@link action} 是否异步没有太大关联
 */
const throttle = (delay, action) => {
 let last = 0
 let result
 return new Proxy(action, {
  apply(target, thisArg, args) {
   return new Promise(resolve => {
    const curr = Date.now()
    if (curr - last > delay) {
     result = Reflect.apply(target, thisArg, args)
     last = curr
     resolve(result)
     return
    }
    resolve(result)
   })
  },
 })
}

总结

嘛,实际上这里的防抖和节流仍然是简单的实现,其他的像 取消防抖/强制刷新缓存 等功能尚未实现。当然,对于吾辈而言功能已然足够了,也被放到了公共的函数库 rx-util 中。

以上就是JavaScript 防抖和节流遇见的奇怪问题及解决的详细内容,更多关于JavaScript 防抖和节流的资料请关注三水点靠木其它相关文章!

Javascript 相关文章推荐
理解js回收机制通俗易懂版
Feb 29 Javascript
在AngularJS框架中处理数据建模的方式解析
Mar 05 Javascript
JS中BOM相关知识点总结(必看篇)
Nov 22 Javascript
bootstrap为水平排列的表单和内联表单设置可选的图标
Feb 15 Javascript
解决vue2.0动态绑定图片src属性值初始化时报错的问题
Mar 14 Javascript
JavaScript使用math.js进行精确计算操作示例
Jun 19 Javascript
JavaScript中变量提升与函数提升经典实例分析
Jul 26 Javascript
微信小程序实现拖拽功能
Sep 26 Javascript
Node对CommonJS的模块规范
Nov 06 Javascript
vue keep-alive列表页缓存 详情页返回上一页不刷新,定位到之前位置
Nov 26 Javascript
vue键盘事件点击事件加native操作
Jul 27 Javascript
js实现炫酷光感效果
Sep 05 Javascript
JavaScript 异步时序问题
Nov 20 #Javascript
JavaScript实现音乐导航效果
Nov 19 #Javascript
JavaScript实现无限轮播效果
Nov 19 #Javascript
微信小程序实现分页加载效果
Nov 19 #Javascript
vue-drawer-layout实现手势滑出菜单栏
Nov 19 #Vue.js
H5 js点击按钮复制文本到粘贴板
Nov 19 #Javascript
JS数据类型分类及常用判断方法
Nov 19 #Javascript
You might like
在“咖啡之国”感受咖啡文化
2021/03/03 咖啡文化
ThinkPHP实现多数据库连接的解决方法
2014/07/01 PHP
5款适合PHP使用的HTML编辑器推荐
2015/07/03 PHP
用javascript将数据库中的TEXT类型数据动态赋值到TEXTAREA中
2007/04/20 Javascript
javascript delete 使用示例代码
2010/03/29 Javascript
js利用与或运算符优先级实现if else条件判断表达式
2010/04/15 Javascript
Jquery中的层次选择器与find()的区别示例介绍
2014/02/20 Javascript
sogou地图API用法实例教程
2014/09/11 Javascript
jQuery+html5+css3实现圆角无刷新表单带输入验证功能代码
2015/08/21 Javascript
JavaScript实现点击按钮字体放大、缩小
2016/02/29 Javascript
JS常用函数和常用技巧小结
2016/10/15 Javascript
微信小程序 wxapp导航 navigator详解
2016/10/31 Javascript
Javascript 链式作用域详细介绍
2017/02/23 Javascript
浅谈ES6新增的数组方法和对象
2017/08/08 Javascript
js实现本地时间同步功能
2017/08/26 Javascript
Vue2几种常见开局方式详解
2017/09/09 Javascript
JavaScript模块模式实例详解
2017/10/25 Javascript
js 只比较时间大小的实例
2017/10/26 Javascript
Angularjs之ngModel中的值验证绑定方法
2018/09/13 Javascript
Electron-vue脚手架改造vue项目的方法
2018/10/22 Javascript
使用react context 实现vue插槽slot功能
2019/07/18 Javascript
Windows上node.js的多版本管理工具用法实例分析
2019/11/06 Javascript
Python和Perl绘制中国北京跑步地图的方法
2016/03/03 Python
Python深度优先算法生成迷宫
2018/01/22 Python
python+ffmpeg批量去视频开头的方法
2019/01/09 Python
python GUI库图形界面开发之PyQt5信号与槽基本操作
2020/02/25 Python
Python3 pywin32模块安装的详细步骤
2020/05/26 Python
荷兰多品牌网上鞋店:Stoute Schoenen
2017/08/24 全球购物
越南综合购物网站:Lazada越南
2019/06/10 全球购物
药品业务员岗位职责
2014/04/17 职场文书
2014年国庆节活动总结
2014/08/26 职场文书
涉及车辆房产分割的离婚协议书范文
2014/10/12 职场文书
先进员工事迹材料
2014/12/20 职场文书
还款承诺书范本
2015/01/20 职场文书
销售员岗位职责
2015/02/10 职场文书
用Python提取PDF表格的方法
2021/04/11 Python