如何在FuntionComponent使用react-hook做一个可靠的防抖节流?

1,840 阅读4分钟

笔者之前做项目时候,遇到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)
    }
  }, [])

可以看到最终正确的在两秒后才打印:

演示1.png

似乎就这样看起来一切正常,下面我换一个用例:

  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>

当我快速点击时这时候 控制台的打印:

演示2.png

可以看到在我触发组件更新之后,我们的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次

下面是控制台还有页面:

演示3.png 演示4.png

虽然控制台看起来是正确的,但是由于闭包问题并不能拿到最新的counter1

假如我们把改成:

  const handleClick = useCallback(
    debounce(function () {
      console.count('click1')
      setCounter1(counter1 + 1)
    }, 1000),
    [counter1],
  )

这时候假如还要其他地方会使用setCounter1修改counter1时,这时候显然debounce就失效了(counter1变化时,useCallbackcallback,这时候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,实现防抖和节流。

参考:# React hooks 怎样做防抖?