目录
简介
自定义Hook是React 16.8引入的一个强大特性,它允许开发者将组件逻辑提取到可重用的函数中。通过自定义Hook,我们可以在不同组件间共享状态逻辑,提高代码的可维护性和可测试性。
优势
- 逻辑复用: 在多个组件间共享相同的状态逻辑
- 关注点分离: 将复杂的逻辑从组件中抽离
- 更好的测试性: 可以独立测试Hook逻辑
- 代码组织: 让组件更加简洁,专注于UI渲染
基础概念
什么是自定义Hook?
自定义Hook是一个JavaScript函数,其名称以"use"开头,并且可以调用其他Hook。它本质上是一个函数式编程的抽象,用于封装和复用状态逻辑。
Hook vs 高阶组件 vs Render Props
特性 | 自定义Hook | 高阶组件 | Render Props |
---|---|---|---|
语法复杂度 | 低 | 中 | 中 |
嵌套问题 | 无 | 有 | 有 |
调试友好度 | 高 | 中 | 中 |
TypeScript支持 | 优秀 | 良好 | 良好 |
Hook规则
核心规则
- 只在函数最顶层调用Hook - 不要在循环、条件或嵌套函数中调用Hook
- 只在React函数中调用Hook - 包括React函数组件和自定义Hook
- Hook名称必须以"use"开头 - 这是约定俗成的命名规范
错误示例
// ❌ 错误:在条件语句中调用Hook
function BadComponent({ shouldUseEffect }) {
if (shouldUseEffect) {
useEffect(() => {
// ...
});
}
}
// ❌ 错误:在循环中调用Hook
function BadComponent({ items }) {
items.forEach(item => {
useState(item); // 错误!
});
}
正确示例
// ✅ 正确:在顶层调用Hook
function GoodComponent({ shouldUseEffect }) {
useEffect(() => {
if (shouldUseEffect) {
// 在这里处理条件逻辑
}
});
}
创建第一个自定义Hook
步骤1:识别可复用的逻辑
首先识别组件中重复出现的状态逻辑。例如,表单输入的状态管理:
// 在多个组件中重复的逻辑
const [value, setValue] = useState('');
const handleChange = (e) => setValue(e.target.value);
const reset = () => setValue('');
步骤2:提取到自定义Hook
function useInput(initialValue = '') {
const [value, setValue] = useState(initialValue);
const handleChange = (e) => {
setValue(e.target.value);
};
const reset = () => {
setValue(initialValue);
};
return {
value,
onChange: handleChange,
reset
};
}
步骤3:在组件中使用
function LoginForm() {
const username = useInput('');
const password = useInput('');
const handleSubmit = (e) => {
e.preventDefault();
console.log(username.value, password.value);
username.reset();
password.reset();
};
return (
<form onSubmit={handleSubmit}>
<input {...username} placeholder="用户名" />
<input {...password} type="password" placeholder="密码" />
<button type="submit">登录</button>
</form>
);
}
常见模式与最佳实践
1. 数据获取模式
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true;
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (isMounted) {
setData(result);
}
} catch (err) {
if (isMounted) {
setError(err.message);
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
fetchData();
return () => {
isMounted = false;
};
}, [url]);
return { data, loading, error };
}
2. 本地存储模式
function useLocalStorage(key, initialValue) {
// 懒初始化避免服务端渲染问题
const [storedValue, setStoredValue] = useState(() => {
if (typeof window === 'undefined') {
return initialValue;
}
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 setValue = useCallback((value) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
}, [key, storedValue]);
return [storedValue, setValue];
}
3. 防抖模式
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
4. 事件监听模式
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]);
}
高级技巧
1. 使用useReducer处理复杂状态
function useFormReducer(initialState) {
const formReducer = (state, action) => {
switch (action.type) {
case 'SET_FIELD':
return {
...state,
[action.field]: action.value
};
case 'SET_ERROR':
return {
...state,
errors: {
...state.errors,
[action.field]: action.error
}
};
case 'RESET':
return initialState;
default:
return state;
}
};
const [state, dispatch] = useReducer(formReducer, initialState);
const setField = (field, value) => {
dispatch({ type: 'SET_FIELD', field, value });
};
const setError = (field, error) => {
dispatch({ type: 'SET_ERROR', field, error });
};
const reset = () => {
dispatch({ type: 'RESET' });
};
return {
state,
setField,
setError,
reset
};
}
2. 组合多个Hook
function useAuthenticatedFetch(url) {
const { token, isAuthenticated } = useAuth();
const { data, loading, error } = useFetch(
isAuthenticated ? url : null,
{
headers: {
'Authorization': `Bearer ${token}`
}
}
);
return {
data,
loading: loading || !isAuthenticated,
error: !isAuthenticated ? 'Not authenticated' : error
};
}
3. 使用Context优化
function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
测试策略
1. 使用@testing-library/react-hooks测试Hook
import { renderHook, act } from '@testing-library/react-hooks';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('should initialize with correct value', () => {
const { result } = renderHook(() => useCounter(5));
expect(result.current.count).toBe(5);
});
it('should increment count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
});
2. 测试异步Hook
import { renderHook, waitFor } from '@testing-library/react-hooks';
import { useFetch } from './useFetch';
// Mock fetch
global.fetch = jest.fn();
describe('useFetch', () => {
beforeEach(() => {
fetch.mockClear();
});
it('should fetch data successfully', async () => {
const mockData = { id: 1, name: 'Test' };
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockData,
});
const { result } = renderHook(() => useFetch('/api/test'));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBe(null);
});
});
性能优化
1. 使用useMemo和useCallback
function useExpensiveCalculation(data) {
const expensiveValue = useMemo(() => {
return data.reduce((acc, item) => acc + item.value, 0);
}, [data]);
const memoizedCallback = useCallback((newItem) => {
// 处理新项目
}, []);
return { expensiveValue, addItem: memoizedCallback };
}
2. 避免不必要的重新渲染
function useStableCallback(callback) {
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
});
return useCallback((...args) => {
return callbackRef.current(...args);
}, []);
}
3. 懒初始化
function useExpensiveState(computeInitialState) {
const [state, setState] = useState(() => {
return computeInitialState();
});
return [state, setState];
}
故障排除
常见问题
-
Hook调用顺序不一致
- 确保Hook始终以相同的顺序调用
- 不要在条件语句中调用Hook
-
闭包陷阱
- 使用useCallback和useMemo正确处理依赖
- 理解JavaScript闭包的工作原理
-
内存泄漏
- 在useEffect中正确清理副作用
- 取消未完成的异步操作
-
无限重新渲染
- 检查useEffect的依赖数组
- 避免在依赖数组中使用对象或数组字面量
调试技巧
-
使用React DevTools
- 检查Hook的状态和更新
- 分析组件的重新渲染原因
-
添加调试日志
function useDebugValue(label, value) {
useDebugValue(`${label}: ${JSON.stringify(value)}`);
return value;
}
- 使用ESLint插件
- 安装并配置eslint-plugin-react-hooks
- 自动检测Hook规则违反
实际应用案例
案例1:购物车管理
function useShoppingCart() {
const [items, setItems] = useState([]);
const addItem = useCallback((product) => {
setItems(prev => {
const existingItem = prev.find(item => item.id === product.id);
if (existingItem) {
return prev.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
}
return [...prev, { ...product, quantity: 1 }];
});
}, []);
const removeItem = useCallback((productId) => {
setItems(prev => prev.filter(item => item.id !== productId));
}, []);
const updateQuantity = useCallback((productId, quantity) => {
if (quantity <= 0) {
removeItem(productId);
return;
}
setItems(prev =>
prev.map(item =>
item.id === productId
? { ...item, quantity }
: item
)
);
}, [removeItem]);
const totalPrice = useMemo(() => {
return items.reduce((total, item) => total + item.price * item.quantity, 0);
}, [items]);
const totalItems = useMemo(() => {
return items.reduce((total, item) => total + item.quantity, 0);
}, [items]);
const clearCart = useCallback(() => {
setItems([]);
}, []);
return {
items,
addItem,
removeItem,
updateQuantity,
clearCart,
totalPrice,
totalItems
};
}
案例2:实时数据同步
function useWebSocket(url) {
const [socket, setSocket] = useState(null);
const [lastMessage, setLastMessage] = useState(null);
const [readyState, setReadyState] = useState(WebSocket.CONNECTING);
useEffect(() => {
const ws = new WebSocket(url);
ws.onopen = () => {
setReadyState(WebSocket.OPEN);
setSocket(ws);
};
ws.onmessage = (event) => {
setLastMessage(JSON.parse(event.data));
};
ws.onclose = () => {
setReadyState(WebSocket.CLOSED);
};
ws.onerror = () => {
setReadyState(WebSocket.CLOSED);
};
return () => {
ws.close();
};
}, [url]);
const sendMessage = useCallback((message) => {
if (socket && readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
}
}, [socket, readyState]);
return {
lastMessage,
readyState,
sendMessage
};
}