引言
React Hooks 已成为现代 React 开发的核心范式,而自定义 Hook 则为我们提供了强大的代码复用机制。
自定义 Hook 的基础原理
自定义 Hook 本质上是一种函数复用机制,它允许我们将组件逻辑提取到可重用的函数中。与传统的高阶组件(HOC)和 render props 模式相比,Hook 提供了更直接的状态共享方式,不会引入额外的组件嵌套。
自定义 Hook 的核心规则
- 命名必须以
use
开头:这不仅是约定,也使 React 能够识别 Hook 函数 - 可以调用其他 Hook:自定义 Hook 内部可以调用 React 内置 Hook 或其他自定义 Hook
- 状态是隔离的:不同组件调用同一个 Hook 不会共享状态
// 基础自定义 Hook 示例
function useCounter(initialValue = 0, step = 1) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => {
setCount(prevCount => prevCount + step);
}, [step]);
const decrement = useCallback(() => {
setCount(prevCount => prevCount - step);
}, [step]);
const reset = useCallback(() => {
setCount(initialValue);
}, [initialValue]);
return { count, increment, decrement, reset };
}
// 使用示例
function CounterComponent() {
const { count, increment, decrement, reset } = useCounter(10, 2);
return (
<div>
<p>当前计数: {count}</p>
<button onClick={increment}>增加</button>
<button onClick={decrement}>减少</button>
<button onClick={reset}>重置</button>
</div>
);
}
自定义 Hook 设计模式
1. 资源管理型 Hook
这类 Hook 负责管理外部资源的生命周期,如网络请求、事件监听等。
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const optionsRef = useRef(options);
useEffect(() => {
let isMounted = true;
const controller = new AbortController();
const signal = controller.signal;
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch(url, {
...optionsRef.current,
signal
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const result = await response.json();
if (isMounted) {
setData(result);
setError(null);
}
} catch (err) {
if (isMounted && err.name !== 'AbortError') {
setError(err.message);
setData(null);
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
fetchData();
return () => {
isMounted = false;
controller.abort();
};
}, [url]);
return { data, loading, error };
}
// 使用示例
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`https://api.example.com/users/${userId}`);
if (loading) return <div>加载中...</div>;
if (error) return <div>错误: {error}</div>;
return (
<div>
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
</div>
);
}
2. 状态逻辑型 Hook
封装复杂状态逻辑,提供简洁的状态管理接口。
function useForm(initialValues = {}) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = useCallback((e) => {
const { name, value } = e.target;
setValues(prev => ({
...prev,
[name]: value
}));
}, []);
const handleBlur = useCallback((e) => {
const { name } = e.target;
setTouched(prev => ({
...prev,
[name]: true
}));
}, []);
const reset = useCallback(() => {
setValues(initialValues);
setErrors({});
setTouched({});
setIsSubmitting(false);
}, [initialValues]);
return {
values,
errors,
touched,
isSubmitting,
handleChange,
handleBlur,
setValues,
setErrors,
setIsSubmitting,
reset
};
}
// 使用示例
function LoginForm() {
const {
values,
errors,
touched,
isSubmitting,
handleChange,
handleBlur,
setErrors,
setIsSubmitting
} = useForm({ email: '', password: '' });
const validate = () => {
const newErrors = {};
if (!values.email) newErrors.email = '邮箱不能为空';
if (!values.password) newErrors.password = '密码不能为空';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validate()) return;
setIsSubmitting(true);
try {
// 登录逻辑
await loginUser(values);
alert('登录成功');
} catch (err) {
setErrors({ form: err.message });
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">邮箱</label>
<input
id="email"
name="email"
type="email"
value={values.email}
onChange={handleChange}
onBlur={handleBlur}
/>
{touched.email && errors.email && <div className="error">{errors.email}</div>}
</div>
<div>
<label htmlFor="password">密码</label>
<input
id="password"
name="password"
type="password"
value={values.password}
onChange={handleChange}
onBlur={handleBlur}
/>
{touched.password && errors.password && <div className="error">{errors.password}</div>}
</div>
{errors.form && <div className="error">{errors.form}</div>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '登录中...' : '登录'}
</button>
</form>
);
}
3. 行为型 Hook
封装特定用户交互行为的逻辑,如拖拽、虚拟滚动等。
function useDrag(ref, options = {}) {
const {
onDragStart,
onDrag,
onDragEnd,
disabled = false
} = options;
const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const startPosRef = useRef({ x: 0, y: 0 });
const currentPosRef = useRef({ x: 0, y: 0 });
useEffect(() => {
if (!ref.current || disabled) return;
const element = ref.current;
const handleMouseDown = (e) => {
// 避免与点击事件冲突
if (e.button !== 0) return;
setIsDragging(true);
startPosRef.current = {
x: e.clientX - currentPosRef.current.x,
y: e.clientY - currentPosRef.current.y
};
if (onDragStart) {
onDragStart({
x: currentPosRef.current.x,
y: currentPosRef.current.y
});
}
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
e.preventDefault();
};
const handleMouseMove = (e) => {
if (!isDragging) return;
const newPos = {
x: e.clientX - startPosRef.current.x,
y: e.clientY - startPosRef.current.y
};
currentPosRef.current = newPos;
setPosition(newPos);
if (onDrag) {
onDrag(newPos);
}
};
const handleMouseUp = () => {
setIsDragging(false);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
if (onDragEnd) {
onDragEnd({
x: currentPosRef.current.x,
y: currentPosRef.current.y
});
}
};
element.addEventListener('mousedown', handleMouseDown);
return () => {
element.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [ref, disabled, isDragging, onDragStart, onDrag, onDragEnd]);
return { isDragging, position, setPosition };
}
// 使用示例
function DraggableBox() {
const boxRef = useRef(null);
const { isDragging, position } = useDrag(boxRef, {
onDragStart: (pos) => console.log('开始拖动', pos),
onDragEnd: (pos) => console.log('结束拖动', pos)
});
return (
<div
ref={boxRef}
style={{
position: 'absolute',
left: `${position.x}px`,
top: `${position.y}px`,
width: '100px',
height: '100px',
background: isDragging ? '#5c7cfa' : '#339af0',
cursor: 'grab',
userSelect: 'none',
boxShadow: isDragging ? '0 8px 16px rgba(0,0,0,0.2)' : '0 2px 4px rgba(0,0,0,0.1)',
transition: isDragging ? 'none' : 'box-shadow 0.3s, background 0.3s'
}}
>
拖拽我
</div>
);
}
高级技巧与优化
1. 依赖收集与性能优化
自定义 Hook 的性能优化主要关注两个方面:减少不必要的重渲染和优化内部逻辑执行效率。
function useSearch(initialQuery = '') {
const [query, setQuery] = useState(initialQuery);
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
// 使用 useRef 保存最新值,避免 useCallback 和 useEffect 依赖过多
const stateRef = useRef({ query });
stateRef.current.query = query;
// 使用 useCallback 缓存函数引用
const search = useCallback(debounce(async () => {
const currentQuery = stateRef.current.query;
if (!currentQuery.trim()) {
setResults([]);
return;
}
setLoading(true);
try {
const response = await fetch(`https://api.example.com/search?q=${encodeURIComponent(currentQuery)}`);
const data = await response.json();
setResults(data);
} catch (error) {
console.error('搜索出错:', error);
setResults([]);
} finally {
setLoading(false);
}
}, 300), []);
// 查询变化时触发搜索
useEffect(() => {
search();
// 返回清理函数,取消正在进行的请求
return () => {
search.cancel();
};
}, [query, search]);
return {
query,
setQuery,
results,
loading
};
}
// 节流/防抖辅助函数
function debounce(fn, delay) {
let timer = null;
const debounced = function(...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
debounced.cancel = function() {
if (timer) {
clearTimeout(timer);
timer = null;
}
};
return debounced;
}
2. 组合 Hooks 实现复杂功能
通过组合多个基础 Hook 实现更复杂的功能,遵循单一职责原则。
// 基础 Hook: 管理分页状态
function usePagination(initialPage = 1, initialPageSize = 10) {
const [page, setPage] = useState(initialPage);
const [pageSize, setPageSize] = useState(initialPageSize);
const reset = useCallback(() => {
setPage(initialPage);
setPageSize(initialPageSize);
}, [initialPage, initialPageSize]);
return {
page,
pageSize,
setPage,
setPageSize,
reset
};
}
// 基础 Hook: 管理排序状态
function useSorting(initialSortField = '', initialSortDirection = 'asc') {
const [sortField, setSortField] = useState(initialSortField);
const [sortDirection, setSortDirection] = useState(initialSortDirection);
const toggleSort = useCallback((field) => {
if (field === sortField) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
}, [sortField]);
return {
sortField,
sortDirection,
toggleSort
};
}
// 组合 Hook: 实现数据表格功能
function useDataTable(fetchFn, initialFilters = {}) {
const [filters, setFilters] = useState(initialFilters);
const [data, setData] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// 组合分页 Hook
const pagination = usePagination();
// 组合排序 Hook
const sorting = useSorting();
// 加载数据的函数
const loadData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = {
page: pagination.page,
pageSize: pagination.pageSize,
sortField: sorting.sortField,
sortDirection: sorting.sortDirection,
...filters
};
const result = await fetchFn(params);
setData(result.data);
setTotal(result.total);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [
fetchFn,
pagination.page,
pagination.pageSize,
sorting.sortField,
sorting.sortDirection,
filters
]);
// 过滤条件、分页或排序变化时重新加载数据
useEffect(() => {
loadData();
}, [loadData]);
// 更新过滤条件的函数
const updateFilters = useCallback((newFilters) => {
setFilters(prev => ({
...prev,
...newFilters
}));
// 重置到第一页
pagination.setPage(1);
}, [pagination]);
// 重置所有状态
const reset = useCallback(() => {
pagination.reset();
setFilters(initialFilters);
}, [pagination, initialFilters]);
return {
// 数据状态
data,
total,
loading,
error,
// 分页相关
page: pagination.page,
pageSize: pagination.pageSize,
setPage: pagination.setPage,
setPageSize: pagination.setPageSize,
// 排序相关
sortField: sorting.sortField,
sortDirection: sorting.sortDirection,
toggleSort: sorting.toggleSort,
// 过滤相关
filters,
updateFilters,
// 操作方法
reload: loadData,
reset
};
}
// 使用示例
function UsersTable() {
const fetchUsers = async (params) => {
const queryString = new URLSearchParams(params).toString();
const response = await fetch(`https://api.example.com/users?${queryString}`);
return await response.json();
};
const {
data: users,
total,
loading,
page,
pageSize,
setPage,
setPageSize,
sortField,
sortDirection,
toggleSort,
filters,
updateFilters
} = useDataTable(fetchUsers, { status: 'active' });
return (
<div>
<div className="filters">
<input
placeholder="搜索用户"
value={filters.keyword || ''}
onChange={e => updateFilters({ keyword: e.target.value })}
/>
<select
value={filters.status}
onChange={e => updateFilters({ status: e.target.value })}
>
<option value="active">活跃</option>
<option value="inactive">非活跃</option>
<option value="all">全部</option>
</select>
</div>
{loading ? (
<div>加载中...</div>
) : (
<table>
<thead>
<tr>
<th onClick={() => toggleSort('name')}>
姓名 {sortField === 'name' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => toggleSort('email')}>
邮箱 {sortField === 'email' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => toggleSort('lastLogin')}>
最近登录 {sortField === 'lastLogin' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.email}</td>
<td>{new Date(user.lastLogin).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
)}
<div className="pagination">
<button
disabled={page === 1}
onClick={() => setPage(page - 1)}
>
上一页
</button>
<span>第 {page} 页,共 {Math.ceil(total / pageSize)} 页</span>
<button
disabled={page >= Math.ceil(total / pageSize)}
onClick={() => setPage(page + 1)}
>
下一页
</button>
<select
value={pageSize}
onChange={e => setPageSize(Number(e.target.value))}
>
<option value={10}>10条/页</option>
<option value={20}>20条/页</option>
<option value={50}>50条/页</option>
</select>
</div>
</div>
);
}
3. 利用 Context 优化 Hook 共享状态
当多个组件需要共享同一个 Hook 的状态时,可以结合 Context API 实现。
// 创建一个主题上下文
const ThemeContext = createContext(null);
// 主题 Provider 组件
function ThemeProvider({ children, initialTheme = 'light' }) {
const [theme, setTheme] = useState(initialTheme);
// 在 localStorage 中保存主题偏好
useEffect(() => {
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = useCallback(() => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
}, []);
// 创建主题值对象
const themeValue = useMemo(() => ({
theme,
setTheme,
toggleTheme,
isDark: theme === 'dark'
}), [theme, toggleTheme]);
return (
<ThemeContext.Provider value={themeValue}>
{children}
</ThemeContext.Provider>
);
}
// 自定义 Hook 用于访问主题上下文
function useTheme() {
const context = useContext(ThemeContext);
if (context === null) {
throw new Error('useTheme 必须在 ThemeProvider 内部使用');
}
return context;
}
// 使用示例
function App() {
return (
<ThemeProvider initialTheme="light">
<MainLayout />
</ThemeProvider>
);
}
function MainLayout() {
const { theme, toggleTheme } = useTheme();
return (
<div className={`app ${theme}`}>
<header>
<h1>我的应用</h1>
<button onClick={toggleTheme}>
切换到{theme === 'light' ? '暗色' : '亮色'}主题
</button>
</header>
<main>
<Content />
</main>
</div>
);
}
function Content() {
const { isDark } = useTheme();
return (
<section className="content">
<h2>内容区域</h2>
<p>当前使用的是{isDark ? '暗色' : '亮色'}主题</p>
</section>
);
}
自定义 Hook 与现代前端架构
1. 与状态管理的整合
自定义 Hook 可以与 Redux、Zustand 等状态管理库无缝集成,提供更集中的状态管理方案。
// 集成 Redux 的自定义 Hook
function useReduxActions(slice) {
const dispatch = useDispatch();
const state = useSelector(state => state[slice]);
// 使用 useMemo 缓存创建的 actions 对象
const actions = useMemo(() => {
// 示例:为一个用户模块创建actions
if (slice === 'users') {
return {
fetchUsers: (params) => {
dispatch({ type: 'users/fetchUsersStart', payload: params });
return fetch(`/api/users?${new URLSearchParams(params)}`)
.then(res => res.json())
.then(data => {
dispatch({ type: 'users/fetchUsersSuccess', payload: data });
return data;
})
.catch(error => {
dispatch({ type: 'users/fetchUsersFailure', payload: error.message });
throw error;
});
},
createUser: (userData) => {
dispatch({ type: 'users/createUserStart', payload: userData });
return fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
})
.then(res => res.json())
.then(data => {
dispatch({ type: 'users/createUserSuccess', payload: data });
return data;
})
.catch(error => {
dispatch({ type: 'users/createUserFailure', payload: error.message });
throw error;
});
}
};
}
return {};
}, [dispatch, slice]);
return { state, ...actions };
}
// 使用示例
function UserList() {
const { state: usersState, fetchUsers } = useReduxActions('users');
const { data, loading, error } = usersState;
useEffect(() => {
fetchUsers({ page: 1, limit: 10 });
}, [fetchUsers]);
if (loading) return <div>加载中...</div>;
if (error) return <div>错误: {error}</div>;
return (
<ul>
{data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
2. 与组件库的协同设计
自定义 Hook 可以成为组件库的强大辅助工具,为复杂组件提供逻辑层抽象。
// 自定义 Hook 和组件协同
function useMenuControl(initialOpenKeys = []) {
const [openKeys, setOpenKeys] = useState(initialOpenKeys);
const [selectedKey, setSelectedKey] = useState(null);
const onOpenChange = useCallback((key) => {
setOpenKeys(prev => {
const keyIndex = prev.indexOf(key);
if (keyIndex >= 0) {
// 已打开,则关闭
const newKeys = [...prev];
newKeys.splice(keyIndex, 1);
return newKeys;
} else {
// 未打开,则添加
return [...prev, key];
}
});
}, []);
const isOpen = useCallback((key) => {
return openKeys.includes(key);
}, [openKeys]);
return {
openKeys,
selectedKey,
setSelectedKey,
onOpenChange,
isOpen
};
}
// 菜单组件
function Menu({ items, defaultOpenKeys = [] }) {
const {
openKeys,
selectedKey,
setSelectedKey,
onOpenChange,
isOpen
} = useMenuControl(defaultOpenKeys);
return (
<nav className="menu">
{items.map(item => {
if (item.children) {
return (
<div key={item.key} className="submenu">
<div
className="submenu-title"
onClick={() => onOpenChange(item.key)}
>
{item.icon && <span className="icon">{item.icon}</span>}
<span>{item.label}</span>
<span className={`arrow ${isOpen(item.key) ? 'open' : ''}`}>▾</span>
</div>
{isOpen(item.key) && (
<div className="submenu-items">
{item.children.map(child => (
<div
key={child.key}
className={`menu-item ${selectedKey === child.key ? 'active' : ''}`}
onClick={() => setSelectedKey(child.key)}
>
{child.icon && <span className="icon">{child.icon}</span>}
<span>{child.label}</span>
</div>
))}
</div>
)}
</div>
);
}
return (
<div
key={item.key}
className={`menu-item ${selectedKey === item.key ? 'active' : ''}`}
onClick={() => setSelectedKey(item.key)}
>
{item.icon && <span className="icon">{item.icon}</span>}
<span>{item.label}</span>
</div>
);
})}
</nav>
);
}
// 使用示例
function SidebarNavigation() {
const menuItems = [
{
key: 'dashboard',
label: '仪表盘',
icon: '📊'
},
{
key: 'users',
label: '用户管理',
icon: '👥',
children: [
{
key: 'user-list',
label: '用户列表'
},
{
key: 'user-groups',
label: '用户组'
}
]
},
{
key: 'settings',
label: '系统设置',
icon: '⚙️',
children: [
{
key: 'profile',
label: '个人资料'
},
{
key: 'security',
label: '安全设置'
}
]
}
];
return (
<div className="sidebar">
<div className="logo">应用名称</div>
<Menu items={menuItems} defaultOpenKeys={['users']} />
</div>
);
}
自定义 Hook 测试最佳实践
测试自定义 Hook 是确保其可靠性和可维护性的关键环节。以下是针对自定义 Hook 的测试策略:
// 示例:使用 @testing-library/react-hooks 测试自定义 Hook
// useCounter.js
import { useState, useCallback } from 'react';
export function useCounter(initialValue = 0, step = 1) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => {
setCount(prevCount => prevCount + step);
}, [step]);
const decrement = useCallback(() => {
setCount(prevCount => prevCount - step);
}, [step]);
const reset = useCallback(() => {
setCount(initialValue);
}, [initialValue]);
return { count, increment, decrement, reset };
}
// useCounter.test.js
import { renderHook, act } from '@testing-library/react-hooks';
import { useCounter } from './useCounter';
describe('useCounter', () => {
test('应该使用默认初始值', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('应该使用提供的初始值', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('应该递增计数', () => {
const { result } = renderHook(() => useCounter(0, 2));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(2);
});
test('应该递减计数', () => {
const { result } = renderHook(() => useCounter(10, 5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(5);
});
test('应该重置计数', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(10);
});
test('当步长变化时应该更新递增/递减行为', () => {
const { result, rerender } = renderHook(
({ initialValue, step }) => useCounter(initialValue, step),
{ initialProps: { initialValue: 0, step: 1 } }
);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
// 更新 step 参数
rerender({ initialValue: 0, step: 3 });
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(4); // 1 + 3
});
});
实际项目应用
在实际项目中应用自定义 Hook 时,应采取以下建议:
- 职责单一:每个 Hook 应专注于单一功能,避免过度复杂
- 明确的命名:使用描述性的名称清晰表达 Hook 的用途
- 文档完善:为每个 Hook 编写详细文档,包括参数、返回值和使用示例
- 版本控制:随着 API 的演进,保持版本兼容性并提供迁移路径
- 优先考虑性能:使用 useCallback、useMemo 优化 Hook 内部逻辑
结语
自定义 Hook 是 React 应用开发中强大的抽象工具,能够显著提升代码复用性和可维护性。
未来,随着 React 生态的不断发展,自定义 Hook 的设计模式也将继续演进。保持学习新的模式和技术,才能帮助我们在前端开发领域保持竞争力。
参考资源
官方文档
技术博客和文章
- Dan Abramov: Making Sense of React Hooks
- Kent C. Dodds: The State Reducer Pattern with React Hooks
- Robin Wieruch: React Hooks Tutorial
- Tanner Linsley: React Query - 重新思考数据获取的自定义 Hook
测试资源
社区讨论
高级模式和实践
如果你觉得这篇文章有帮助,欢迎点赞收藏,也期待在评论区看到你的想法和建议!👇
终身学习,共同成长。
咱们下一期见
💻