JavaScript 防抖和节流遇见的奇怪问题及解决
场景
网络上已经存在了大量的有关防抖和节流的文章,为何吾辈还要再写一篇呢?事实上,防抖和节流,吾辈在使用中发现了一些奇怪的问题,并经过了数次的修改,这里主要分享一下吾辈遇到的问题以及是如何解决的。
为什么要用防抖和节流?
因为某些函数触发/调用的频率过快,吾辈需要手动去限制其执行的频率。例如常见的监听滚动条的事件,如果没有防抖处理的话,并且,每次函数执行花费的时间超过了触发的间隔时间的话–页面就会卡顿。
演进
初始实现
我们先实现一个简单的去抖函数
functiondebounce(delay,action){ lettId returnfunction(...args){ if(tId)clearTimeout(tId) tId=setTimeout(()=>{ action(...args) },delay) } }
测试一下
//使用Promise简单封装setTimeout,下同 constwait=ms=>newPromise(resolve=>setTimeout(resolve,ms)) ;(async()=>{ letnum=0 constadd=()=>++num add() add() console.log(num)//2 constfn=debounce(10,add) fn() fn() console.log(num)//2 awaitwait(20) console.log(num)//3 })()
好了,看来基本的效果是实现了的。包装过的函数fn调用了两次,却并没有立刻执行,而是等待时间间隔过去之后才最终执行了一次。
this怎么办?
然而,上面的实现有一个致命的问题,没有处理this!当你用在原生的事件处理时或许还不觉得,然而,当你使用了ES6class这类对this敏感的代码时,就一定会遇到this带来的问题。
例如下面使用class来声明一个计数器
classCounter{ constructor(){ this.i=0 } add(){ this.i++ } }
我们可能想在constructor中添加新的属性fn
classCounter{ constructor(){ this.i=0 this.fn=debounce(10,this.add) } add(){ this.i++ } }
但很遗憾,这里的this绑定是有问题的,执行以下代码试试看
constcounter=newCounter() counter.fn()//Cannotreadproperty'i'ofundefined
会抛出异常Cannotreadproperty'i'ofundefined,究其原因就是this没有绑定,我们可以手动绑定this.bind(this)
classCounter{ constructor(){ this.i=0 this.fn=debounce(10,this.add.bind(this)) } add(){ this.i++ } }
但更好的方式是修改debounce,使其能够自动绑定this
functiondebounce(delay,action){ lettId returnfunction(...args){ if(tId)clearTimeout(tId) tId=setTimeout(()=>{ action.apply(this,args) },delay) } }
然后,代码将如同预期的运行
;(async()=>{ classCounter{ constructor(){ this.i=0 this.fn=debounce(10,this.add) } add(){ this.i++ } } constcounter=newCounter() counter.add() counter.add() console.log(counter.i)//2 counter.fn() counter.fn() console.log(counter.i)//2 awaitwait(20) console.log(counter.i)//3 })()
返回值呢?
不知道你有没有发现,现在使用debounce包装的函数都没有返回值,是完全只有副作用的函数。然而,吾辈还是遇到了需要返回值的场景。
例如:输入停止后,使用Ajax请求后台数据判断是否已存在相同的数据。
修改debounce成会缓存上一次执行结果并且有初始结果参数的实现
functiondebounce(delay,action,init=undefined){ letflag letresult=init returnfunction(...args){ if(flag)clearTimeout(flag) flag=setTimeout(()=>{ result=action.apply(this,args) },delay) returnresult } }
调用代码变成了
;(async()=>{ classCounter{ constructor(){ this.i=0 this.fn=debounce(10,this.add,0) } add(){ return++this.i } } constcounter=newCounter() console.log(counter.add())//1 console.log(counter.add())//2 console.log(counter.fn())//0 console.log(counter.fn())//0 awaitwait(20) console.log(counter.fn())//3 })()
看起来很完美?然而,没有考虑到异步函数是个大失败!
尝试以下测试代码
;(async()=>{ constget=asynci=>i console.log(awaitget(1)) console.log(awaitget(2)) constfn=debounce(10,get,0) fn(3).then(i=>console.log(i))//fn(...).thenisnotafunction fn(4).then(i=>console.log(i)) awaitwait(20) fn(5).then(i=>console.log(i)) })()
会抛出异常fn(...).thenisnotafunction,因为我们包装过后的函数是同步的,第一次返回的值并不是Promise类型。
除非我们修改默认值
;(async()=>{ constget=asynci=>i console.log(awaitget(1)) console.log(awaitget(2)) //注意,修改默认值为Promise constfn=debounce(10,get,newPromise(resolve=>resolve(0))) fn(3).then(i=>console.log(i))//0 fn(4).then(i=>console.log(i))//0 awaitwait(20) fn(5).then(i=>console.log(i))//4 })()
支持有返回值的异步函数
支持异步有两种思路
- 将异步函数包装为同步函数
- 将包装后的函数异步化
第一种思路实现
functiondebounce(delay,action,init=undefined){ letflag letresult=init returnfunction(...args){ if(flag)clearTimeout(flag) flag=setTimeout(()=>{ consttemp=action.apply(this,args) if(tempinstanceofPromise){ temp.then(res=>(result=res)) }else{ result=temp } },delay) returnresult } }
调用方式和同步函数完全一样,当然,是支持异步函数的
;(async()=>{ constget=asynci=>i console.log(awaitget(1)) console.log(awaitget(2)) //注意,修改默认值为Promise constfn=debounce(10,get,0) console.log(fn(3))//0 console.log(fn(4))//0 awaitwait(20) console.log(fn(5))//4 })()
第二种思路实现
constdebounce=(delay,action,init=undefined)=>{ letflag letresult=init returnfunction(...args){ returnnewPromise(resolve=>{ if(flag)clearTimeout(flag) flag=setTimeout(()=>{ result=action.apply(this,args) resolve(result) },delay) setTimeout(()=>{ resolve(result) },delay) }) } }
调用方式支持异步的方式
;(async()=>{ constget=asynci=>i console.log(awaitget(1)) console.log(awaitget(2)) //注意,修改默认值为Promise constfn=debounce(10,get,0) fn(3).then(i=>console.log(i))//0 fn(4).then(i=>console.log(i))//4 awaitwait(20) fn(5).then(i=>console.log(i))//5 })()
可以看到,第一种思路带来的问题是返回值永远会是旧的返回值,第二种思路主要问题是将同步函数也给包装成了异步。利弊权衡之下,吾辈觉得第二种思路更加正确一些,毕竟使用场景本身不太可能必须是同步的操作。而且,原本setTimeout也是异步的,只是不需要返回值的时候并未意识到这点。
避免原函数信息丢失
后来,有人提出了一个问题,如果函数上面携带其他信息,例如类似于jQuery的$,既是一个函数,但也同时含有其他属性,如果使用debounce就找不到了呀
一开始吾辈立刻想到了复制函数上面的所有可遍历属性,然后想起了ES6的Proxy特性–这实在是太魔法了。使用Proxy解决这个问题将异常的简单–因为除了调用函数,其他的一切操作仍然指向原函数!
constdebounce=(delay,action,init=undefined)=>{ letflag letresult=init returnnewProxy(action,{ apply(target,thisArg,args){ returnnewPromise(resolve=>{ if(flag)clearTimeout(flag) flag=setTimeout(()=>{ resolve((result=Reflect.apply(target,thisArg,args))) },delay) setTimeout(()=>{ resolve(result) },delay) }) }, }) }
测试一下
;(async()=>{ constget=asynci=>i get.rx='rx' console.log(get.rx)//rx constfn=debounce(10,get,0) console.log(fn.rx)//rx })()
实现节流
以这种思路实现一个节流函数throttle
/** *函数节流 *节流(throttle)让一个函数不要执行的太频繁,减少执行过快的调用,叫节流 *类似于上面而又不同于上面的函数去抖,包装后函数在上一次操作执行过去了最小间隔时间后会直接执行,否则会忽略该次操作 *与上面函数去抖的明显区别在连续操作时会按照最小间隔时间循环执行操作,而非仅执行最后一次操作 *注:该函数第一次调用一定会执行,不需要担心第一次拿不到缓存值,后面的连续调用都会拿到上一次的缓存值 *注:返回函数结果的高阶函数需要使用{@linkProxy}实现,以避免原函数原型链上的信息丢失 * *@param{Number}delay最小间隔时间,单位为ms *@param{Function}action真正需要执行的操作 *@return{Function}包装后有节流功能的函数。该函数是异步的,与需要包装的函数{@linkaction}是否异步没有太大关联 */ constthrottle=(delay,action)=>{ letlast=0 letresult returnnewProxy(action,{ apply(target,thisArg,args){ returnnewPromise(resolve=>{ constcurr=Date.now() if(curr-last>delay){ result=Reflect.apply(target,thisArg,args) last=curr resolve(result) return } resolve(result) }) }, }) }
总结
嘛,实际上这里的防抖和节流仍然是简单的实现,其他的像取消防抖/强制刷新缓存等功能尚未实现。当然,对于吾辈而言功能已然足够了,也被放到了公共的函数库rx-util中。
以上就是JavaScript防抖和节流遇见的奇怪问题及解决的详细内容,更多关于JavaScript防抖和节流的资料请关注毛票票其它相关文章!