本文最后更新于:2023年3月13日 下午
场景 网络上已经存在了大量的有关 防抖 和 节流 的文章,为何吾辈还要再写一篇呢?事实上,防抖和节流,吾辈在使用中发现了一些奇怪的问题,并经过了数次的修改,这里主要分享一下吾辈遇到的问题以及是如何解决的。
为什么要用防抖和节流? 因为某些函数触发/调用的频率过快,吾辈需要手动去限制其执行的频率。例如常见的监听滚动条的事件,如果没有防抖处理的话,并且,每次函数执行花费的时间超过了触发的间隔时间的话 – 页面就会卡顿。
演进 初始实现 我们先实现一个简单的去抖函数
1 2 3 4 5 6 7 8 9 function debounce (delay, action ) { let tId return function (...args ) { if (tId) clearTimeout (tId) tId = setTimeout (() => { action (...args) }, delay) } }
测试一下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 const wait = ms => new Promise (resolve => setTimeout (resolve, ms)) ;(async () => { let num = 0 const add = ( ) => ++num add () add () console .log (num) const fn = debounce (10 , add) fn () fn () console .log (num) await wait (20 ) console .log (num) })()
好了,看来基本的效果是实现了的。包装过的函数 fn
调用了两次,却并没有立刻执行,而是等待时间间隔过去之后才最终执行了一次。
this 怎么办? 然而,上面的实现有一个致命的问题,没有处理 this
!当你用在原生的事件处理时或许还不觉得,然而,当你使用了 ES6 class
这类对 this
敏感的代码时,就一定会遇到 this
带来的问题。
例如下面使用 class
来声明一个计数器
1 2 3 4 5 6 7 8 class Counter { constructor ( ) { this .i = 0 } add ( ) { this .i ++ } }
我们可能想在 constructor
中添加新的属性 fn
1 2 3 4 5 6 7 8 9 class Counter { constructor ( ) { this .i = 0 this .fn = debounce (10 , this .add ) } add ( ) { this .i ++ } }
但很遗憾,这里的 this
绑定是有问题的,执行以下代码试试看
1 2 const counter = new Counter () counter.fn ()
会抛出异常 Cannot read property 'i' of undefined
,究其原因就是 this
没有绑定,我们可以手动绑定 this .bind(this)
1 2 3 4 5 6 7 8 9 class Counter { constructor ( ) { this .i = 0 this .fn = debounce (10 , this .add .bind (this )) } add ( ) { this .i ++ } }
但更好的方式是修改 debounce
,使其能够自动绑定 this
1 2 3 4 5 6 7 8 9 function debounce (delay, action ) { let tId return function (...args ) { if (tId) clearTimeout (tId) tId = setTimeout (() => { action.apply (this , args) }, delay) } }
然后,代码将如同预期的运行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 ;(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 ) counter.fn () counter.fn () console .log (counter.i ) await wait (20 ) console .log (counter.i ) })()
返回值呢? 不知道你有没有发现,现在使用 debounce
包装的函数都没有返回值,是完全只有副作用的函数。然而,吾辈还是遇到了需要返回值的场景。 例如:输入停止后,使用 Ajax 请求后台数据判断是否已存在相同的数据。
修改 debounce
成会缓存上一次执行结果并且有初始结果参数的实现
1 2 3 4 5 6 7 8 9 10 11 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 } }
调用代码变成了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 ;(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 ()) console .log (counter.add ()) console .log (counter.fn ()) console .log (counter.fn ()) await wait (20 ) console .log (counter.fn ()) })()
看起来很完美?然而,没有考虑到异步函数是个大失败!
尝试以下测试代码
1 2 3 4 5 6 7 8 9 10 11 ;(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 (4 ).then (i => console .log (i)) await wait (20 ) fn (5 ).then (i => console .log (i)) })()
会抛出异常 fn(...).then is not a function
,因为我们包装过后的函数是同步的,第一次返回的值并不是 Promise
类型。
除非我们修改默认值
1 2 3 4 5 6 7 8 9 10 11 12 ;(async () => { const get = async i => i console .log (await get (1 )) console .log (await get (2 )) const fn = debounce (10 , get, new Promise (resolve => resolve (0 ))) fn (3 ).then (i => console .log (i)) fn (4 ).then (i => console .log (i)) await wait (20 ) fn (5 ).then (i => console .log (i)) })()
支持有返回值的异步函数 支持异步有两种思路
将异步函数包装为同步函数
将包装后的函数异步化
第一种思路实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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 } }
调用方式和同步函数完全一样,当然,是支持异步函数的
1 2 3 4 5 6 7 8 9 10 11 12 ;(async () => { const get = async i => i console .log (await get (1 )) console .log (await get (2 )) const fn = debounce (10 , get, 0 ) console .log (fn (3 )) console .log (fn (4 )) await wait (20 ) console .log (fn (5 )) })()
第二种思路实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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) }) } }
调用方式支持异步的方式
1 2 3 4 5 6 7 8 9 10 11 12 ;(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 (4 ).then (i => console .log (i)) await wait (20 ) fn (5 ).then (i => console .log (i)) })()
可以看到,第一种思路带来的问题是返回值永远会是 旧的 返回值,第二种思路主要问题是将同步函数也给包装成了异步。利弊权衡之下,吾辈觉得第二种思路更加正确一些,毕竟使用场景本身不太可能必须是同步的操作。而且,原本 setTimeout
也是异步的,只是不需要返回值的时候并未意识到这点。
避免原函数信息丢失 后来,有人提出了一个问题,如果函数上面携带其他信息,例如类似于 jQuery
的 $
,既是一个函数,但也同时含有其他属性,如果使用 debounce
就找不到了呀
一开始吾辈立刻想到了复制函数上面的所有可遍历属性,然后想起了 ES6 的 Proxy
特性 – 这实在是太魔法 了。使用 Proxy 解决这个问题将异常的简单 – 因为除了调用函数,其他的一切操作仍然指向原函数!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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) }) }, }) }
测试一下
1 2 3 4 5 6 7 8 ;(async () => { const get = async i => i get.rx = 'rx' console .log (get.rx ) const fn = debounce (10 , get, 0 ) console .log (fn.rx ) })()
实现节流 以这种思路实现一个节流函数 throttle
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 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 中。