React 用自定义 useDebounce Hook 治好搜索框频繁请求:防抖、竞态取消与卸载清理

React 用自定义 useDebounce Hook 治好搜索框频繁请求:防抖、竞态取消与卸载清理

搜索框每敲一个字就发一次请求,是新手最常写出来的性能杀手。用户输入「react hooks」11 个字符,就打了 11 次接口------前 10 次的结果你根本不用,还可能因为网络快慢不一,后发的请求先回、先发的后回,最终把旧关键词的结果 渲染到界面上。这就是防抖(debounce)要解决的问题:等用户停止输入一小段时间后,才真正发请求。

这篇从最朴素的错误写法出发,一步步做出一个能复用、能取消竞态、能在组件卸载时正确清理的 useDebounce Hook。

错误写法:每次输入都请求

jsx 复制代码
function Search() {
  const [keyword, setKeyword] = useState("");
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!keyword) return;
    // 每次 keyword 变都发请求 ------ 敲多快请求就多密
    fetch(`/api/search?q=${keyword}`)
      .then((r) => r.json())
      .then(setResults);
  }, [keyword]);

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

问题很直接:输入「react」发 5 次请求,90% 是浪费。而且这些请求没有任何取消机制,回来的顺序不保证,结果会闪、会错乱。

第一步:把「值」做防抖,抽成 useDebounce

防抖的本质是「延迟」+「重置」:值一变就起一个定时器,若在定时器到期前值又变了,就清掉旧定时器重新计时。只有用户停手超过 delay 毫秒,值才「落定」。

我们不直接对请求防抖,而是对值防抖------返回一个「滞后的值」,更通用也更好测:

jsx 复制代码
import { useState, useEffect } from "react";

// 传入实时变化的 value,返回延迟 delay 毫秒后才更新的值
function useDebounce(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    // value 每变一次就起个定时器
    const timer = setTimeout(() => setDebounced(value), delay);

    // 关键:清理函数会在下次 effect 执行前(即 value 又变了)先跑,
    // 把上一个还没到期的定时器清掉 ------ 这就是「重置计时」
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debounced;
}

这个 Hook 的精髓全在那句 return () => clearTimeout(timer)。React 在依赖变化重新执行 effect 前,会先调用上一次 effect 返回的清理函数。所以只要用户还在打字(value 一直变),旧定时器就一个接一个被清掉,永远轮不到 setDebounced 执行;一旦停手 delay 毫秒,最后那个定时器才得以触发。

用起来非常干净:

jsx 复制代码
function Search() {
  const [keyword, setKeyword] = useState("");
  const debouncedKeyword = useDebounce(keyword, 400); // 停手 400ms 才落定
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!debouncedKeyword) return;
    fetch(`/api/search?q=${debouncedKeyword}`)
      .then((r) => r.json())
      .then(setResults);
  }, [debouncedKeyword]); // 依赖防抖后的值,请求次数骤降

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

输入框仍然实时响应(用的是 keyword),但请求只在 debouncedKeyword 变化时发------「react hooks」11 次输入,现在只发 1 次。

第二步:防抖了还不够,得处理竞态

防抖大幅减少请求,但没根除竞态。设想用户快速搜「a」然后改成「ab」:如果搜「a」的请求因为网络慢,比「ab」晚回来,setResults 会被「a」的旧结果覆盖------界面显示的是「ab」输入框配「a」的结果。

解法是在请求这层加取消:用 AbortController,新请求发起时中止上一个,同时清理函数里也中止,防止把过期结果 set 进去:

jsx 复制代码
useEffect(() => {
  if (!debouncedKeyword) return;

  const controller = new AbortController();

  fetch(`/api/search?q=${debouncedKeyword}`, { signal: controller.signal })
    .then((r) => r.json())
    .then(setResults)
    .catch((err) => {
      // 被 abort 的请求会抛 AbortError,这是预期内的,忽略即可
      if (err.name !== "AbortError") throw err;
    });

  // 清理:关键词又变了 / 组件卸载了,就中止这个还没回来的请求
  return () => controller.abort();
}, [debouncedKeyword]);

现在无论请求回来的顺序如何,过期的那个都已被 abort,不会再 setResults。竞态问题根除。

第三步:卸载清理------为什么两个清理函数都不能少

上面两段代码各有一个 return () => ... 清理函数,它们不只在依赖变化时跑,组件卸载时也会跑。这解决了另一个常见警告:「Can't perform a React state update on an unmounted component」。

设想用户搜索后立刻切走页面(组件卸载),但请求还在飞。如果没有 controller.abort(),请求回来时 setResults 会作用在一个已卸载的组件上,轻则告警,重则内存泄漏。清理函数在卸载时中止请求,就把这条路堵死了。

同理,useDebounce 里的 clearTimeout 在卸载时也会把挂起的定时器清掉,避免定时器在组件没了之后还去 setDebounced。这两个清理不是锦上添花,是防抖场景的标配。

一个坑:不要在渲染里现造 debounce 函数

有人会用 lodash 的 debounce 包裹回调,写成这样:

jsx 复制代码
// 错误:每次渲染都新建一个 debounced 函数,防抖状态被重置,根本不生效
const handleSearch = debounce((v) => fetch(...), 400);

每次组件渲染,debounce(...) 都返回一个全新的函数,内部的定时器计数从头开始,防抖形同虚设。如果非要用 lodash 版,得用 useMemo/useRef 把这个函数稳定住,只创建一次。而用上面「对值防抖 + useEffect」的写法,天然没有这个问题,推荐优先用它。

小结

  • 搜索框卡顿/请求爆炸的根因是「每次输入都请求」,防抖的思路是等用户停手 delay 毫秒再落定。
  • useDebounce 的核心是 setTimeout + 清理函数 clearTimeout:值一变就重置计时,只有停手才触发。
  • 光防抖不够,还要用 AbortController 取消竞态,否则慢请求后回来会覆盖新结果。
  • 两个清理函数在组件卸载时也会执行,顺手治好了「卸载后 setState」警告------别省。
  • 别在渲染函数里现造 debounce 函数,否则每次渲染都重置防抖状态;对值防抖的写法天然规避这个坑。

记忆点:防抖治「发太多」,AbortController 治「回太乱」,清理函数治「走之后还在动」------三者配齐,搜索框才算真正做对。

相关推荐
卸任17 小时前
Vibe coding工具使用心得
前端·ai编程
brien918 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance18 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M18 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写18 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李18 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易19 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
清晨Coding19 小时前
删了 Cookie 还能自动登录?Google系网站登录态自动恢复的原理分析
javascript
风骏时光牛马19 小时前
前沿视界
前端
kyriewen19 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员