如何在React Hook中使用节流或防抖?

142

我正在尝试在函数组件中使用来自lodashthrottle方法,例如:

const App = () => {
  const [value, setValue] = useState(0)
  useEffect(throttle(() => console.log(value), 1000), [value])
  return (
    <button onClick={() => setValue(value + 1)}>{value}</button>
  )
}

由于 useEffect 内部的方法在每次渲染时都会重新声明,因此节流效果无法正常工作。

有没有简单的解决方案(不需要将节流实现移到组件外)?


1
你是否可以将被限制的函数定义在 App 组件之外,并在 useEffect 函数中调用它? - Tholle
是的,我尝试过并且它可以工作,但在我的情况下,它不太优雅,因为我在节流方法中使用组件变量。 - Alexandre Annic
28个回答

123

经过一段时间后,我相信使用setTimeout/clearTimeout(并将其移动到单独的自定义钩子中)来处理事情比使用函数式帮助程序更容易。处理后者会在我们将其应用于useCallback时创建额外的挑战,因为它可能会因依赖项更改而重新创建,但我们不想重置延迟运行。

以下是原始答案

您可能(并且可能需要)使用useRef在呈现之间存储值。就像对计时器建议的那样一样

就像这样

const App = () => {
  const [value, setValue] = useState(0)
  const throttled = useRef(throttle((newValue) => console.log(newValue), 1000))

  useEffect(() => throttled.current(value), [value])

  return (
    <button onClick={() => setValue(value + 1)}>{value}</button>
  )
}

关于 useCallback:
它也可以像这样工作
const throttled = useCallback(throttle(newValue => console.log(newValue), 1000), []);

但是,如果我们尝试在更改value后重新创建回调函数:

const throttled = useCallback(throttle(() => console.log(value), 1000), [value]);

我发现当value被改变时,回调函数会立即重新创建并执行,因此我们可能发现它不会延迟执行。

因此,在需要延迟运行的情况下,使用useCallback并没有提供显著的优势。这取决于您自己。

[更新]最初是这样的

  const throttled = useRef(throttle(() => console.log(value), 1000))

  useEffect(throttled.current, [value])

但这样一来,throttled.current 就被绑定到了初始值(0),并且通过闭包的方式无法改变,即使在下一次渲染时也是如此。因此,在将函数推入 useRef 时要小心,因为会受到闭包特性的影响。

