最近在准备技术面试,整理了几个常用的自定义 Hooks:useDebounce、usePrevious 和 useRequest。乍一看它们都很简单,几十行代码就能实现。但仔细思考后发现,这些看似简单的封装背后,其实蕴含着 React 设计哲学、性能优化和状态管理的核心思想。
这篇文章是我的学习笔记,尝试从这三个 Hook 出发,探索它们解决的问题、设计思路,以及在什么场景下引入它们会让代码更优雅。
问题的起源:为什么需要自定义 Hooks?
在 React 16.8 引入 Hooks 之前,组件逻辑复用主要依赖高阶组件(HOC)和 Render Props。但这两种模式都有个问题:嵌套层级过深,代码难以理解。Hooks 的出现让我们可以用函数的方式提取和复用组件逻辑。
自定义 Hook 的核心价值在于:封装带副作用的逻辑,让组件代码更声明式。比如防抖、请求状态管理这些"脏活累活",可以抽离到自定义 Hook 中,让组件只关注 "我要什么数据" 而不是 "我怎么拿到数据"。
核心概念探索
useDebounce: 控制变化频率的艺术
useDebounce 的使用场景很常见:搜索框输入、窗口 resize、滚动监听等高频触发的事件。如果每次输入都发起请求,会造成不必要的性能开销和服务器压力。
// 环境:React 18+
// 场景:搜索框实时搜索
import { useState } from 'react';
import { useDebounce } from './useDebounce';
function SearchBox() {
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebounce(keyword, 500);
// Only trigger search when user stops typing for 500ms
useEffect(() => {
if (debouncedKeyword) {
fetchSearchResults(debouncedKeyword);
}
}, [debouncedKeyword]);
return (
<input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="Search..."
/>
);
}
实现原理:
useDebounce 的核心是利用 useEffect 的清理函数。每当 value 变化时,先清除之前的定时器,再设置新的定时器。只有当 value 在 delay 时间内不再变化,定时器才会执行,更新 debouncedValue。
// 环境:React 18+
// 关键点:useEffect 的清理函数
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value); // Only update after delay
}, delay);
return () => clearTimeout(timer); // Cleanup on every render
}, [value, delay]);
这里有个值得思考的细节:为什么清理函数在每次渲染时都会执行?
我的理解是,React 的 Effect 清理机制保证了 "副作用的及时撤销"。每次 value 变化时,旧的定时器被清除,新的定时器才会生效。这种模式避免了多个定时器同时存在导致的竞态问题。
权衡点:
delay太短: 防抖效果不明显delay太长: 用户体验差,响应迟钝- 常见取值: 搜索框 300-500ms,滚动事件 100-200ms
usePrevious: 时间旅行的小技巧
usePrevious 解决的是一个看似简单但实际很有用的问题: 如何在组件中获取上一次渲染的值?
// 环境:React 18+
// 场景:比较前后两次 props 的变化
function UserProfile({ userId }) {
const prevUserId = usePrevious(userId);
useEffect(() => {
if (userId !== prevUserId) {
console.log(`User changed from ${prevUserId} to ${userId}`);
fetchUserData(userId);
}
}, [userId, prevUserId]);
return <div>User ID: {userId}</div>;
}
实现原理:
usePrevious 利用了 useRef 的两个特性:
- 引用的持久性 :
ref.current在组件的整个生命周期中都保持同一个引用 - 修改不触发重渲染: 改变
ref.current不会导致组件重新渲染
// 环境:React 18+
// 关键点:useRef 的持久性
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value; // Update ref AFTER render
});
return ref.current; // Return old value during render
}
这里的执行顺序很关键:
- 组件渲染时,
usePrevious返回ref.current(上一次的值) - 渲染完成后,
useEffect执行,将ref.current更新为当前值 - 下次渲染时,
ref.current已经是"上一次"的值了
一个有趣的思考:为什么 useEffect 不需要依赖数组?
因为我们希望每次渲染后都更新 ref.current。如果加了依赖数组,反而会导致某些情况下 ref.current 不更新,失去"追踪前一个值"的意义。
useRequest:异步状态管理的简化方案
useRequest 封装了数据请求的三个核心状态: data、loading、error。这是几乎所有数据请求场景都需要的状态管理逻辑。
// 环境:React 18+
// 场景:组件挂载时自动请求数据
function UserList() {
const { data, loading, error, run } = useRequest(
() => fetch('/api/users').then(res => res.json())
);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
{data?.map(user => <div key={user.id}>{user.name}</div>)}
<button onClick={run}>Refresh</button>
</div>
);
}
实现原理:
useRequest 的核心是状态机思想: 请求可能处于三种互斥状态之一(加载中、成功、失败),通过 useState 管理这些状态的切换。
// 环境:React 18+
// 关键点:异步状态管理 + useCallback 优化
const run = useCallback(() => {
setLoading(true);
setError(null);
return fetcher()
.then((res) => {
setData(res);
setError(null);
})
.catch((err) => {
setError(err);
setData(undefined);
})
.finally(() => {
setLoading(false);
});
}, [fetcher]);
这里有几个值得注意的细节:
useCallback包裹run函数: 避免每次渲染都生成新的函数引用,减少子组件不必要的重渲染finally中设置loading: false: 无论成功还是失败,都要结束加载状态- 成功时清除
error,失败时清除data: 保证状态的一致性
实际场景思考
场景 1: 搜索框 + 请求防抖
这是 useDebounce 和 useRequest 的经典组合场景:
// 环境:React 18+ / 浏览器
// 场景:实时搜索,防抖优化
// 依赖:无(原生 Fetch API)
function SearchPage() {
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebounce(keyword, 300);
const { data: results, loading } = useRequest(
() => fetch(`/api/search?q=${debouncedKeyword}`).then(r => r.json()),
{ manual: false }
);
// Re-fetch when debounced keyword changes
useEffect(() => {
if (debouncedKeyword) {
// Trigger search
}
}, [debouncedKeyword]);
return (
<div>
<input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
{loading ? <Spinner /> : <ResultList data={results} />}
</div>
);
}
权衡点:
- 防抖延迟太短,请求依然频繁
- 延迟太长,用户感觉响应迟钝
- 建议根据实际搜索耗时调整,一般 300-500ms
场景 2: 依赖变化时的副作用清理
usePrevious 在处理 "依赖变化时的清理逻辑" 时很有用:
// 环境:React 18+ / 浏览器
// 场景:WebSocket 连接管理,userId 变化时重连
// 依赖:WebSocket API
function ChatRoom({ roomId }) {
const prevRoomId = usePrevious(roomId);
const [ws, setWs] = useState(null);
useEffect(() => {
// If roomId changed, disconnect old room
if (prevRoomId && prevRoomId !== roomId && ws) {
console.log(`Leaving room ${prevRoomId}`);
ws.close();
}
// Connect to new room
const socket = new WebSocket(`ws://chat.com/${roomId}`);
setWs(socket);
return () => socket.close();
}, [roomId, prevRoomId]);
return <div>Connected to room: {roomId}</div>;
}
这个模式在 "需要根据前后值的差异执行不同逻辑" 时特别有用。
场景 3: 请求竞态问题
一个容易被忽视的问题: 如果用户快速切换页面,多个请求同时进行,后发起的请求可能先返回,导致数据错乱。
// 环境:React 18+
// 场景:页面切换时的请求竞态
// 问题:后发起的请求可能先返回
function UserDetail({ userId }) {
const { data, loading } = useRequest(
() => fetch(`/api/users/${userId}`).then(r => r.json())
);
// Problem: If userId changes quickly:
// Request A (userId=1) -> Request B (userId=2)
// But B returns first, then A returns
// Final data might be userId=1 (wrong!)
}
一种可能的解决方案:
// 环境:React 18+
// 场景:使用 AbortController 取消过期请求
function useRequest(fetcher, options = {}) {
// ... existing code
const run = useCallback(() => {
const abortController = new AbortController();
setLoading(true);
setError(null);
return fetcher({ signal: abortController.signal })
.then(setData)
.catch((err) => {
if (err.name !== 'AbortError') {
setError(err);
}
})
.finally(() => setLoading(false));
// Cleanup: abort on next call or unmount
return () => abortController.abort();
}, [fetcher]);
// ...
}
但这个方案需要 fetcher 支持 signal 参数,对使用者有一定要求。另一种思路是用"请求 ID"标记最新请求,忽略过期响应。
延伸与发散
在研究这些 Hook 时,我产生了一些新的疑问:
1. useDebounce vs useThrottle
防抖(debounce)是"停止触发后才执行",节流(throttle)是"固定时间间隔执行一次"。什么场景下应该用节流而不是防抖?
我的理解是:
- 防抖适合"最终状态"场景: 搜索框、表单验证、窗口 resize
- 节流适合"过程状态"场景: 滚动加载、鼠标移动跟踪、拖拽
2. useRequest 的更多能力
现有的 useRequest 还比较简陋,实际项目中可能需要:
- 缓存: 相同参数的请求直接返回缓存
- 重试: 请求失败后自动重试
- 轮询: 定时刷新数据
- 依赖刷新: 某个依赖变化时自动重新请求
这些能力可以通过 ahooks 的 useRequest 或 react-query 等库实现,它们的源码值得深入学习。
3. usePrevious 的局限性
usePrevious 只能追踪一次前一个值。如果需要追踪"前两次"、"前三次"的值呢?
一种可能的思路是:
// 环境:React 18+
// 场景:追踪历史值
function useHistory(value, maxLength = 10) {
const historyRef = useRef([]);
useEffect(() => {
historyRef.current = [value, ...historyRef.current].slice(0, maxLength);
});
return historyRef.current;
}
但这会带来额外的内存开销,需要权衡。
4. Hooks 的依赖管理
这三个 Hook 都涉及 useEffect 的依赖数组。我在实践中经常遇到的困惑:
- 什么时候应该加依赖?
- 什么时候可以省略依赖?
- 如何避免无限循环?
React 官方的 ESLint 插件(eslint-plugin-react-hooks)会检查依赖,但有时它的建议并不完全符合实际需求。这可能需要更深入理解 React 的渲染机制。
设计 Hook 的思维模式
通过这三个 Hook,我总结了一些设计自定义 Hook 的思路:
1. 单一职责
每个 Hook 只做一件事:
useDebounce: 控制变化频率usePrevious: 获取前一次的值useRequest: 管理请求状态
避免把多个功能塞进一个 Hook,保持可组合性。
2. 声明式 API
Hook 的使用者应该关注"要什么",而不是"怎么做":
// Good: Declarative
const debouncedValue = useDebounce(value, 500);
// Bad: Imperative
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), 500);
return () => clearTimeout(timer);
}, [value]);
小结
这篇文章从三个简单的自定义 Hook 出发,探索了 React 中的副作用管理、状态同步和性能优化。useDebounce 教会我们如何优雅地清理副作用,usePrevious 展示了 useRef 的妙用,useRequest 则是异步状态管理的入门范例。
但这些都只是起点。实际项目中,我们可能需要更强大的状态管理方案(如 react-query、swr),或者需要考虑更复杂的场景(并发请求、错误重试、缓存策略等)。
这篇文章更多是我个人的学习笔记,而非标准答案。如果你有不同的理解或补充,欢迎交流。
参考资料
- React Hooks 官方文档 - useEffect、useRef、useCallback 详解
- ahooks useRequest - 功能完善的 useRequest 实现
- react-query 文档 - 现代数据请求库
- useHooks - 自定义 Hooks 示例集合