使用 useCallback 反应去抖动问题

React debouncing problem with useCallback

在我的 React 组件中,我有这些:

const [vendor,setVendor] = useState("");
const [taggedWith,setTaggedWith] = useState("");

function updateQuery () {
  const filters = [];
  if(vendor) {
    filters.push({
      label: `Vendor: ${vendor}`
    })
  }
  if(taggedWith) {
    filters.push({
      label: `Tagged with: ${taggedWith}`
    })
  }
  props.onUpdate(filters);
}

function debounce (func,delay){
  let timer;
  return function () {
    clearTimeout(timer);
    timer = setTimeout(()=>{
      func();
    },delay);
  };
};

const updateQueryWithDebounce = useCallback(debounce(updateQuery,300),[]);

useEffect(()=>{
  updateQueryWithDebounce();
},[taggedWith,vendor]);

去抖动有效,但问题是,由于 useCallback,updateQuery 函数内的状态变量保持不变。如果我将这些状态传递给 useCallback 的依赖数组,去抖动函数会在每次渲染时重新声明,因此,会创建带有闭包的新函数,这会导致去抖动不起作用。我该如何解决?

您可以使用一个 ref 来存储计时器句柄,这样您就可以在任一状态变量发生变化时取消之前的更新:

const [vendor,setVendor] = useState("");
const [taggedWith,setTaggedWith] = useState("");
const updateRef = useRef(0);

useEffect(() => {
    updateRef.current = setTimeout(() => {
        updateRef.current = 0;
        const filters = [];
        if (vendor) {
            filters.push({
                label: `Vendor: ${vendor}`
            });
        }
        if (taggedWith) {
            filters.push({
                label: `Tagged with: ${taggedWith}`
            });
        }
        props.onUpdate(filters);
    }, 300);
    return () => { // Cleanup callback
        clearTmeout(updateRef.current);
        updateRef.current = 0;
    };
}, [taggedWith, vendor]);

部分代码可以完全从组件中分离出来:

const buildFilters = (taggedWith, vendor) => {
    const filters = [];
    if (vendor) {
        filters.push({
            label: `Vendor: ${vendor}`
        });
    }
    if (taggedWith) {
        filters.push({
            label: `Tagged with: ${taggedWith}`
        });
    }
    return filters;
};

然后组件的主体变成:

const [vendor,setVendor] = useState("");
const [taggedWith,setTaggedWith] = useState("");
const updateRef = useRef(0);

useEffect(() => {
    updateRef.current = setTimeout(() => {
        updateRef.current = 0;
        props.onUpdate(buildFilters(taggedWith, vendor));
    }, 300);
    return () => { // Cleanup callback
        clearTmeout(updateRef.current);
        updateRef.current = 0;
    };
}, [taggedWith, vendor]);

注意: 以上所有假设 props.onUpdate 保证是一个稳定的函数(就像 useState 中的设置器一样)。如果不是,事情就更复杂了,因为您必须将它添加到依赖项列表,然后处理 onUpdate 已更改但 taggedWithvendor 没有更改的可能性。


您甚至可以将去抖动逻辑包装在钩子中(并让钩子处理不稳定的回调)。这是一个相当 off-the-cuff 的例子:

const useDebounce = (fn, ms, deps) => {
    const ref = useRef(null);
    if (!ref.current) {
        // One-time init
        ref.current = {
            timer: 0,
        };
    }
    // Always remember the most recent `fn` on our ref object
    ref.current.fn = fn;

    useEffect(() => {
        ref.current.timer = setTimeout(() => {
            ref.current.timer = 0;
            // Always use the most recent `fn`, not necessarily
            // the one we had when scheduling the timer
            ref.current.fn.call(); // `call` so we don't pass our ref obj as `this`
        }, ms);
        return () => {
            clearTimeout(ref.current.timer);
            ref.current.timer = 0;
        };
    }, deps);
};

然后组件代码(使用buildFilters)如下所示:

const [vendor, setVendor] = useState("");
const [taggedWith, setTaggedWith] = useState("");

useDebounce(
    () => {
        props.onUpdate(buildFilters(taggedWith, vendor));
    },
    300,
    [taggedWith, vendor]
);

实例:

const { useState, useRef, useEffect } = React;

const useDebounce = (fn, ms, deps) => {
    const ref = useRef(null);
    if (!ref.current) {
        // One-time init
        ref.current = {
            timer: 0,
        };
    }
    // Always remember the most recent `fn` on our ref object
    ref.current.fn = fn;

    useEffect(() => {
        ref.current.timer = setTimeout(() => {
            ref.current.timer = 0;
            // Always use the most recent `fn`, not necessarily
            // the one we had when scheduling the timer
            ref.current.fn.call(); // `call` so we don't pass our ref obj as `this`
        }, ms);
        return () => {
            clearTimeout(ref.current.timer);
            ref.current.timer = 0;
        };
    }, deps);
};

