防抖为什么会失效?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 分钟前
给AI配一台电脑,开源版Muse来了,OpenMuse赶紧去二次开发吧
javascript·面试·github
Jack N14 分钟前
【Strapi 系列之二】Collection / Single / Component 怎么选?Strapi 字段大全一篇讲透
前端·javascript·strapi
Java后端的Ai之路22 分钟前
09-React组件生命周期
前端·react.js·前端框架
郑州光合科技余经理39 分钟前
本地生活服务系统:预约状态和进度通知接口怎么拆
java·开发语言·前端·javascript·后端·php·生活
半个落月10 小时前
LangSmith 全链路观测(下):用 Dataset 与 LLM Judge 量化评估 RA
javascript·langchain
半个落月10 小时前
LangSmith 全链路观测(上):用 LangGraph 与 Milvus 构建可追踪的 RAG 客服
javascript·langchain
Java后端的Ai之路11 小时前
06-React-Props
前端·react.js·前端框架
半个落月11 小时前
LangChain.js Agent 中间件实战:生命周期、动态工具与文件权限
javascript·langchain
志尊宝12 小时前
Vue3 零基础每日笔记(082):文件上传实战——el-upload 校验、自定义上传、头像预览
前端·javascript·vue.js·笔记·#javascript·#vue.js
bmxy小明同学18 小时前
2026-10-11-幻觉治理
服务器·前端·javascript