从重复逻辑到自定义 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 更容易复用。