防抖为什么会失效?React 面试里的闭包和定时器

防抖为什么会失效?React 面试里的闭包和定时器

很多人手写防抖时,定时器逻辑本身没错,但一放进 React 组件,点击几次还是会发出几次请求。

我更愿意先问一句:这个 debounce 实例在每次 render 之后是不是还是同一个?

先看最容易踩的写法

tsx 复制代码
function SearchBox() {
  const [keyword, setKeyword] = useState('');

  const search = debounce((value: string) => {
    fetch('/api/search?q=' + value);
  }, 300);

  return (
    <input
      value={keyword}
      onChange={event => {
        setKeyword(event.target.value);
        search(event.target.value);
      }}
    />
  );
}

debounce 每次调用都会创建一个新对象。输入一个字符会触发 setKeyword,组件重新渲染,下一次输入拿到的已经是另一个 debounce。上一个实例手里的 timer 没有被新实例接管,于是"连续输入只执行一次"这个前提被破坏了。

这不是闭包把值"弄丢"这么简单,根因是实例生命周期和组件 render 生命周期不一致。

实例稳定之后,还会遇到旧闭包

把实例挪到 useMemo 里能解决重复创建:

tsx 复制代码
const search = useMemo(
  () =>
    debounce((value: string) => {
      fetch('/api/search?q=' + value);
    }, 300),
  [],
);

但真实业务里回调通常会读组件状态、鉴权信息或请求函数。依赖数组写空,回调就会一直闭包住第一次 render 的值;依赖写满,又会随着依赖变化重建 debounce,之前的 timer 仍可能被丢下。

这里我会把"稳定的定时器"和"最新的业务回调"拆开:

tsx 复制代码
function useDebouncedCallback<TArgs extends unknown[]>(
  callback: (...args: TArgs) => void,
  delay: number,
) {
  const callbackRef = useRef(callback);
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

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

  useEffect(() => {
    return () => {
      if (timerRef.current !== null) {
        clearTimeout(timerRef.current);
      }
    };
  }, []);

  return useCallback((...args: TArgs) => {
    if (timerRef.current !== null) {
      clearTimeout(timerRef.current);
    }

    timerRef.current = setTimeout(() => {
      timerRef.current = null;
      callbackRef.current(...args);
    }, delay);
  }, [delay]);
}

调用时:

tsx 复制代码
const search = useDebouncedCallback((value: string) => {
  fetch('/api/search?q=' + value);
}, 300);

<input value={keyword} onChange={event => {
  setKeyword(event.target.value);
  search(event.target.value);
}} />

这个 Hook 的关键有两个:

  • timerRef 让定时器跨 render 保持同一份状态。
  • callbackRef 在每次提交后更新,真正执行时读取的是最新回调。

React 官方文档也提醒过,Effect 的清理函数会在组件卸载前执行;函数依赖如果每次 render 都变,Effect 就会被反复触发。这里把 timer 的清理放在卸载阶段,避免组件离开页面后还执行搜索。

面试官继续追问时

"把 debounce 包进 useMemo 就够了吗?"

不一定。只要回调需要读取会变化的状态,就要处理旧闭包;只要 delay 会变化,就要决定是取消旧 timer,还是让旧 timer 用旧延迟完成。这个取舍应该在 Hook 的 API 里说清楚。

"为什么不用 useCallback 包住 debounce?"

useCallback 只缓存函数引用,不会替你管理 debounce 内部的 timer。它能让返回给子组件的调用函数稳定,但 timer 仍然要放在 useRef 或专门的 debounce 实例里。

"组件卸载时一定要取消吗?"

搜索建议取消。否则 timer 到点后仍可能发请求,回调还会访问已经离开的页面逻辑。写 Hook 时把 clearTimeout 放进 cleanup,测试时用 fake timers 检查这一点。

比如在组件卸载前输入了"react",300 毫秒内切走页面,网络面板里不应再出现这条搜索请求。

相关推荐
浅水壁虎1 小时前
vue基础(第四章 Pinia)
前端·javascript·vue.js
张元清2 小时前
React useEventSource Hook:自带断线重连的 Server-Sent Events (2026)
javascript·react.js
光影少年2 小时前
RN 网络请求:Fetch / Axios 封装、超时、拦截器
前端·react native·react.js
铁皮饭盒2 小时前
网页端, 6.5MB人脸识别模型, 谷歌框架, 又快又准
前端·javascript·后端
自进化Agent智能体2 小时前
Hermes 工具与工具集——Hermes 的内置能力
javascript
你挚爱的强哥3 小时前
【exportExcel】单纯靠js不用任何其他第三方插件导出xls格式文件
开发语言·javascript·ecmascript
大家的林语冰4 小时前
👉 尤雨溪再次成立新公司,同时官宣 Pinia 4 正式发布!
前端·javascript·vue.js
用户938515635074 小时前
从零在浏览器里跑 DeepSeek-R1:WebGPU + Transformer.js 全链路实战(二)
前端·javascript·typescript
BD_Marathon5 小时前
部署Spark
大数据·javascript·spark