我重写了自己用了两年的防抖节流Hook——发现里面藏着3个隐藏bug

防抖节流是前端最基础的知识点之一。随手从网上抄一个useDebounce、useThrottle塞进项目,能跑,两年没出过大问题。

直到最近排查一个诡异的内存泄漏警告,我把这个用了两年的Hook翻出来重新看了一遍------发现里面藏着3个隐藏bug,平时根本不会触发,一旦触发就是线上事故。

我两年前写的版本

tsx 复制代码
// 两年前的版本,能跑,但有问题
function useDebounce(callback, delay) {
  const timerRef = useRef(null);

  const debouncedFn = (...args) => {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
    }
    timerRef.current = setTimeout(() => {
      callback(...args);
    }, delay);
  };

  return debouncedFn;
}

用法:

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

  const search = useDebounce((value) => {
    fetchSearchResults(value).then(setResults);
  }, 300);

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

这段代码在大部分场景下工作得很好。但它藏着3个问题。

隐藏bug1:组件卸载后依然执行回调

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

  const search = useDebounce((value) => {
    fetchSearchResults(value).then((data) => {
      setResults(data); // 组件可能已经卸载了!
    });
  }, 300);

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

触发场景: 用户输入关键词后立刻跳转到别的页面。300ms后,防抖的回调触发,setResults执行在一个已经卸载的组件上------React会在控制台警告"Cannot update state on an unmounted component",虽然不会直接崩溃,但如果这个回调里有其他副作用(比如写localStorage、发埋点),可能真的会出问题。

tsx 复制代码
// ✅ 组件卸载时清理定时器
function useDebounce(callback, delay) {
  const timerRef = useRef(null);

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

  const debouncedFn = (...args) => {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
    }
    timerRef.current = setTimeout(() => {
      callback(...args);
    }, delay);
  };

  return debouncedFn;
}

原则:所有用setTimeout/setInterval的Hook,必须在useEffect的清理函数里清掉定时器。这不是可选项,是必选项。

隐藏bug2:闭包里的callback是旧的(stale closure)

这是最隐蔽的一个。先看一个复现场景:

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

  // category变化时,search函数应该拿到最新的category
  const search = useDebounce((value) => {
    fetchSearchResults(value, category).then(setResults);
    // 这里的category永远是Hook第一次创建时的值!
  }, 300);

  return (
    <div>
      <select onChange={(e) => setCategory(e.target.value)}>
        <option value="all">全部</option>
        <option value="books">图书</option>
      </select>
      <input onChange={(e) => search(e.target.value)} />
    </div>
  );
}

为什么? useDebounce只在组件首次渲染时创建了debouncedFn这个函数(如果没有依赖数组控制),这个函数内部闭包捕获的callback是第一次渲染时 传进来的那个函数。之后即使category变了,组件重新渲染,callback里引用的还是旧的category。

这就是经典的**stale closure(闭包过期)**问题。用户明明切换了分类,但搜索请求带的还是旧分类------bug非常隐蔽,因为大多数时候你测试时不会正好在防抖等待期间切换筹码。

tsx 复制代码
// ✅ 用ref存最新的callback,避免闭包过期
function useDebounce(callback, delay) {
  const timerRef = useRef(null);
  const callbackRef = useRef(callback);

  // 每次渲染都更新ref,保证拿到的是最新的callback
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

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

  const debouncedFn = useCallback((...args) => {
    if (timerRef.current) clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => {
      callbackRef.current(...args); // 永远拿最新的callback
    }, delay);
  }, [delay]);

  return debouncedFn;
}

原则:Hook内部如果要长期持有一个外部传入的函数,用ref存最新值,不要指望闭包会自动更新。

这也是为什么useEffect的依赖数组这么重要------它就是在提醒你"闭包会过期"这件事。

隐藏bug3:没有取消方法,无法主动打断

tsx 复制代码
function SearchBox() {
  const search = useDebounce((value) => {
    fetchSearchResults(value).then(setResults);
  }, 300);

  const handleClear = () => {
    setKeyword('');
    setResults([]);
    // 但是search之前排队的那次调用还会在300ms后执行!
    // 清空后0.3秒,搜索结果又跳出来了
  };

  return (
    <div>
      <input onChange={(e) => search(e.target.value)} />
      <button onClick={handleClear}>清空</button>
    </div>
  );
}

触发场景: 用户输入了关键词,防抖计时器在跑。这时用户点了"清空"按钮,UI上结果确实清空了------但0.3秒后,之前排队的防抖回调触发,搜索结果又跳出来覆盖了清空状态。用户会看到一个诡异的"清空后又自己冒出内容"的bug。

