1. 为什么需要自定义Hook?
三年前我刚接触React Hooks时,总觉得官方提供的几个基础Hook已经够用了。直到接手一个大型后台管理系统项目,发现不同模块间存在大量重复的状态逻辑:表单验证、权限检查、数据缓存...这些代码像病毒一样在各个组件中复制粘贴。这时候才真正理解自定义Hook的价值——它让React组件真正实现了"乐高式"开发。
自定义Hook本质上是一种逻辑复用机制,但与传统工具函数不同,它完全融入React的生态体系:
- 可以使用其他Hook
- 遵循React的渲染周期
- 天然支持状态响应
- 完美契合组件组合模式
举个例子,我们有个获取用户信息的逻辑,传统方案可能是这样:
javascript复制// 组件A
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUser = async () => {
setLoading(true);
try {
const response = await fetch('/api/user');
setUser(await response.json());
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
};
fetchUser();
}, []);
// 组件B (重复相同逻辑)
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const fetchProduct = async () => {
setLoading(true);
try {
const response = await fetch('/api/product');
setProduct(await response.json());
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
};
fetchProduct();
}, []);
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 自定义Hook设计原则
2.1 单一职责原则
好的自定义Hook应该像乐高积木一样,每个模块只解决一个特定问题。比如:
- useFetch:专门处理数据请求
- useLocalStorage:处理本地存储读写
- useEventListener:管理事件监听
反例是把太多不相关逻辑塞进一个Hook:
javascript复制// 不好的设计:混杂了太多功能
function useUser() {
// 用户数据获取
const [user, setUser] = useState(null);
// 主题切换
const [theme, setTheme] = useState('light');
// 权限检查
const checkPermission = () => {...}
return { user, theme, checkPermission };
}
2.2 命名规范
遵循React官方的命名约定:
- 始终以"use"开头(这是React识别Hook的机制)
- 使用驼峰命名法
- 名称应该明确表达功能
好的命名示例:
- useDebounce
- useWindowSize
- useHover
2.3 参数设计
灵活的参数设计能让Hook更通用:
javascript复制// 基础版
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = () => setValue(!value);
return [value, toggle];
}
// 增强版:支持自定义切换逻辑
function useToggle(initialValue = false, options = {}) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => {
setValue(prev => {
if (options.reverse) return !prev;
if (options.setter) return options.setter(prev);
return !prev;
});
}, [options]);
return [value, toggle];
}
3. 实战案例:构建健壮的useFetch Hook
3.1 基础实现
让我们从最常用的数据请求场景开始:
javascript复制function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch(url, options);
const json = await response.json();
setData(json);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
};
fetchData();
}, [url, options]);
return { data, error, loading };
}
3.2 添加高级功能
真实项目需要更多功能:
javascript复制function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState(null);
// 添加取消请求功能
const abortControllerRef = useRef(new AbortController());
const fetchData = useCallback(async (overrideUrl) => {
const controller = new AbortController();
abortControllerRef.current = controller;
setLoading(true);
setError(null);
try {
const targetUrl = overrideUrl || url;
const response = await fetch(targetUrl, {
...options,
signal: controller.signal
});
setStatus(response.status);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
setData(json);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err);
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}, [url, options]);
// 自动重试机制
const retry = useCallback(async (retries = 3, delay = 1000) => {
for (let i = 0; i < retries; i++) {
try {
await fetchData();
break;
} catch (err) {
if (i === retries - 1) throw err;
await new Promise(res => setTimeout(res, delay));
}
}
}, [fetchData]);
// 取消请求
const abort = useCallback(() => {
abortControllerRef.current.abort();
}, []);
useEffect(() => {
fetchData();
return () => {
abortControllerRef.current.abort();
};
}, [fetchData]);
return {
data,
error,
loading,
status,
refetch: fetchData,
retry,
abort
};
}
3.3 使用示例
javascript复制function UserProfile({ userId }) {
const {
data: user,
loading,
error,
retry
} = useFetch(`/api/users/${userId}`);
if (loading) return <Spinner />;
if (error) return (
<div>
<Error message={error.message} />
<button onClick={retry}>重试</button>
</div>
);
return (
<div>
<h1>{user.name}</h1>
<img src={user.avatar} alt={user.name} />
</div>
);
}
4. 组合Hook实现复杂逻辑
真正的威力在于组合使用多个Hook:
4.1 带防抖的搜索框
javascript复制function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
function SearchBox() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
const { data: results } = useFetch(`/api/search?q=${debouncedQuery}`);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索..."
/>
<ul>
{results?.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
4.2 带本地缓存的用户偏好设置
javascript复制function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
const setValue = (value) => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue];
}
function useUserPreferences() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [fontSize, setFontSize] = useLocalStorage('fontSize', 16);
const [notifications, setNotifications] = useLocalStorage('notifications', true);
const toggleTheme = useCallback(() => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
}, [setTheme]);
return {
theme,
fontSize,
notifications,
toggleTheme,
setFontSize,
setNotifications
};
}
5. 性能优化技巧
5.1 避免不必要的重新渲染
javascript复制function useMemoCompare(value, compare) {
const previousRef = useRef(value);
const previousValue = previousRef.current;
const isEqual = compare(previousValue, value);
useEffect(() => {
if (!isEqual) {
previousRef.current = value;
}
});
return isEqual ? previousValue : value;
}
// 使用示例:深度比较依赖项
function useDeepCompareEffect(callback, dependencies) {
const memoizedDeps = useMemoCompare(dependencies, (prev, next) => {
return JSON.stringify(prev) === JSON.stringify(next);
});
useEffect(callback, [memoizedDeps]);
}
5.2 使用useCallback优化事件处理
javascript复制function useEventListener(eventName, handler, element = window) {
const savedHandler = useRef();
useEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(() => {
const isSupported = element && element.addEventListener;
if (!isSupported) return;
const eventListener = (event) => savedHandler.current(event);
element.addEventListener(eventName, eventListener);
return () => {
element.removeEventListener(eventName, eventListener);
};
}, [eventName, element]);
}
// 使用示例
function ResizablePanel() {
const [width, setWidth] = useState(200);
const handleResize = useCallback((e) => {
setWidth(e.clientX);
}, []);
useEventListener('mousemove', handleResize);
return <div style={{ width }} />;
}
6. 测试自定义Hook
6.1 使用React Testing Library
javascript复制import { renderHook, act } from '@testing-library/react-hooks';
test('should use counter', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
6.2 测试异步Hook
javascript复制test('should fetch data', async () => {
const mockData = { id: 1, name: 'John' };
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve(mockData),
})
);
const { result, waitForNextUpdate } = renderHook(() =>
useFetch('/api/user')
);
expect(result.current.loading).toBe(true);
await waitForNextUpdate();
expect(result.current.loading).toBe(false);
expect(result.current.data).toEqual(mockData);
});
7. 常见问题与解决方案
7.1 Hook调用顺序问题
错误示例:
javascript复制function useExample(condition) {
if (condition) {
const [state, setState] = useState(null); // 条件性调用Hook
// ...
}
// ...
}
解决方案:
- 永远不要在条件、循环或嵌套函数中调用Hook
- 如果需要条件逻辑,把条件放在Hook内部:
javascript复制function useExample(condition) {
const [state, setState] = useState(null);
useEffect(() => {
if (condition) {
// 条件逻辑
}
}, [condition]);
}
7.2 闭包陷阱
javascript复制function useInterval(callback, delay) {
useEffect(() => {
const id = setInterval(() => {
callback(); // 总是调用初始callback
}, delay);
return () => clearInterval(id);
}, [delay]); // 缺少callback依赖
}
解决方案:
javascript复制function useInterval(callback, delay) {
const savedCallback = useRef();
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
const id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}
7.3 内存泄漏
javascript复制function useEventListener(eventName, handler, element = window) {
useEffect(() => {
element.addEventListener(eventName, handler);
// 缺少清理函数
}, [eventName, handler, element]);
}
正确实现:
javascript复制function useEventListener(eventName, handler, element = window) {
useEffect(() => {
element.addEventListener(eventName, handler);
return () => {
element.removeEventListener(eventName, handler);
};
}, [eventName, handler, element]);
}
8. 高级模式:Hook工厂函数
对于需要高度可配置的Hook,可以使用工厂模式:
javascript复制function createUsePagination(fetchData) {
return function usePagination(initialPage = 1, pageSize = 10) {
const [page, setPage] = useState(initialPage);
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [hasMore, setHasMore] = useState(true);
const loadPage = useCallback(async (pageNum) => {
setLoading(true);
try {
const result = await fetchData(pageNum, pageSize);
setData(prev => [...prev, ...result.items]);
setHasMore(result.total > pageNum * pageSize);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
}, [pageSize]);
const nextPage = useCallback(() => {
const newPage = page + 1;
setPage(newPage);
loadPage(newPage);
}, [page, loadPage]);
useEffect(() => {
loadPage(page);
}, []);
return {
data,
loading,
error,
page,
nextPage,
hasMore,
refresh: () => loadPage(page)
};
};
}
// 使用示例
const fetchUserList = async (page, size) => {
const response = await fetch(`/api/users?page=${page}&size=${size}`);
return response.json();
};
const useUserPagination = createUsePagination(fetchUserList);
function UserList() {
const { data, loading, nextPage, hasMore } = useUserPagination();
return (
<div>
<ul>
{data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
{loading && <Spinner />}
{hasMore && !loading && (
<button onClick={nextPage}>加载更多</button>
)}
</div>
);
}
9. 类型安全的TypeScript实现
为自定义Hook添加类型支持:
typescript复制interface FetchResult<T> {
data: T | null;
error: Error | null;
loading: boolean;
status: number | null;
refetch: (overrideUrl?: string) => Promise<void>;
retry: (retries?: number, delay?: number) => Promise<void>;
abort: () => void;
}
function useFetch<T = any>(
url: string,
options?: RequestInit
): FetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<number | null>(null);
const abortControllerRef = useRef(new AbortController());
const fetchData = useCallback(async (overrideUrl?: string) => {
const controller = new AbortController();
abortControllerRef.current = controller;
setLoading(true);
setError(null);
try {
const targetUrl = overrideUrl || url;
const response = await fetch(targetUrl, {
...options,
signal: controller.signal
});
setStatus(response.status);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json() as T;
setData(json);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err as Error);
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}, [url, options]);
// ...其他实现与之前相同
return {
data,
error,
loading,
status,
refetch: fetchData,
retry,
abort
};
}
// 使用示例
interface User {
id: number;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: number }) {
const { data: user } = useFetch<User>(`/api/users/${userId}`);
if (!user) return <div>Loading...</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
10. 生态系统集成
10.1 与Redux结合
javascript复制function useReduxAction(actionCreator) {
const dispatch = useDispatch();
return useCallback((...args) => {
return dispatch(actionCreator(...args));
}, [dispatch, actionCreator]);
}
// 使用示例
const fetchUser = (userId) => ({
type: 'FETCH_USER',
payload: { userId }
});
function UserContainer({ userId }) {
const boundFetchUser = useReduxAction(fetchUser);
const user = useSelector(state => state.users[userId]);
useEffect(() => {
boundFetchUser(userId);
}, [boundFetchUser, userId]);
// ...
}
10.2 与React Router集成
javascript复制function useQueryParams() {
const location = useLocation();
const navigate = useNavigate();
const queryParams = useMemo(() => {
const searchParams = new URLSearchParams(location.search);
return Object.fromEntries(searchParams.entries());
}, [location.search]);
const setQueryParams = useCallback((params) => {
const searchParams = new URLSearchParams(params);
navigate({ search: searchParams.toString() });
}, [navigate]);
return [queryParams, setQueryParams];
}
// 使用示例
function SearchPage() {
const [queryParams, setQueryParams] = useQueryParams();
const [searchTerm, setSearchTerm] = useState(queryParams.q || '');
const handleSearch = useCallback(() => {
setQueryParams({ q: searchTerm });
}, [searchTerm, setQueryParams]);
return (
<div>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<button onClick={handleSearch}>搜索</button>
</div>
);
}
11. 性能监控Hook
javascript复制function usePerformanceLogger(name) {
const startTimeRef = useRef(performance.now());
useEffect(() => {
const measure = () => {
const duration = performance.now() - startTimeRef.current;
console.log(`${name} took ${duration.toFixed(2)}ms`);
// 也可以发送到监控系统
// trackMetric(name, duration);
};
return measure;
}, [name]);
}
// 使用示例
function ExpensiveComponent() {
usePerformanceLogger('ExpensiveComponent render');
// 昂贵的计算或渲染
const data = useMemo(() => {
// ...复杂计算
}, []);
return <div>{data}</div>;
}
12. 调试技巧
12.1 开发专用Hook
javascript复制function useWhyDidYouUpdate(name, props) {
const previousProps = useRef({});
useEffect(() => {
if (__DEV__) {
const changes = {};
const allKeys = new Set([
...Object.keys(previousProps.current),
...Object.keys(props)
]);
allKeys.forEach(key => {
if (previousProps.current[key] !== props[key]) {
changes[key] = {
from: previousProps.current[key],
to: props[key]
};
}
});
if (Object.keys(changes).length) {
console.log('[why-did-you-update]', name, changes);
}
previousProps.current = props;
}
});
}
// 使用示例
function MyComponent(props) {
useWhyDidYouUpdate('MyComponent', props);
// ...
}
12.2 自定义Hook调试工具
javascript复制function useDebugValueHook(value, formatter) {
// 在生产环境不会增加开销
useDebugValue(value, formatter);
}
// 使用示例
function useFriendStatus(friendID) {
const [isOnline, setIsOnline] = useState(null);
// 在React DevTools中显示标签
useDebugValueHook(isOnline, status =>
status === null ? 'Loading...' : status ? 'Online' : 'Offline'
);
useEffect(() => {
const handleStatusChange = (status) => {
setIsOnline(status.isOnline);
};
ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
};
}, [friendID]);
return isOnline;
}
13. 最佳实践总结
-
保持Hook小而专一:每个Hook只解决一个具体问题,就像乐高积木的每个零件都有明确用途。
-
完善的错误处理:特别是异步操作,要考虑加载状态、错误状态和取消逻辑。
-
清晰的接口设计:返回值的结构应该直观,命名要有意义,让使用者一看就明白。
-
充分的类型定义:即使是JavaScript项目,也建议使用JSDoc提供类型提示。
-
完整的测试覆盖:特别是边界条件和异常场景。
-
性能优化意识:合理使用useMemo、useCallback避免不必要的计算和渲染。
-
完善的文档:至少包含:
- 基本用法示例
- 参数说明
- 返回值说明
- 注意事项
-
版本兼容性考虑:如果Hook会被多个项目使用,要考虑React版本的兼容性。
14. 推荐工具库
虽然自定义Hook很强大,但有些通用场景可以直接使用成熟方案:
- ahooks - 阿里开源的React Hooks库
- react-use - 社区最流行的Hooks集合
- usehooks-ts - TypeScript友好的Hooks集合
- react-query - 数据请求管理
- swr - 数据获取Hook
15. 从Hook到自定义抽象
当多个Hook经常一起使用时,可以考虑进一步抽象:
javascript复制// 数据列表常见模式抽象
function useDataList(fetchData, initialParams = {}) {
const [params, setParams] = useState(initialParams);
const { data, loading, error, retry } = useFetch(() => fetchData(params));
const [selectedItem, setSelectedItem] = useState(null);
const updateParams = useCallback((newParams) => {
setParams(prev => ({ ...prev, ...newParams }));
}, []);
return {
data,
loading,
error,
params,
updateParams,
selectedItem,
setSelectedItem,
refresh: retry
};
}
// 使用示例
function fetchProducts(params) {
const query = new URLSearchParams(params);
return fetch(`/api/products?${query}`).then(res => res.json());
}
function ProductList() {
const {
data: products,
loading,
updateParams
} = useDataList(fetchProducts, { page: 1, size: 10 });
const handleSearch = useCallback((keyword) => {
updateParams({ keyword });
}, [updateParams]);
// ...
}
