前言
React Hooks 自 React 16.8 引入以来,彻底改变了组件逻辑复用的方式。自定义 Hook 是 Hooks 最强大的特性之一,它允许我们将组件逻辑提取到可复用的函数中。然而,实际开发中,很多开发者容易陷入“过度抽象”或“设计不当”的陷阱,导致 Hook 难以维护、复用性差。本文将分享我在实际项目中积累的自定义 Hook 封装最佳实践,通过具体案例带你掌握从设计到测试的完整流程。
一、设计原则:单一职责与组合优于继承
1.1 单一职责
一个 Hook 只做一件事。例如,不要将数据获取和表单验证混在一个 Hook 中。
// ❌ 错误:一个 Hook 做两件事
function useUserAndValidate() {
const [user, setUser] = useState(null);
const [errors, setErrors] = useState({});
// ... 数据获取与验证逻辑混在一起
}
// ✅ 正确:分离关注点
function useUser(userId) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
return user;
}
function useValidation(values) {
const [errors, setErrors] = useState({});
useEffect(() => {
// 仅验证逻辑
}, [values]);
return errors;
}
1.2 组合优于继承
利用多个基础 Hook 组合成复杂 Hook,而不是在一个 Hook 中堆砌所有功能。
function useUserProfile(userId) {
const user = useUser(userId);
const permissions = usePermissions(user?.role);
const theme = useTheme();
return { user, permissions, theme };
}
二、状态管理:避免“状态爆炸”
2.1 使用 useReducer 替代多个 useState
当状态逻辑复杂且包含多个子值时,使用 useReducer 让状态更新更可预测。
function useForm(initialValues) {
const [state, dispatch] = useReducer((state, action) => {
switch (action.type) {
case 'SET_FIELD':
return { ...state, [action.field]: action.value };
case 'RESET':
return initialValues;
default:
return state;
}
}, initialValues);
const setField = useCallback((field, value) => {
dispatch({ type: 'SET_FIELD', field, value });
}, []);
const reset = useCallback(() => {
dispatch({ type: 'RESET' });
}, []);
return [state, setField, reset];
}
2.2 避免在 Hook 内部创建不必要的对象
每次渲染都创建新对象会导致依赖比较失效,引发无限循环。
// ❌ 错误:每次渲染创建新对象
function useData() {
const [data, setData] = useState(null);
useEffect(() => {
fetchData().then(setData);
}, []); // 依赖为空,但 fetchData 可能变化?
return { data, setData };
}
// ✅ 正确:稳定引用
function useData() {
const [data, setData] = useState(null);
useEffect(() => {
const fetchData = async () => {
const result = await api.get('/data');
setData(result);
};
fetchData();
}, []); // 依赖稳定
return { data, setData };
}
三、副作用处理:优雅地处理清理与竞态
3.1 清理函数防止内存泄漏
在 useEffect 中返回清理函数,取消订阅或异步操作。
function useWebSocket(url) {
const [message, setMessage] = useState(null);
useEffect(() => {
const ws = new WebSocket(url);
ws.onmessage = (event) => setMessage(event.data);
return () => {
ws.close(); // 组件卸载时关闭连接
};
}, [url]);
return message;
}
3.2 使用 AbortController 取消请求
对于异步请求,使用 AbortController 避免竞态条件。
function useFetch(url) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const abortController = new AbortController();
const fetchData = async () => {
try {
const response = await fetch(url, { signal: abortController.signal });
const result = await response.json();
setData(result);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err);
}
}
};
fetchData();
return () => {
abortController.abort(); // 组件卸载或 url 变化时取消请求
};
}, [url]);
return { data, error };
}
四、参数设计:灵活且类型安全
4.1 使用选项对象模式
当参数较多时,使用对象参数提高可读性。
// ❌ 错误:参数过多,难以记忆
function useTimer(delay, interval, autoStart, onTick, onComplete) {}
// ✅ 正确:选项对象
function useTimer({ delay, interval, autoStart = false, onTick, onComplete }) {}
4.2 提供默认值与类型检查
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key “${key}”:`, error);
return initialValue;
}
});
const setStoredValue = useCallback((newValue) => {
setValue((prev) => {
const valueToStore = newValue instanceof Function ? newValue(prev) : newValue;
try {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Error setting localStorage key “${key}”:`, error);
}
return valueToStore;
});
}, [key]);
return [value, setStoredValue];
}
五、测试策略:确保 Hook 的可靠性
5.1 使用 @testing-library/react-hooks
import { renderHook, act } from '@testing-library/react-hooks';
import useCounter from './useCounter';
test('should increment counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
5.2 测试异步 Hook
import { renderHook } from '@testing-library/react-hooks';
import useFetch from './useFetch';
// 模拟 fetch
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ data: 'test' }),
})
);
test('should fetch data', async () => {
const { result, waitForNextUpdate } = renderHook(() => useFetch('/api'));
await waitForNextUpdate();
expect(result.current.data).toEqual({ data: 'test' });
});
六、常见坑与踩坑经验
6.1 忘记在 useEffect 中指定依赖
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []); // 正确:空依赖,因为 handleResize 不依赖任何值
return width;
}
6.2 在渲染函数中直接调用 Hooks
Hooks 只能在函数组件或自定义 Hook 的顶层调用,不能在条件语句或循环中调用。
// ❌ 错误
if (condition) {
useEffect(() => {});
}
// ✅ 正确
useEffect(() => {
if (condition) {
// 执行副作用
}
}, [condition]);
6.3 过度抽象导致性能问题
不要为每个小功能都创建 Hook,避免不必要的性能开销。
七、总结
本文从设计原则、状态管理、副作用处理、参数设计、测试策略等方面,系统性地介绍了自定义 Hook 的最佳实践。关键要点:
- 单一职责:一个 Hook 只做一件事。
- 组合复用:利用多个基础 Hook 组合成复杂逻辑。
- 稳定引用:避免每次渲染创建新对象或函数。
- 清理副作用:及时取消订阅或请求。
- 灵活参数:使用选项对象并提供默认值。
- 充分测试:使用
@testing-library/react-hooks确保可靠性。
延伸阅读:
- React 官方文档:自定义 Hook
- useHooks.com – 收集了大量实用的自定义 Hook 示例
希望本文能帮助你写出更优雅、可维护的自定义 Hook。如果你有更好的实践,欢迎在评论区分享!