笔者之前做项目时候,遇到qa反馈说,提交一个表单时多次点击生成了多条数据,很显然是因为没有做节流和防抖,但是对于使用react-hook函数组件来说,标准的js版本显然是不适用的(文中详细解释了各种场景下的闭包陷进),本文将写一个react-hook版本的节流和防抖(虽然也有很多第三方库的解决方案,但是如果只是为了使用部分工具函数建议还是自己写)。
什么是节流和防抖?
先来看看百度上通常能找到的概念:
防抖意味着 N 秒内函数只会被执行一次(最后一次),如果 N 秒内再次被触发,则重新计算延迟时间。
节流函数的作用是规定一个单位时间,在这个单位时间内最多只能触发一次函数执行,如果这个单位时间内多次触发函数,只能有一次生效。
什么还是不懂?
假如你经常玩游戏你可以这样理解:
- 对于防抖来说:防抖就是技能释放需要的固定施法前摇,反复释放会重新计算前摇,前摇结束技能施放。
- 对于节流来说:节流就是技能的CD(冷却时间),当处于CD状态,反复释放什么也不会发生,只有当CD转好,技能才能成功正确释放。
这样是不是会更好理解。
常见的js版本防抖节流
防抖:
function debounce(fn, delay) {
let timer = null
return function () {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn.call(this)
timer = null
}, delay)
}
}
节流:
function throttle(fn, delay) {
let timer = null
return function () {
if (timer) return
fn.call(this)
timer = setTimeout(() => {
timer = null
}, delay);
}
}
为什么在react中不能正确使用?
这里先拿防抖做示例(ts版本):
function debounce(fn: Function, delay: number) {
let timer: NodeJS.Timer | null = null
return function () {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn.call(this)
timer = null
}, delay)
}
}
测试代码如下:
const handleClick = debounce(
() => {
console.log('you click!')
}, 1001
)
useEffect(() => {
// 模拟点击两次
(() => {
handleClick()
setTimeout(() => {
handleClick()
}, 1000)
})()
const t = setInterval(() => {
console.log('1秒过去了')
}, 1000);
return () => {
clearInterval(t)
}
}, [])
可以看到最终正确的在两秒后才打印:
似乎就这样看起来一切正常,下面我换一个用例:
const [counter1, setCounter1] = useState(0);
const [counter2, setCounter2] = useState(0);
const handleClick = debounce(function () {
console.count('click1')
setCounter1(counter1 + 1)
}, 1000)
useEffect(function () {
const t = setInterval(() => {
setCounter2(x => x + 1)
}, 500);
return () => clearInterval(t)
}, [])
return <div style={{ padding: 30 }}>
<button
onClick={handleClick}
>click</button>
<p>c1:{counter1}c2:{counter2}</p>
</div>
当我快速点击时这时候 控制台的打印:
可以看到在我触发组件更新之后,我们的debounce失效了。
有人可能会说,使用useCallback缓存一下不就行了,是的我们使用useCallback确实可以解决这个问题,但是我们再一个用例:
const [counter1, setCounter1] = useState(0);
const [counter2, setCounter2] = useState(0);
const handleClick = useCallback(
debounce(function () {
console.count('click1')
setCounter1(counter1 + 1)
}, 1000),
[],
)
useEffect(function () {
const t = setInterval(() => {
setCounter2(x => x + 1)
}, 500);
return () => clearInterval(t)
}, [])
return <div style={{ padding: 30 }}>
<button
onClick={handleClick}
>click</button>
<p>c1:{counter1}c2:{counter2}</p>
</div>
}
这里描述一下我的操作:我进行了三次秒内连击5次
下面是控制台还有页面:
虽然控制台看起来是正确的,但是由于闭包问题并不能拿到最新的counter1
假如我们把改成:
const handleClick = useCallback(
debounce(function () {
console.count('click1')
setCounter1(counter1 + 1)
}, 1000),
[counter1],
)
这时候假如还要其他地方会使用setCounter1修改counter1时,这时候显然debounce就失效了(counter1变化时,useCallback的callback,这时候timer又不可靠了):
const [counter1, setCounter1] = useState(0);
const [counter2, setCounter2] = useState(0);
const handleClick = useCallback(
debounce(function () {
console.count('click1')
setCounter1(counter1 + 1)
}, 1000),
[counter1],
)
useEffect(function () {
const t = setInterval(() => {
setCounter2(x => x + 1)
setCounter1(x => x + 1)
}, 500);
return () => clearInterval(t)
}, [])
return <div style={{ padding: 30 }}>
<button
onClick={handleClick}
>click</button>
<p>c1:{counter1}c2:{counter2}</p>
</div>
}
这里的bug不太方便演示,你可以自己测试一下,这里会发生一下闪动。
这时候又会有人说啦,你使用函数拿到上一轮的counter1更新counter1不就行了:
const handleClick = useCallback(
debounce(function () {
console.count('click1')
setCounter1(x => x + 1)
}, 1000),
[],
)
是的这时候确实是能正确的setCounter1防抖功能又行了,但是我再换个需求呢?
const handleClick = useCallback(
debounce(function () {
console.count('click1')
setCounter1(x => x + 1)
setCounter2(counter1 + 1)
}, 1000),
[],
)
这时候就死局了,如果你写依赖debounce又失效了。
如果你对hook比较了解,这时候肯定有一个呼之欲出的hook,没错就是useRef!
useRef生成的ref对象可以保证在所以渲染周期内都不会改变,这样我们就可以把timer绑定在ref上,打造一个可靠的timer
使用useRef打造可靠的防抖节流
这里直接上代码!
/**
*
* @param func 回调函数
* @param delay 延时
* @param isImmediate 第一次点击是否立即执行
* @returns
*/
const useDebounce = (func: Function, delay: number, isImmediate?: boolean) => {
let { current } = useRef<{ timer: NodeJS.Timeout | null }>({ timer: null })
let result: unknown
function debounced(...args: unknown[]) {
if (current.timer) clearTimeout(current.timer)
// 如果第一次需要立即执行
if (isImmediate) {
const callNow = !current.timer
current.timer = setTimeout(() => {
current.timer = null
}, delay)
if (callNow) result = func.apply(null, args)
} else {
current.timer = setTimeout(() => {
current.timer = null
result = func.apply(null, args)
}, delay)
}
return result
};
// 清除的debounce接口
debounced.clear = () => {
if (current.timer) clearTimeout(current.timer)
current.timer = null
};
return debounced
}
export default useDebounce
节流就留给聪明的你写吧!
总结
由于react-hook的更新机制,每次更新都会反复重新执行,因此每次渲染都是一个新的闭包,在这样的场景下,常规的js版本防抖节流会变得不可靠,甚至失效。因此我们需要依赖react的可靠不变useRef挂载一个可靠的timer,实现防抖和节流。