1
也许我错过了这一部分,useRef 的初始值会形成闭包的初始值。 - skyboyer
1
@mikes 这取决于(对于lodash的版本,有leadingtrailing选项来配置 https://github.com/lodash/lodash/blob/master/throttle.js) - skyboyer
2
我们可以使用 useRef 来创建回调并保留它,但我认为最好使用 useCallback,即使在必要时传递所需的变量,这种情况很少发生。我们可以使用 setValueuseCallback 中更改值,而无需将 value 添加到依赖项数组中,甚至可以使用 setValue(previous => ...) 访问先前的值。如果我们需要直接访问值而不更改它,则可以像您在示例中使用 useRef 一样将其作为参数传递,例如 useCallback(throttle((value) => { ... }, 1000), []) - Christos Lytras
42
这个答案的哪一部分是实际的答案?它有点跑题。 - coler-j
19
这个答案太令人困惑了,同意@coler-j的观点。 - alexr89
显示剩余5条评论

79

我创建了自己的定制钩子 useDebouncedEffect, 它将等待状态在延迟时间内没有更新后才执行 useEffect.

在这个示例中, 当你停止点击按钮1秒后,该效果将记录到控制台。

沙盒示例 https://codesandbox.io/s/react-use-debounced-effect-6jppw

App.jsx

import { useState } from "react";
import { useDebouncedEffect } from "./useDebouncedEffect";

const App = () => {
  const [value, setValue] = useState(0)

  useDebouncedEffect(() => console.log(value), [value], 1000);

  return (
    <button onClick={() => setValue(value + 1)}>{value}</button>
  )
}

export default App;

useDebouncedEffect.js

import { useEffect } from "react";

export const useDebouncedEffect = (effect, deps, delay) => {
    useEffect(() => {
        const handler = setTimeout(() => effect(), delay);

        return () => clearTimeout(handler);
    // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [...(deps || []), delay]);
}

如果您不希望看到警告并且要避免linter因为没有作为依赖项而抱怨,则需要添加禁用exhaustive-deps的注释。将effect作为依赖项添加将在每次渲染时触发useEffect。相反,您可以将检查添加到useDebouncedEffect以确保它正在传递所有依赖项(请参见下文)。

useDebouncedEffect添加exhaustive dependencies检查

如果您想让eslint检查useDebouncedEffect的exhaustive dependencies,可以将其添加到package.json中的eslint配置中。

  "eslintConfig": {
    "extends": [
      "react-app"
    ],
    "rules": {
      "react-hooks/exhaustive-deps": ["warn", {
        "additionalHooks": "useDebouncedEffect"
      }]
    }
  },

https://github.com/facebook/react/tree/master/packages/eslint-plugin-react-hooks#advanced-configuration


7
如果你想知道为什么需要使用 useCallback,我认为原因是:JavaScript 中的函数没有引用相等性(即 () => {} === () => {} // false)。因此,每次组件重新渲染时,effect 不同于之前的。但是,使用 useCallback 可以告诉 React:“只有在我的 deps 改变时才认为我已经改变!” - David
2
@David,函数绝对具有引用相等性,这就是为什么你首先需要使用“useCallback”的原因。你的例子是结构相等,而不是引用相等。 - Kevin Beal
@KevinBeal,我之前好像没听说过“结构相等”的术语,但是在 Kotlin 中进行了快速的互联网搜索,发现引用相等是 ===,而结构相等则是 ==。按照这个逻辑,我认为在 JavaScript 中函数具有结构相等性。 - David
@David 结构相等只是指内部的值相同,具有相同的键、值等。这是值相等或其他你想称之为的东西。 - Kevin Beal
为什么在 useDebouncedEffect 的 useEffect 中将 delay 作为依赖项? - Muhammad
@Muhammad 如果延迟发生变化,您希望更新useEffect。只有当延迟没有硬编码时才会有影响。 - Todd Skelton

47

useThrottleuseDebounce

如何同时使用它们

const App = () => {
  const [value, setValue] = useState(0);
  // called at most once per second (same API with useDebounce)
  const throttledCb = useThrottle(() => console.log(value), 1000);
  // usage with useEffect: invoke throttledCb on value change
  useEffect(throttledCb, [value]);
  // usage as event handler
  <button onClick={throttledCb}>log value</button>
  // ... other render code
};

useThrottle (Lodash)

import _ from "lodash"

function useThrottle(cb, delay) {
  const options = { leading: true, trailing: false }; // add custom lodash options
  const cbRef = useRef(cb);
  // use mutable ref to make useCallback/throttle not depend on `cb` dep
  useEffect(() => { cbRef.current = cb; });
  return useCallback(
    _.throttle((...args) => cbRef.current(...args), delay, options),
    [delay]
  );
}

const App = () => {
  const [value, setValue] = useState(0);
  const invokeDebounced = useThrottle(
    () => console.log("changed throttled value:", value),
    1000
  );
  useEffect(invokeDebounced, [value]);
  return (
    <div>
      <button onClick={() => setValue(value + 1)}>{value}</button>
      <p>value will be logged at most once per second.</p>
    </div>
  );
};

function useThrottle(cb, delay) {
  const options = { leading: true, trailing: false }; // pass custom lodash options
  const cbRef = useRef(cb);
  useEffect(() => {
    cbRef.current = cb;
  });
  return useCallback(
    _.throttle((...args) => cbRef.current(...args), delay, options),
    [delay]
  );
}

ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.0/umd/react.production.min.js" integrity="sha256-32Gmw5rBDXyMjg/73FgpukoTZdMrxuYW7tj8adbN8z4=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.0/umd/react-dom.production.min.js" integrity="sha256-bjQ42ac3EN0GqK40pC9gGi/YixvKyZ24qMP/9HiGW7w=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.15/lodash.min.js" integrity="sha256-VeNaFBVDhoX3H+gJ37DpT/nTuZTdjYro9yBruHjVmoQ=" crossorigin="anonymous"></script>
<script>var { useReducer, useEffect, useState, useRef, useCallback } = React</script>
<div id="root"></div>

useDebounce (Lodash)

import _ from "lodash"

function useDebounce(cb, delay) {
  // ...
  const inputsRef = useRef({cb, delay}); // mutable ref like with useThrottle
  useEffect(() => { inputsRef.current = { cb, delay }; }); //also track cur. delay
  return useCallback(
    _.debounce((...args) => {
        // Debounce is an async callback. Cancel it, if in the meanwhile
        // (1) component has been unmounted (see isMounted in snippet)
        // (2) delay has changed
        if (inputsRef.current.delay === delay && isMounted())
          inputsRef.current.cb(...args);
      }, delay, options
    ),
    [delay, _.debounce]
  );
}

const App = () => {
  const [value, setValue] = useState(0);
  const invokeDebounced = useDebounce(
    () => console.log("debounced", value),
    1000
  );
  useEffect(invokeDebounced, [value]);
  return (
    <div>
      <button onClick={() => setValue(value + 1)}>{value}</button>
      <p> Logging is delayed until after 1 sec. has elapsed since the last invocation.</p>
    </div>
  );
};

function useDebounce(cb, delay) {
  const options = {
    leading: false,
    trailing: true
  };
  const inputsRef = useRef(cb);
  const isMounted = useIsMounted();
  useEffect(() => {
    inputsRef.current = { cb, delay };
  });

  return useCallback(
    _.debounce(
      (...args) => {
        // Don't execute callback, if (1) component in the meanwhile 
        // has been unmounted or (2) delay has changed
        if (inputsRef.current.delay === delay && isMounted())
          inputsRef.current.cb(...args);
      },
      delay,
      options
    ),
    [delay, _.debounce]
  );
}

function useIsMounted() {
  const isMountedRef = useRef(true);
  useEffect(() => {
    return () => {
      isMountedRef.current = false;
    };
  }, []);
  return () => isMountedRef.current;
}

ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.0/umd/react.production.min.js" integrity="sha256-32Gmw5rBDXyMjg/73FgpukoTZdMrxuYW7tj8adbN8z4=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.0/umd/react-dom.production.min.js" integrity="sha256-bjQ42ac3EN0GqK40pC9gGi/YixvKyZ24qMP/9HiGW7w=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.15/lodash.min.js" integrity="sha256-VeNaFBVDhoX3H+gJ37DpT/nTuZTdjYro9yBruHjVmoQ=" crossorigin="anonymous"></script>
<script>var { useReducer, useEffect, useState, useRef, useCallback } = React</script>
<div id="root"></div>


自定义

1. 您可以使用自己的 throttle(节流)debounce(防抖) 代码替换 Lodash,例如:

const debounceImpl = (cb, delay) => {
  let isDebounced = null;
  return (...args) => {
    clearTimeout(isDebounced);
    isDebounced = setTimeout(() => cb(...args), delay);
  };
};

const throttleImpl = (cb, delay) => {
  let isThrottled = false;
  return (...args) => {
    if (isThrottled) return;
    isThrottled = true;
    cb(...args);
    setTimeout(() => {
      isThrottled = false;
    }, delay);
  };
};

const App = () => {
  const [value, setValue] = useState(0);
  const invokeThrottled = useThrottle(
    () => console.log("throttled", value),
    1000
  );
  const invokeDebounced = useDebounce(
    () => console.log("debounced", value),
    1000
  );
  useEffect(invokeThrottled, [value]);
  useEffect(invokeDebounced, [value]);
  return <button onClick={() => setValue(value + 1)}>{value}</button>;
};

function useThrottle(cb, delay) {
  const cbRef = useRef(cb);
  useEffect(() => {
    cbRef.current = cb;
  });
  return useCallback(
    throttleImpl((...args) => cbRef.current(...args), delay),
    [delay]
  );
}

function useDebounce(cb, delay) {
  const cbRef = useRef(cb);
  useEffect(() => {
    cbRef.current = cb;
  });
  return useCallback(
    debounceImpl((...args) => cbRef.current(...args), delay),
    [delay]
  );
}

ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.0/umd/react.production.min.js" integrity="sha256-32Gmw5rBDXyMjg/73FgpukoTZdMrxuYW7tj8adbN8z4=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.0/umd/react-dom.production.min.js" integrity="sha256-bjQ42ac3EN0GqK40pC9gGi/YixvKyZ24qMP/9HiGW7w=" crossorigin="anonymous"></script>
<script>var { useReducer, useEffect, useState, useRef, useCallback } = React</script>
<div id="root"></div>

2. 如果始终与 useEffect 一起使用,则可以缩短 useThrottle 的长度(对于 useDebounce 也是如此):

const App = () => {
  // useEffect now is contained inside useThrottle
  useThrottle(() => console.log(value), 1000, [value]);
  // ...
};

const App = () => {
  const [value, setValue] = useState(0);
  useThrottle(() => console.log(value), 1000, [value]);
  return (
    <div>
      <button onClick={() => setValue(value + 1)}>{value}</button>
      <p>value will be logged at most once per second.</p>
    </div>
  );
};

function useThrottle(cb, delay, additionalDeps) {
  const options = { leading: true, trailing: false }; // pass custom lodash options
  const cbRef = useRef(cb);
  const throttledCb = useCallback(
    _.throttle((...args) => cbRef.current(...args), delay, options),
    [delay]
  );
  useEffect(() => {
    cbRef.current = cb;
  });
  // set additionalDeps to execute effect, when other values change (not only on delay change)
  useEffect(throttledCb, [throttledCb, ...additionalDeps]);
}

ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.0/umd/react.production.min.js" integrity="sha256-32Gmw5rBDXyMjg/73FgpukoTZdMrxuYW7tj8adbN8z4=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.0/umd/react-dom.production.min.js" integrity="sha256-bjQ42ac3EN0GqK40pC9gGi/YixvKyZ24qMP/9HiGW7w=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.15/lodash.min.js" integrity="sha256-VeNaFBVDhoX3H+gJ37DpT/nTuZTdjYro9yBruHjVmoQ=" crossorigin="anonymous"></script>
<script>var { useReducer, useEffect, useState, useRef, useCallback } = React</script>
<div id="root"></div>


为什么要使用 useEffect(() => { cbRef.current = cb; }); 而不带任何依赖项?这意味着我们在每次重新渲染时都会运行 effect,那么为什么不直接赋值而不使用 useEffect 呢? - ogostos
3
很好的问题 - 这意味着cbRef始终包含最新的回调。可变的ref可以像钩子的实例变量一样使用 - 这里是Overreacted博客中使用setInterval的示例。渲染阶段也应该是纯净的,没有副作用,例如与React并发模式兼容。这就是我们在useEffect中包装赋值的原因。 - ford04
当我使用useThrottle(Lodash)时,似乎出现了一个错误:“TypeError:无法读取未定义的属性'apply'”。除此之外,我还有一个ESLint错误,说“React Hook useCallback接收到一个其依赖项未知的函数。改为传递内联函数。” - alexr89
为什么useThrottleuseDebounce的实现不同,也就是说,为什么useDebounce依赖于_.debounce并处理取消,而useThrottle则没有?你写了注释// Debounce is an async callback,但对于throttle()也是如此,因此这并不是差异的解释。 - nh2
此外,你写道 Debounce is an async callback. Cancel it,但是你没有取消它(也就是说,你没有调用 lodash 的 cancel 函数)。 - nh2
这个很好用!我唯一需要做的就是在 useDebounce 中删除 options 参数,因为它没有被声明。 - Justin

21

使用 useCallback 钩子来实现防抖。

import React, { useState, useCallback } from 'react';
import debounce from 'lodash.debounce';

function App() {
    const [value, setValue] = useState('');
    const [dbValue, saveToDb] = useState(''); // would be an API call normally

    // highlight-starts
    const debouncedSave = useCallback(
        debounce(nextValue => saveToDb(nextValue), 1000),
        [], // will be created only once initially
    );
    // highlight-ends

    const handleChange = event => {
        const { value: nextValue } = event.target;
        setValue(nextValue);
        // Even though handleChange is created on each render and executed
        // it references the same debouncedSave that was created initially
        debouncedSave(nextValue);
    };

    return <div></div>;
}

4
正确答案。这是最直接的解决方案。 - dcporter7
我尝试了这个,但回调函数从未被调用。 - papillon

21

可能是一个小小的自定义钩子,就像这样:

useDebounce.js

import React, { useState, useEffect } from 'react';

export default (value, timeout) => {
    const [state, setState] = useState(value);

    useEffect(() => {
        const handler = setTimeout(() => setState(value), timeout);

        return () => clearTimeout(handler);
    }, [value, timeout]);

    return state;
}

用法示例:

import React, { useEffect } from 'react';

import useDebounce from '/path/to/useDebounce';

const App = (props) => {
    const [state, setState] = useState({title: ''});    
    const debouncedTitle = useDebounce(state.title, 1000);

    useEffect(() => {
        // do whatever you want with state.title/debouncedTitle
    }, [debouncedTitle]);        

    return (
        // ...
    );
}
// ...

注意: 你可能已经知道,useEffect 总是在初始渲染时运行,因此如果你使用我的答案,你可能会看到你的组件渲染两次。不用担心,你只需要编写另一个自定义钩子。查看我的其他答案获取更多信息。


我不明白如何避免第二次(或第一次)渲染,即使使用了链接钩子。你能否提供一个例子?谢谢。 - andreapier
@andreapier 我已经添加了另一个自定义钩子的链接,以防止在初始渲染时进行渲染。如果您没有看到它,这是链接:https://dev59.com/VlQJ5IYBdhLWcg3wtoSJ#57941438 - Mehdi Dehghani
是的,我看到了。我的问题是如何让它们两个一起工作。然而,我转向另一种解决方案,因为这个(在我看来)存在太多问题。 - andreapier
如果你想要将 useDebounceuseDidMountEffect 一起使用,你只需要在上面的例子中用 useDidMountEffect 替换 useEffect 即可。 - Mehdi Dehghani

8

我写了两个简单的hooks (use-throttled-effectuse-debounced-effect),针对这种情况,也许对寻找简单解决方案的其他人有用。

import React, { useState } from 'react';
import useThrottledEffect  from 'use-throttled-effect';

export default function Input() {
  const [count, setCount] = useState(0);

  useEffect(()=>{
    const interval = setInterval(() => setCount(count=>count+1) ,100);
    return ()=>clearInterval(interval);
  },[])

  useThrottledEffect(()=>{
    console.log(count);     
  }, 1000 ,[count]);

  return (
    {count}
  );
}

7

我想使用 useState 来处理控制节流和防抖的输入,加入这个活动:

// import { useState, useRef } from 'react' // nomral import
const { useState, useRef } = React // inline import

// Throttle

const ThrottledInput = ({ onChange, delay = 500 }) => {
  const t = useRef()
  
  const handleChange = ({ target }) => {
    if (!t.current) {
      t.current = setTimeout(() => {
        onChange(target.value)
        clearTimeout(t.current)
        t.current = null
      }, delay)
    }
  }
  
  return (
    <input
      placeholder="throttle"
      onChange={handleChange}
    />
  )
}


// Debounce

const DebouncedInput = ({ onChange, delay = 500 }) => {
  const t = useRef()
  
  const handleChange = ({ target }) => {
    clearTimeout(t.current)
    t.current = setTimeout(() => onChange(target.value), delay)
  }
  
  return (
    <input
      placeholder="debounce"
      onChange={handleChange}
    />
  )
}

// ----

ReactDOM.render(<div>
  <ThrottledInput onChange={console.log} />
  <DebouncedInput onChange={console.log} />
</div>, document.getElementById('root'))
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>


1
整洁,使用并点赞!只是一个小问题:在节流函数(Throttle)示例中,clearTimeout(t) 不应该是 clearTimeout(t.current) 吗? - HynekS
实际上,谢谢。我已经修复了! - gazdagergo
很好的解决方案,这就是我正在使用的,谢谢。请问一个问题,假设我们有很大的延迟并且组件卸载了,那么我们需要添加一个带有另一个重置参数的包装器,还是只需清除超时?(试图模拟库的.cancel等效) - nagiatzi
如果我们只是添加一个空的 useEffect 钩子,其中包含一个带有 clearTimeout(t) 的清理函数,那么这样能解决问题吗? - gazdagergo
or t.current sorry - gazdagergo

6

还有一种实现方式。自定义钩子:

function useThrottle (func, delay) {
  const [timeout, saveTimeout] = useState(null);
    
  const throttledFunc = function () {
    if (timeout) {
      clearTimeout(timeout);
    }

    const newTimeout = setTimeout(() => {
      func(...arguments);
      if (newTimeout === timeout) {
        saveTimeout(null);
      }
    }, delay);

    saveTimeout(newTimeout);
  }

  return throttledFunc;
}

和使用:

const throttledFunc = useThrottle(someFunc, 200);

希望这能帮助到某些人。

4

使用 lodash 的防抖函数,这里是我的做法:

import debounce from 'lodash/debounce'

// The function that we want to debounce, for example the function that makes the API calls
const getUsers = (event) => {
// ...
}


// The magic!
const debouncedGetUsers = useCallback(debounce(getUsers, 500), [])

在你的JSX中:
<input value={value} onChange={debouncedGetUsers} />

迄今为止最简单的解决方案 - Raine Revere

4
你可以使用useMemo钩子来优化你的节流事件处理程序。 以下是示例代码:
const App = () => {
  const [value, setValue] = useState(0);

  // ORIGINAL EVENT HANDLER
  function eventHandler(event) {
    setValue(value + 1);
  }

  // THROTTLED EVENT HANDLER
  const throttledEventHandler = useMemo(() => throttle(eventHandler, 1000), [value]);
  
  return (
    <button onClick={throttledEventHandler}>Throttled Button with value: {value}</button>
  )
}

这个备忘录更新了状态,可以吗?我对React的这条指令感到疑惑: “请记住,传递给useMemo的函数在渲染期间运行。不要在那里做任何你在渲染时通常不会做的事情。例如,副作用应该放在useEffect中,而不是useMemo中。” - user2078023

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接