西瓜博客
返回所有文章

从重复逻辑到自定义 Hook:实现一个防抖输入

以搜索输入为例,拆解自定义 Hook 的设计边界,实现带清理机制的 useDebouncedValue,并区分防抖、节流和延迟渲染。

当多个组件都需要相同的状态和副作用逻辑时,可以把它们提取成自定义 Hook。好的 Hook 不是把代码搬个家,而是提供一个清晰、稳定、容易理解的行为接口。

搜索输入的防抖逻辑很适合作为第一个例子:用户持续输入时,先更新输入框;停止输入一段时间后,再更新用于筛选或请求的值。

1. 明确接口和行为

我们希望这样使用:

const debouncedKeyword = useDebouncedValue(keyword, 300);

它的行为约定是:

  • 首次渲染直接返回初始值。
  • 后续值变化时,等待指定时间再提交新值。
  • 等待期间再次变化,取消上一个计时器。
  • 组件卸载时,不留下待执行的计时器。

明确这些约定后,实现和测试就都有了依据。

2. 实现 useDebouncedValue

把下面的实现保存为应用中的 use-debounced-value.ts:

import { useEffect, useState } from "react";

export function useDebouncedValue<Value>(value: Value, delayMs = 300): Value {
  const [debouncedValue, setDebouncedValue] = useState<Value>(() => value);

  useEffect(() => {
    const timerId = setTimeout(() => {
      setDebouncedValue(() => value);
    }, delayMs);

    return () => clearTimeout(timerId);
  }, [value, delayMs]);

  return debouncedValue;
}

泛型保留输入值的类型,因此返回值与输入值一致。用函数包装初始值和 setter 参数,是为了兼容值本身就是函数的情况,避免 React 把它误当作初始化器或更新器。

清理函数是整个实现的关键。每次依赖变化时,上一轮计时器都会被取消;只有最近一次变化对应的计时器能够在等待结束后更新状态。

调用方应传入有限且非负的 delayMs。如果 Hook 要作为公共库发布,可以进一步明确非法参数的处理规则。

3. 在搜索组件中使用

下面使用本地文章列表,完整展示输入值与防抖值的区别,不依赖额外的服务端接口。在 Next.js App Router 中,应将交互组件放在客户端组件边界内。

import { useState } from "react";
import { useDebouncedValue } from "./use-debounced-value";

const articles = [
  { id: "state", title: "React 状态管理入门" },
  { id: "effect", title: "正确理解 useEffect" },
  { id: "hooks", title: "自定义 Hook 的设计方法" },
];

export function ArticleSearch() {
  const [keyword, setKeyword] = useState("");
  const debouncedKeyword = useDebouncedValue(keyword, 300);
  const normalizedKeyword = debouncedKeyword.trim().toLowerCase();
  const results = articles.filter(article =>
    article.title.toLowerCase().includes(normalizedKeyword)
  );
  const isWaiting = keyword !== debouncedKeyword;

  return (
    <section>
      <label htmlFor="article-search">搜索文章</label>
      <input
        id="article-search"
        value={keyword}
        onChange={event => setKeyword(event.target.value)}
        placeholder="输入 React 或 Hook"
      />
      <p role="status">
        {isWaiting ? "等待输入结束……" : `找到 ${results.length} 篇文章`}
      </p>
      <ul>
        {results.map(article => <li key={article.id}>{article.title}</li>)}
      </ul>
    </section>
  );
}

输入框必须绑定即时的 keyword,不能绑定防抖值,否则用户会感觉输入卡顿。防抖的是后续消费行为,不是输入框本身。

这个小列表不需要性能优化,示例只是用来观察时序。若接入远程搜索,仍然要处理请求取消和竞态:防抖减少请求次数,但不能保证响应按顺序返回。

4. 自定义 Hook 共享逻辑,不共享状态

两个组件各自调用 useDebouncedValue,会拥有各自的状态和计时器。提取 Hook 并不会自动把它变成全局状态。

如果多个组件需要同一个搜索词,应先把状态提升到共同父组件;跨层级共享时,再考虑 Context 或其他状态管理方案。

Hook 必须在组件或其他 Hook 的顶层调用,不能放在条件、循环或事件处理函数里。另外,传入每次渲染都重新创建的对象可能反复重启计时器;本例使用字符串,不存在这个引用问题。

5. 防抖、节流和 useDeferredValue 的区别

方案 核心目标 常见用途
防抖 连续变化结束后再执行 搜索请求、延后保存
节流 一段时间内限制执行频率 滚动事件、高频上报
useDeferredValue 让部分渲染滞后于高优先级更新 昂贵列表渲染

useDeferredValue 不承诺固定等待 300 毫秒,也不会自动减少网络请求。不要因为它让界面更流畅,就把它当成请求防抖工具。

小结

设计自定义 Hook 时,先描述输入、输出和生命周期,再写实现。一个只有十几行、行为边界明确的 Hook,往往比一个参数很多的万能 Hook 更容易复用。