自定义 Hook 是 React 中复用状态逻辑的核心手段。它允许你将组件内的有状态逻辑(如数据获取、表单处理、订阅、计时器等)提取到可复用的函数中,从而让组件更专注于渲染,逻辑更清晰、可测试。
8.4.1 什么是自定义 Hook
自定义 Hook 本质上是一个函数,其名称以 use 开头,内部可以调用其他 Hook(如 useState、useEffect、useRef 等)。它遵循 React Hook 的所有规则,但可以自由组合参数和返回值,封装任意复杂的逻辑。
function useDocumentTitle(title) {
useEffect(() => {
document.title = title;
}, [title]);
}
调用方只需:
function HomePage() {
useDocumentTitle('首页 - 我的应用');
return <div>...</div>;
}
自定义 Hook 让“设置文档标题”这一逻辑可以在任何组件中零成本复用,而组件本身不受影响。
8.4.2 封装原则与命名规范
命名规范
- 必须以
use开头,这是 React 用来校验 Hook 规则的约定。 - 名称应描述功能而非实现,如
useFetch、useMediaQuery、useLocalStorage。
封装原则
- 单一责任:一个自定义 Hook 只做一件事。如果一个 Hook 既请求数据又管理分页,考虑拆分为
useFetch+usePagination。 - 可配置性:通过参数让行为可定制,例如请求的 URL、防抖的延迟时间等。
- 返回值精简:返回足够少但必要的数据和方法。典型返回一个对象,支持解构。
- 无副作用泄漏:确保
useEffect等副作用有清理逻辑,避免内存泄漏。 - 纯逻辑,不渲染:自定义 Hook 返回的是状态和更新函数,不返回 JSX。UI 交由组件负责。
8.4.3 从业务组件中提取自定义 Hook
常见的需求是“一个按钮,点击后发送请求,需要 loading 状态和错误处理”。如果直接在组件中写,代码会臃肿且不可复用:
// ❌ 耦合在组件中
function SubmitButton() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async () => {
setLoading(true);
try {
await api.submit();
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return <button onClick={handleSubmit} disabled={loading}>提交</button>;
}
提取为 useAsync:
function useAsync(asyncFn) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const execute = useCallback(async (...args) => {
setLoading(true);
setError(null);
try {
const result = await asyncFn(...args);
return result;
} catch (err) {
setError(err.message);
throw err;
} finally {
setLoading(false);
}
}, [asyncFn]);
return { loading, error, execute };
}
复用后,组件变得极其简洁:
function SubmitButton() {
const { loading, error, execute } = useAsync(api.submit);
return (
<div>
<button onClick={execute} disabled={loading}>提交</button>
{error && <p>{error}</p>}
</div>
);
}
8.4.4 常见通用 Hooks 封装示例
1. useLocalStorage:持久化状态
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
window.localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
用法:const [theme, setTheme] = useLocalStorage('theme', 'light');
2. useDebounce:防抖输入
function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
用于搜索输入:const debouncedKeyword = useDebounce(keyword, 500);,然后根据 debouncedKeyword 发起请求。
3. useMediaQuery:响应式查询
function useMediaQuery(query) {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const mql = window.matchMedia(query);
const handler = (e) => setMatches(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, [query]);
return matches;
}
用法:const isMobile = useMediaQuery('(max-width: 768px)');
4. usePrevious:获取上一轮渲染的值
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
常用于比较前后 Props 变化,但注意返回值在初次渲染时为 undefined。
5. useIsMounted:检查组件是否仍挂载
function useIsMounted() {
const isMounted = useRef(false);
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
return isMounted;
}
避免在已卸载的组件上设置状态。
8.4.5 自定义 Hooks 的常见坑点与解决
1. 闭包过期(Stale Closure)
当自定义 Hook 内部使用了外部变量或状态,但没有在依赖数组中声明,可能导致使用了旧的引用。
function useInterval(callback, delay) {
useEffect(() => {
const id = setInterval(callback, delay);
return () => clearInterval(id);
}, [delay]); // ❌ 缺少 callback 依赖
}
解法:将 callback 用 useRef 保存,避免需要其作为依赖。
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
2. 过度抽象
不要为了“DRY”而创建只有一处使用的自定义 Hook。如果一个 Hook 没有明显的复用价值,或者其抽象使得逻辑难以理解,保持内联在组件中可能更好。等待第二次复用时再提取。
3. 返回值混乱
如果一个 Hook 返回了太多状态和方法,调用方难以理解。尽量返回对象而非数组(除非明确像 useState 那样解构),并且保持接口稳定。
4. 副作用清理不完整
在 useEffect 中订阅、添加事件监听、设置定时器,务必返回清理函数。自定义 Hook 内部同样适用这条规则,否则在组件卸载后可能发生状态更新报错。
8.4.6 设计一个健壮的自定义 Hook:useFetch 示例
一个封装数据请求的 Hook 需要考虑:加载、错误、取消竞态、缓存、自动重取等。这里给出一个基础但实用的版本:
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Network error');
const json = await response.json();
if (!cancelled) {
setData(json);
setError(null);
}
} catch (err) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setLoading(false);
}
};
fetchData();
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}
关键设计:cancelled 标志避免竞态下旧请求覆盖新请求的状态。调用方只需:
const { data, loading, error } = useFetch('/api/user');
8.4.7 通用业务 Hooks 封装示例
真实项目中常见的自定义 Hook 往往与业务逻辑相关,例如权限检查、日志上报、表单校验等。
function usePermission(requiredPermission) {
const { user } = useUser(); // 假设有获取当前用户的 Hook
return user?.permissions?.includes(requiredPermission) ?? false;
}
这样任何组件都可以通过 const canEdit = usePermission('edit') 干净地获取权限状态。
8.4.8 最佳实践总结
- 从组件中提取,而不是凭空设计:当发现多个组件有相似的状态逻辑时,再抽象成 Hook。
- 保持函数纯净:自定义 Hook 自身应该是纯函数?不,内部可以使用 Hooks,但应确保没有隐式外部依赖,所有依赖都应通过参数或内部 Hook 显式声明。
- 测试友好:自定义 Hook 可以独立于组件进行测试,利用
renderHook方法(来自@testing-library/react)验证行为。 - 使用 TypeScript 标注类型:明确参数和返回值类型,方便 IDE 提示和调用方理解。
自定义 Hook 是 React 组合模式的高阶体现,它将状态逻辑从组件中剥离,使得代码结构更扁平、复用性更强。掌握了自定义 Hook 的设计,你就能够构建一套抽象良好、可扩展的应用逻辑层。