我们的useDebounce只提供了触发方法,没有提供取消方法。这是一个功能缺失,不是逻辑错误,但同样会导致真实的用户体验问题。

tsx 复制代码
// ✅ 提供cancel方法,让外部可以主动打断
function useDebounce(callback, delay) {
  const timerRef = useRef(null);
  const callbackRef = useRef(callback);

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

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

  const debouncedFn = useCallback((...args) => {
    if (timerRef.current) clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => {
      callbackRef.current(...args);
    }, delay);
  }, [delay]);

  const cancel = useCallback(() => {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
      timerRef.current = null;
    }
  }, []);

  return [debouncedFn, cancel];
}
tsx 复制代码
// 用法:拿到cancel方法,在需要打断的地方调用
function SearchBox() {
  const [search, cancelSearch] = useDebounce((value) => {
    fetchSearchResults(value).then(setResults);
  }, 300);

  const handleClear = () => {
    setKeyword('');
    setResults([]);
    cancelSearch(); // 主动打断排队中的调用
  };

  return (
    <div>
      <input onChange={(e) => search(e.target.value)} />
      <button onClick={handleClear}>清空</button>
    </div>
  );
}

原则:任何"延迟执行"的机制,都要配一个"取消执行"的出口。没有取消方法的防抖/节流,只是半成品。

节流(throttle)也有一样的问题

把上面的思路套到节流上,同样的3个bug会以不同形式出现:

tsx 复制代码
// ✅ 完整版useThrottle,同样处理3个问题
function useThrottle(callback, delay) {
  const timerRef = useRef(null);
  const lastRunRef = useRef(0);
  const callbackRef = useRef(callback);

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

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

  const throttledFn = useCallback((...args) => {
    const now = Date.now();
    const remaining = delay - (now - lastRunRef.current);

    if (remaining <= 0) {
      lastRunRef.current = now;
      callbackRef.current(...args);
    } else if (!timerRef.current) {
      timerRef.current = setTimeout(() => {
        lastRunRef.current = Date.now();
        timerRef.current = null;
        callbackRef.current(...args);
      }, remaining);
    }
  }, [delay]);

  const cancel = useCallback(() => {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
      timerRef.current = null;
    }
  }, []);

  return [throttledFn, cancel];
}

节流比防抖多一个细节:要不要"leading"(第一次触发立即执行)和"trailing"(最后一次触发后再执行一次)。这两种语义在滚动监听和按钮防重复点击场景下完全不同,用之前一定要想清楚你要的是哪种。

速查表

隐藏bug 触发场景 修复
卸载后仍执行 组件卸载时定时器还在跑 useEffect清理函数里clearTimeout
闭包过期 依赖的外部变量变化后不生效 用ref存最新callback
无法取消 需要主动打断排队中的调用 暴露cancel方法

两年没出问题≠没有bug

这3个bug之所以在生产环境潜伏了两年没被发现,是因为触发条件都比较刁钻:卸载时机的竞态、依赖变化的时序、需要主动打断的交互。日常测试很难覆盖到。

但只要项目跑得足够久、用户量足够大,这些边界情况迟早会被撞到。

工具函数这种"抄一遍就不再看"的代码,反而是最容易埋雷的地方------因为没人会因为"它一直没出问题"而主动去重新审视它。

你项目里的防抖节流Hook,有取消方法吗?评论区说说。

相关推荐
怕浪猫11 小时前
RAG 面试 6 连问,从原理到优化全部覆盖
python·算法·面试
To_OC13 小时前
从一头雾水到跑通全流程:我用一个周末啃透了JWT登录鉴权
前端·后端·http
excel14 小时前
研究 Vue 3 源码的收获
前端·vue.js
可乐鸡翅yeah_16 小时前
业务中 M3U8 水印相关坑,硬水印和动态水印区别
前端·网络·数据库·ffmpeg·m3u8在线
lerhxx16 小时前
AI 应用如何高效优雅地恢复中断?—— "连接解耦 + 状态持久化"
前端·javascript
计算机魔术师16 小时前
METR 演示 AI 智能体如何篡改 Inspect 评估记录以掩盖不当行为
前端
前端snow17 小时前
ai agent --- 异步处理之 Rabbit MQ
前端
念何架构之路17 小时前
zap扩展生态与总结
java·前端·数据库
独孤九剑打醒他17 小时前
【原创开源】【概念设计】源 - 栅 - 漏 - 栅 - 源 横向双栅 MOS,低压交流多值逻辑芯片探索
前端·其他·架构·开源·硬件工程
mantou13218 小时前
我给 AI Agent 做了个「油猴」:让 Claude Code / Codex 直接用你已登录的浏览器
前端·javascript·后端