#如何设计自定义 Hook?有哪些最佳实践?
#一、是什么
自定义 Hook 是以 use 开头的 JavaScript 函数,内部可以调用其他 Hooks。它是 React 复用状态逻辑的核心机制,不同于组件共享 UI,自定义 Hook 共享的是状态逻辑。
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}每次调用自定义 Hook 都会获得独立的状态副本。两个组件同时调用 useOnlineStatus(),各自维护独立的 isOnline 状态——共享的是逻辑,不是状态本身。
#二、设计原则
#1. 单一职责
每个 Hook 只做一件事,职责越单一越容易复用和测试:
// 好:职责单一
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const item = localStorage.getItem(key);
return item !== null ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
console.warn(`Failed to save ${key} to localStorage`);
}
}, [key, value]);
return [value, setValue];
}
// 差:做了太多事
function useUserFormWithValidationAndStorage() {
// ... 表单状态 + 校验 + 持久化 + 提交,应该拆分
}#2. 接口设计要直观
返回值的约定:
- 单个值:直接返回
- 一对值(state + setter):返回数组
[value, setValue],类似 useState - 多个值:返回对象
{ data, loading, error }
// 返回数组——调用者可自由命名
const [name, setName] = useLocalStorage('name', '');
const [theme, setTheme] = useLocalStorage('theme', 'dark');
// 返回对象——字段名明确
const { data, loading, error, refetch } = useFetch('/api/users');#3. 参数设计
简单参数直接传递,复杂配置用 options 对象:
function useDebounce(value, delay = 300) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
function useFetch(url, options = {}) {
const { method = 'GET', headers = {}, enabled = true } = options;
// ...
}#三、实用自定义 Hook 示例
#useDebounce — 防抖
function useDebounce(value, delay = 300) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
function SearchInput() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
useEffect(() => {
if (debouncedQuery) {
fetchSearchResults(debouncedQuery);
}
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}#useFetch — 数据请求
function useFetch(url) {
const [state, setState] = useState({
data: null,
loading: true,
error: null,
});
const refetch = useCallback(() => {
setState(prev => ({ ...prev, loading: true, error: null }));
return fetchData();
}, [url]);
const fetchData = useCallback(async () => {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
setState({ data: json, loading: false, error: null });
} catch (err) {
setState({ data: null, loading: false, error: err });
}
}, [url]);
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(json => {
if (!cancelled) setState({ data: json, loading: false, error: null });
})
.catch(err => {
if (!cancelled && err.name !== 'AbortError') {
setState({ data: null, loading: false, error: err });
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [url]);
return { ...state, refetch };
}#useMediaQuery — 响应式断点
function useMediaQuery(query) {
const [matches, setMatches] = useState(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
const mql = window.matchMedia(query);
const handler = (e) => setMatches(e.matches);
mql.addEventListener('change', handler);
setMatches(mql.matches);
return () => mql.removeEventListener('change', handler);
}, [query]);
return matches;
}
function Layout() {
const isMobile = useMediaQuery('(max-width: 768px)');
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');
return (
<div className={`${isMobile ? 'mobile' : 'desktop'} ${prefersDark ? 'dark' : 'light'}`}>
{isMobile ? <MobileNav /> : <DesktopNav />}
</div>
);
}#usePrevious — 获取上一次的值
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>当前:{count},上一次:{prevCount}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
);
}#useClickOutside — 点击外部检测
function useClickOutside(ref, handler) {
useEffect(() => {
const listener = (event) => {
if (!ref.current || ref.current.contains(event.target)) return;
handler(event);
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
}
function Dropdown() {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useClickOutside(ref, () => setOpen(false));
return (
<div ref={ref}>
<button onClick={() => setOpen(o => !o)}>菜单</button>
{open && <ul><li>选项 1</li><li>选项 2</li></ul>}
</div>
);
}#四、命名规范
| 规范 | 示例 |
|---|---|
以 use 开头 | useAuth、useFetch |
| 动词描述行为 | useToggle、useDebounce |
返回布尔值用 is/has | useIsOnline → isOnline |
| 避免泛化命名 | 用 useUserPermissions 而非 useData |
以 use 开头不仅是约定,也是 React 和 ESLint 插件识别 Hook 的依据。如果函数名不以 use 开头,React 不会检查其内部的 Hooks 调用规则。
#五、测试自定义 Hook
使用 @testing-library/react 的 renderHook:
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
const reset = useCallback(() => setCount(initialValue), [initialValue]);
return { count, increment, decrement, reset };
}
test('should increment counter', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('should reset to initial value', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(10);
});#六、常见的规则与约束
- 不要在条件/循环中调用 Hooks——和内置 Hooks 一样的规则
- 只能在函数组件或自定义 Hook 中调用
- 不要在 Hook 中返回 JSX——那应该是组件的职责
- 避免过度抽象——如果一段逻辑只在一个组件使用且不复杂,就地编写即可
- 注意闭包陷阱——确保依赖数组完整,或使用 useRef 持有最新值
#七、总结
自定义 Hook 是 React 函数式编程范式的精髓。它让我们把组件的"做什么"(UI)和"怎么做"(逻辑)分离开来。好的自定义 Hook 应该具备:
- 可组合性:多个简单 Hook 可以像函数一样组合
- 可测试性:纯逻辑,不依赖渲染层
- 可复用性:跨组件、跨项目共享
设计 Hook 时,优先思考"调用者需要什么",然后反推内部实现。接口先行,实现在后。