防抖为什么会失效?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 毫秒内切走页面,网络面板里不应再出现这条搜索请求。

相关推荐
柚yuzumi2 小时前
别再猜 this:先看它属于谁,再看它指向谁
前端·javascript
YIAN2 小时前
React + Zustand + JWT 前端权限体系完整实现:从登录鉴权到路由守卫全流程拆解
前端·react.js·架构
汉堡大王95272 小时前
面试必考:手写代码 new 做了什么?从原理到实现全解析
前端·javascript·面试
BillKu2 小时前
TypeScript中,字符串字面量联合类型(Union Type)、enum的用法说明
前端·javascript·typescript
顶级自由人5 小时前
本地正常、线上正常,为什么一个 Hook 仍会报错?
前端·javascript·程序员
lerhxx5 小时前
R3F 第一人称漫游与碰撞检测:Pointer Lock + 不穿墙的滑墙秘诀(中)
前端·javascript·three.js
labixiong5 小时前
button按钮原生开关弹窗,零 JS 搞定80%交互场景
前端·javascript·html
隔岸观火烧连营5 小时前
如何用 WebCodecs 在浏览器里实现高清录屏 —— 无插件、无水印、直接导出 MP4
前端·javascript
Hilaku6 小时前
技术好就能升职是前端圈最大的谎言!
前端·javascript·程序员
光影少年7 小时前
react navite高频手写/实操题
前端·javascript·react native·react.js·前端框架