const buildFilters = (taggedWith, vendor) => {
    const filters = [];
    if (vendor) {
        filters.push({
            label: `Vendor: ${vendor}`
        });
    }
    if (taggedWith) {
        filters.push({
            label: `Tagged with: ${taggedWith}`
        });
    }
    return filters;
};

const Example = (props) => {
    const [vendor, setVendor] = useState("");
    const [taggedWith, setTaggedWith] = useState("");

    useDebounce(
        () => {
            props.onUpdate(buildFilters(taggedWith, vendor));
        },
        300,
        [taggedWith, vendor]
    );

    return <div>
        <label>
            <input type="text" value={vendor} onChange={
                ({target: {value}}) => { setVendor(value); }
            } />
        </label>
        <label>
            <input type="text" value={taggedWith} onChange={
                ({target: {value}}) => { setTaggedWith(value); }
            } />
        </label>
    </div>;
};

const App = () => {
    const [filters, setFilters] = useState([]);

    return <div>
        <Example onUpdate={setFilters} />
        <div>
            Filters ({filters.length}):
            {!!filters.length &&
                <ul>
                    {filters.map(filter => <li key={filter.label}>{filter.label}</li>)}
                </ul>
            }
        </div>
    </div>;
};

ReactDOM.render(<App />, document.getElementById("root"));
<div id="root"></div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.development.js"></script>

我不能说 hook 已经过彻底测试,但即使回调不稳定,它似乎也能正常工作:

const { useState, useRef, useEffect } = React;

const useDebounce = (fn, ms, deps) => {
    const ref = useRef(null);
    if (!ref.current) {
        // One-time init
        ref.current = {
            timer: 0,
        };
    }
    // Always remember the most recent `fn` on our ref object
    ref.current.fn = fn;

    useEffect(() => {
        ref.current.timer = setTimeout(() => {
            ref.current.timer = 0;
            // Always use the most recent `fn`, not necessarily
            // the one we had when scheduling the timer
            ref.current.fn.call(); // `call` so we don't pass our ref obj as `this`
        }, ms);
        return () => {
            clearTimeout(ref.current.timer);
            ref.current.timer = 0;
        };
    }, deps);
};

const buildFilters = (taggedWith, vendor) => {
    const filters = [];
    if (vendor) {
        filters.push({
            label: `Vendor: ${vendor}`
        });
    }
    if (taggedWith) {
        filters.push({
            label: `Tagged with: ${taggedWith}`
        });
    }
    return filters;
};

const Example = (props) => {
    const [vendor, setVendor] = useState("");
    const [taggedWith, setTaggedWith] = useState("");

    useDebounce(
        () => {
            console.log(`filter update ${vendor} ${taggedWith}`);
            props.onUpdate(buildFilters(taggedWith, vendor));
        },
        300,
        [taggedWith, vendor]
    );

    return <div>
        <label>
            <input type="text" value={vendor} onChange={
                ({target: {value}}) => { setVendor(value); }
            } />
        </label>
        <label>
            <input type="text" value={taggedWith} onChange={
                ({target: {value}}) => { setTaggedWith(value); }
            } />
        </label>
    </div>;
};

const App = () => {
    const [counter, setCounter] = useState(0);
    const [filters, setFilters] = useState([]);
    const ref = useRef(null);
    if (!ref.current) {
        ref.current = {};
    }

    useEffect(() => {
        const timer = setInterval(() => setCounter(c => c + 1), 100);
        return () => {
            clearInterval(timer);
        };
    }, []);

    // An update callback that we intentionally recreate every render
    // to check that useDebounce handles using the **latest** function
    const onUpdate = ref.current.onUpdate = function onUpdate(filters) {
        if (onUpdate !== ref.current.onUpdate) {
            console.log("STALE FUNCTION CALLED");
        }
        setFilters(filters);
    };

    return <div>
        Counter: {counter}
        <Example onUpdate={onUpdate} />
        <div>
            Filters ({filters.length}):
            {!!filters.length &&
                <ul>
                    {filters.map(filter => <li key={filter.label}>{filter.label}</li>)}
                </ul>
            }
        </div>
    </div>;
};

ReactDOM.render(<App />, document.getElementById("root"));
<div id="root"></div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.development.js"></script>