React 自定义 Hooks 完全指南:逻辑复用与模块化
React 自定义 Hooks 完全指南:逻辑复用与模块化
一、Introduction
React 内置的 Hooks(useState、useEffect、useRef 等)提供了构建 UI 的基础能力,但当你的组件中有复杂的逻辑需要在多个地方复用时,内置 Hooks 就不够用了。自定义 Hook(Custom Hook)应运而生:它本质上就是一个使用其他 Hooks 的 JavaScript 函数,通过约定以 use 开头来让 React 自动执行 lint 规则检查。
自定义 Hook 是 React 中最重要的代码复用模式,它比 HOC(高阶组件)和 Render Props 更直观、更灵活,也更符合 React Hooks 的设计哲学。学会写自定义 Hook 是 React 开发者从"会用"到"用好"的分水岭。
---
二、自定义 Hook 的核心概念
#### 2.1 自定义 Hook 本质
自定义 Hook 就是带 use 前缀的函数,内部可以调用其他 Hooks,React 通过函数名是否以 use 开头来判断是否应用 Hooks 规则。
// 自定义 Hook:提取计数器逻辑
import { useState, useCallback } from 'react';
// 这个函数就是一个自定义 Hook
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => Math.max(0, c - 1)), []);
const reset = useCallback(() => setCount(initialValue), [initialValue]);
// 返回状态和操作方法
return { count, increment, decrement, reset };
}
// 使用自定义 Hook 的组件
function CounterA() {
const { count, increment, decrement, reset } = useCounter(0);
return (
<div>
<p>A 计数器:{count}</p>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>重置</button>
</div>
);
}
function CounterB() {
// 完全独立的计数器实例,逻辑复用但状态隔离
const { count, increment, reset } = useCounter(10);
return (
<div>
<p>B 计数器(初始10):{count}</p>
<button onClick={increment}>+1</button>
<button onClick={reset}>重置</button>
</div>
);
}
---
三、实用自定义 Hook 实战
#### 3.1 useLocalStorage:持久化状态到浏览器本地存储
import { useState, useEffect, useCallback } from 'react';
// 自定义 Hook:将 state 同步到 localStorage
function useLocalStorage(key, initialValue) {
// 初始化:优先从 localStorage 读取,否则用初始值
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.warn(`读取 localStorage key "${key}" 失败:`, error);
return initialValue;
}
});
// 每当 storedValue 变化时,同步到 localStorage
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
console.warn(`写入 localStorage key "${key}" 失败:`, error);
}
}, [key, storedValue]);
// 返回稳定的 setter 函数
const setValue = useCallback((value) => {
setStoredValue(prev => {
// 支持直接传入值或函数
const valueToStore = value instanceof Function ? value(prev) : value;
return valueToStore;
});
}, []);
return [storedValue, setValue];
}
// 使用示例
function SettingsPanel() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [username, setUsername] = useLocalStorage('username', '');
return (
<div style={{ background: theme === 'dark' ? '#333' : '#fff', color: theme === 'dark' ? '#fff' : '#333', padding: 16 }}>
<h3>设置面板(刷新后设置不丢失)</h3>
<div>
<label>
主题:
<select value={theme} onChange={e => setTheme(e.target.value)}>
<option value="light">浅色</option>
<option value="dark">深色</option>
</select>
</label>
</div>
<div style={{ marginTop: 12 }}>
<label>
用户名:
<input
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="输入用户名"
/>
</label>
</div>
<p style={{ fontSize: 12, marginTop: 12 }}>
当前主题:{theme},用户名:{username || '(未设置)'}
</p>
</div>
);
}
export default SettingsPanel;
#### 3.2 useDebounce:防抖 Hook
import { useState, useEffect } from 'react';
// 延迟更新值的 Hook,常用于搜索框输入防抖
function useDebounce(value, delay = 500) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
// 每次 value 变化时,重置定时器
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// 清理函数:在下次 effect 执行时清除上次的定时器
// 这样只有 value 停止变化 delay 毫秒后,debouncedValue 才更新
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// 使用示例:搜索框防抖
function DebouncedSearch() {
const [query, setQuery] = useState('');
// 只有用户停止输入 500ms 后,debouncedQuery 才会更新
const debouncedQuery = useDebounce(query, 500);
useEffect(() => {
if (!debouncedQuery) return;
console.log(`发起 API 请求,搜索词:${debouncedQuery}`);
// 这里放实际的 API 调用,如 fetch(`/api/search?q=${debouncedQuery}`)
}, [debouncedQuery]);
return (
<div>
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="输入搜索词(500ms 防抖)..."
style={{ padding: 8, width: 300 }}
/>
<p style={{ color: '#888', marginTop: 8 }}>
搜索词(实时):{query}
<br />
防抖后值:{debouncedQuery || '(等待输入...)'}
</p>
</div>
);
}
export { useDebounce, DebouncedSearch };
#### 3.3 useFetch:数据请求 Hook
import { useState, useEffect } from 'react';
// 封装异步数据请求逻辑
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP 错误: ${response.status} ${response.statusText}`);
}
const json = await response.json();
setData(json);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
// 只有在没有取消请求时才设置 loading 为 false
setLoading(false);
}
};
fetchData();
// 清理:组件卸载或 url 变化时取消请求
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
// 使用示例
function GitHubUser({ username }) {
const { data, loading, error } = useFetch(
username ? `https://api.github.com/users/${username}` : null
);
if (!username) return <p>请输入 GitHub 用户名</p>;
if (loading) return <p>加载中...</p>;
if (error) return <p>错误:{error}</p>;
return (
<div>
<img src={data.avatar_url} alt={data.login} style={{ width: 80, borderRadius: '50%' }} />
<h3>{data.name || data.login}</h3>
<p>粉丝:{data.followers},关注:{data.following}</p>
<p>位置:{data.location || '未知'}</p>
</div>
);
}
export { useFetch, GitHubUser };
---
四、运行效果说明
useCounter:CounterA 和 CounterB 是完全独立的计数器实例,共享同一套增减重置逻辑,但各自维护自己的状态,互不影响。刷新页面计数器会重置为初始值。
useLocalStorage:SettingsPanel 的设置写入 localStorage,刷新页面或关闭浏览器后重新打开,theme 和 username 保持上次的值。
useDebounce:在搜索框快速输入"react hooks",debouncedQuery 只在停止输入 500ms 后才更新,API 请求次数大大减少。
useFetch:输入 GitHub 用户名(如 "facebook"),自动发起请求并展示用户头像、粉丝数和位置。
---
五、常见问题(FAQ)
#### Q1:自定义 Hook 和普通函数有什么区别?
关键区别:自定义 Hook 可以调用其他 Hooks,普通函数不行。React 根据函数名是否以 use 开头来判断它是否使用了 Hooks 规则(这也是为什么自定义 Hook 必须以 use 开头)。此外,自定义 Hook 的每次调用都拥有完全独立的状态——这叫"状态共享隔离",每次组件调用 Hook,React 内部都会为其分配独立的状态存储。
// 两个组件各调用一次 useCustomHook,它们的状态完全独立
function ComponentA() {
const { value } = useCustomHook(); // 独立的状态 "value"
}
function ComponentB() {
const { value } = useCustomHook(); // 另一个独立的状态 "value"
}
#### Q2:自定义 Hook 可以使用其他自定义 Hook 吗?
可以。 自定义 Hook 之间可以自由嵌套和组合,形成逻辑的层层抽象:
function useAuth() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// ... 认证逻辑
return { user, loading, login, logout };
}
function useUserData() {
const { user } = useAuth(); // 在自定义 Hook 中调用另一个自定义 Hook
const [data, setData] = useState(null);
useEffect(() => {
if (!user) return;
fetchUserData(user.id).then(setData);
}, [user]);
return { data, loading: !user && !data };
}
#### Q3:自定义 Hook 的逻辑和组件的渲染逻辑应该怎么分离?
原则:自定义 Hook 只负责数据和逻辑,组件只负责 UI 渲染。不要在自定义 Hook 里直接返回 JSX。
// 错误:自定义 Hook 返回 JSX
function useLoginForm() {
// ... 表单逻辑
return <form>...</form>; // 不要这样做
}
// 正确:自定义 Hook 只返回数据和方法
function useLoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
try {
await login(email, password);
} catch (err) {
setError(err.message);
}
};
return { email, setEmail, password, setPassword, error, handleSubmit };
}
function LoginForm() {
// 组件只负责 UI,数据和逻辑来自自定义 Hook
const { email, setEmail, password, setPassword, error, handleSubmit } = useLoginForm();
return (
<form onSubmit={handleSubmit}>
<input value={email} onChange={e => setEmail(e.target.value)} />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} />
{error && <p>{error}</p>}
<button type="submit">登录</button>
</form>
);
}
---
六、延伸阅读
- [React Hooks 完全指南:useState 与 useEffect 核心用法]()
- [React useRef 完全指南:操作 DOM 与持久化变量]()
- [React Context API 状态管理深度理解]()
- [React 组件设计模式:高阶组件与 Render Props]()
---