React 中的去抖动和超时

Debouncing and Timeout in React

我这里有一个输入字段,它在每种类型上都会调度一个 redux 操作。 我已经放了一个 useDebounce 以便它不会很重。问题是它说 Hooks can only be called inside of the body of a function component. 正确的方法是什么?

useTimeout

import { useCallback, useEffect, useRef } from "react";

export default function useTimeout(callback, delay) {
  const callbackRef = useRef(callback);
  const timeoutRef = useRef();

  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  const set = useCallback(() => {
    timeoutRef.current = setTimeout(() => callbackRef.current(), delay);
  }, [delay]);

  const clear = useCallback(() => {
    timeoutRef.current && clearTimeout(timeoutRef.current);
  }, []);

  useEffect(() => {
    set();
    return clear;
  }, [delay, set, clear]);

  const reset = useCallback(() => {
    clear();
    set();
  }, [clear, set]);

  return { reset, clear };
}

使用去抖

import { useEffect } from "react";
import useTimeout from "./useTimeout";

export default function useDebounce(callback, delay, dependencies) {
  const { reset, clear } = useTimeout(callback, delay);
  useEffect(reset, [...dependencies, reset]);
  useEffect(clear, []);
}

表单组件

import React from "react";
import TextField from "@mui/material/TextField";
import useDebounce from "../hooks/useDebounce";

export default function ProductInputs(props) {
  const { handleChangeProductName = () => {} } = props;

  return (
    <TextField
      fullWidth
      label="Name"
      variant="outlined"
      size="small"
      name="productName"
      value={formik.values.productName}
      helperText={formik.touched.productName ? formik.errors.productName : ""}
      error={formik.touched.productName && Boolean(formik.errors.productName)}
      onChange={(e) => {
        formik.setFieldValue("productName", e.target.value);
        useDebounce(() => handleChangeProductName(e.target.value), 1000, [
          e.target.value,
        ]);
      }}
    />
  );
}

看看您对 useDebounce 的实现,它作为一个钩子看起来不是很有用。它似乎已经接管了调用你的函数的工作,并且没有 return 任何东西,但它的大部分实现都是在 useTimeout 中完成的,这也没有做太多...

在我看来,useDebounce 应该 return 是 callback

的“去抖动”版本

这是我对 useDebounce 的看法:

export default function useDebounce(callback, delay) {
  const [debounceReady, setDebounceReady] = useState(true);

  const debouncedCallback = useCallback((...args) => {
    if (debounceReady) {
      callback(...args);
      setDebounceReady(false);
    }
  }, [debounceReady, callback]);

  useEffect(() => {
    if (debounceReady) {
      return undefined;
    }
    const interval = setTimeout(() => setDebounceReady(true), delay);
    return () => clearTimeout(interval);    
  }, [debounceReady, delay]);

  return debouncedCallback;
}

用法类似于:

import React from "react";
import TextField from "@mui/material/TextField";
import useDebounce from "../hooks/useDebounce";

export default function ProductInputs(props) {
  const handleChangeProductName = useCallback((value) => {
    if (props.handleChangeProductName) {
      props.handleChangeProductName(value);
    } else {
      // do something else...
    };
  }, [props.handleChangeProductName]);

  const debouncedHandleChangeProductName = useDebounce(handleChangeProductName, 1000);

  return (
    <TextField
      fullWidth
      label="Name"
      variant="outlined"
      size="small"
      name="productName"
      value={formik.values.productName}
      helperText={formik.touched.productName ? formik.errors.productName : ""}
      error={formik.touched.productName && Boolean(formik.errors.productName)}
      onChange={(e) => {
        formik.setFieldValue("productName", e.target.value);
        debouncedHandleChangeProductName(e.target.value);
      }}
    />
  );
}

我认为 React hooks 不适合限制或去抖功能。根据我对你的问题的理解,你实际上想要去抖 handleChangeProductName 函数。

这是一个简单的高阶函数,您可以使用它来装饰回调函数以对其进行去抖动。如果在超时到期之前再次调用返回的函数,则清除并重新实例化超时。只有当超时到期时,装饰函数才会被调用并传递参数。

const debounce = (fn, delay) => {
  let timerId;
  return (...args) => {
    clearTimeout(timerId);
    timerId = setTimeout(() => fn(...args), delay);
  }
};

用法示例:

export default function ProductInputs({ handleChangeProductName }) {
  const debouncedHandler = useCallback(debounce(handleChangeProductName, 200), []);

  return (
    <TextField
      fullWidth
      label="Name"
      variant="outlined"
      size="small"
      name="productName"
      value={formik.values.productName}
      helperText={formik.touched.productName ? formik.errors.productName : ""}
      error={formik.touched.productName && Boolean(formik.errors.productName)}
      onChange={(e) => {
        formik.setFieldValue("productName", e.target.value);
        debouncedHandler(e.target.value);
      }}
    />
  );
}

如果可能,将 handleChangeProductName 回调作为 prop 传递的父组件应该可以处理创建一个去抖动的、记忆化的处理程序,但上面的应该也能工作。

去抖onChange本身有一些注意事项。比如说,它 必须 是不受控制的组件,因为在受控组件上去抖动 onChange 会导致烦人的打字延迟。

另一个陷阱,我们可能需要立即做某事并在延迟后再做其他事情。比如说,在任何更改后立即显示加载指示器而不是(过时的)搜索结果,但仅在用户停止输入后才发送实际请求。

考虑到所有这些,我建议通过 useEffect:

去抖动回调而不是去抖动回调
const [text, setText] = useState('');
const isValueSettled = useIsSettled(text);

useEffect(() => {
  if (isValueSettled) {
    props.onChange(text);
  }
}, [text, isValueSettled]);

...
  <input value={value} onChange={({ target: { value } }) => setText(value)}

并且useIsSetlled本身会去抖:

function useIsSettled(value, delay = 500) {
  const [isSettled, setIsSettled] = useState(true);
  const isFirstRun = useRef(true);
  const prevValueRef = useRef(value);

  useEffect(() => {
    if (isFirstRun.current) {
      isFirstRun.current = false;
      return;
    }
    setIsSettled(false);
    prevValueRef.current = value;
    const timerId = setTimeout(() => {
      setIsSettled(true);
    }, delay);
    return () => { clearTimeout(timerId); }
  }, [delay, value]);
  if (isFirstRun.current) {
    return true;
  }
  return isSettled && prevValueRef.current === value;
}

其中 isFirstRun 显然可以避免我们在初始渲染后得到“哦,不,用户更改了一些东西”(当 valueundefined 更改为初始值时)。

并且 prevValueRef.current === value 不是必需的部分,但确保我们将在同一个渲染 运行 中获得 useIsSettled 返回 false,而不是在下一个渲染中,仅在 useEffect执行。