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 相关文章推荐
JavaScript中的new的使用方法与注意事项
May 16 Javascript
{}与function(){}选用空对象{}来存放keyValue
May 23 Javascript
Extjs中TabPane如何嵌套在其他网页中实现思路及代码
Jan 27 Javascript
jquery库或JS文件在eclipse下报错问题解决方法
Apr 17 Javascript
使用node+vue.js实现SPA应用
Jan 28 Javascript
JQuery Mobile实现导航栏和页脚
Mar 09 Javascript
浅析bootstrap原理及优缺点
Mar 19 Javascript
node.js 核心http模块,起一个服务器,返回一个页面的实例
Sep 11 Javascript
bootstrap 路径导航 分页 进度条的实例代码
Aug 06 Javascript
vue 使用html2canvas将DOM转化为图片的方法
Sep 11 Javascript
微信小程序实现签到弹窗动画
Sep 21 Javascript
TypeScript 内置高级类型编程示例
Sep 23 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
PHP中使用foreach和引用导致程序BUG的问题介绍
2012/09/05 PHP
php使用正则过滤js脚本代码实例
2014/05/10 PHP
PHP新建类问题分析及解决思路
2015/11/19 PHP
基于php实现的验证码小程序
2016/12/13 PHP
js获取height和width的方法说明
2013/01/06 Javascript
浅谈javascript语法和定时函数
2015/05/03 Javascript
有关jquery与DOM节点操作方法和属性记录
2016/04/15 Javascript
Bootstrap实现带动画过渡的弹出框
2016/08/09 Javascript
详解React 16 中的异常处理
2017/07/28 Javascript
nodejs 图片预览和上传的示例代码
2017/09/30 NodeJs
parabola.js抛物线与加入购物车效果的示例代码
2017/10/25 Javascript
vue-router路由与页面间导航实例解析
2017/11/07 Javascript
vue基于element的区间选择组件
2018/09/07 Javascript
JS实现的贪吃蛇游戏案例详解
2019/05/01 Javascript
js实现随机8位验证码
2020/07/24 Javascript
vue element 关闭当前tab 跳转到上一路由操作
2020/07/22 Javascript
python解决网站的反爬虫策略总结
2016/10/26 Python
Python+selenium实现自动循环扔QQ邮箱漂流瓶
2018/05/29 Python
python3使用flask编写注册post接口的方法
2018/12/28 Python
python+mysql实现教务管理系统
2019/02/20 Python
基于Python的ModbusTCP客户端实现详解
2019/07/13 Python
python Django中models进行模糊查询的示例
2019/07/18 Python
Python queue队列原理与应用案例分析
2019/09/27 Python
pandas 空数据处理方法详解
2019/11/02 Python
Tensorflow中tf.ConfigProto()的用法详解
2020/02/06 Python
python安装后的目录在哪里
2020/06/21 Python
奥地利智能家居和智能生活网上商店:tink.at
2019/10/07 全球购物
NYX Professional Makeup俄罗斯官网:世界知名的化妆品品牌
2019/12/26 全球购物
应届医学毕业生求职信分享
2013/12/02 职场文书
大学生写自荐信的技巧
2014/01/08 职场文书
护理学院专科毕业生求职信
2014/06/28 职场文书
安全承诺书格式范本
2015/04/28 职场文书
2015年禁毒工作总结
2015/04/30 职场文书
2016民族团结先进个人事迹材料
2016/02/26 职场文书
MySQL中in和exists区别详解
2021/06/03 MySQL
详解Vue router路由
2021/11/20 Vue.js