
自定义 Hooks 实战
一、自定义 Hook 基础
1.1 什么是自定义 Hook?
自定义 Hook 是一个以 use 开头的 JavaScript 函数,可以调用其他 Hooks。它让你可以将组件逻辑提取到可复用的函数中。
1.2 基本规则
- 必须以 use 开头
- 只能在函数组件或其他 Hook 中调用
- 可以调用其他 Hooks
- 遵循 Hooks 规则
1.3 最简单的自定义 Hook
// 自定义 Hook
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue(prev => !prev), []);
return [value, toggle];
}
// 使用
function Component() {
const [isOpen, toggleOpen] = useToggle(false);
return (
<div>
<button onClick={toggleOpen}>{isOpen ? '关闭' : '打开'}</button>
{isOpen && <div>内容</div>}
</div>
);
}
二、常用自定义 Hook 模式
2.1 useLocalStorage
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
const setValue = useCallback((value) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
}, [key, storedValue]);
return [storedValue, setValue];
}
// 使用
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [language, setLanguage] = useLocalStorage('language', 'zh-CN');
return (
<div>
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="light">亮色</option>
<option value="dark">暗色</option>
</select>
<select value={language} onChange={(e) => setLanguage(e.target.value)}>
<option value="zh-CN">中文</option>
<option value="en-US">English</option>
</select>
</div>
);
}
2.2 useFetch
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const abortController = new AbortController();
let isMounted = true;
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(url, {
…options,
signal: abortController.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
if (isMounted) {
setData(result);
setError(null);
}
} catch (err) {
if (isMounted && err.name !== 'AbortError') {
setError(err.message);
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
fetchData();
return () => {
isMounted = false;
abortController.abort();
};
}, [url, JSON.stringify(options)]);
return { data, loading, error };
}
// 使用
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <div>加载中…</div>;
if (error) return <div>错误: {error}</div>;
return <div>{user?.name}</div>;
}
2.3 useDebounce
function useDebounce(value, delay = 500) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// 使用
function SearchInput() {
const [searchTerm, setSearchTerm] = useState('');
const debouncedTerm = useDebounce(searchTerm, 500);
useEffect(() => {
if (debouncedTerm) {
// 执行搜索
console.log('搜索:', debouncedTerm);
}
}, [debouncedTerm]);
return (
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="搜索…"
/>
);
}
2.4 usePrevious
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
// 使用
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>当前: {count}</p>
<p>上一次: {prevCount}</p>
<button onClick={() => setCount(count + 1)}>增加</button>
</div>
);
}
2.5 useEventListener
function useEventListener(eventName, handler, element = window) {
const savedHandler = useRef(handler);
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]);
}
// 使用
function MouseTracker() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEventListener('mousemove', (e) => {
setPosition({ x: e.clientX, y: e.clientY });
});
return <div>鼠标位置: {position.x}, {position.y}</div>;
}
2.6 useMediaQuery
function useMediaQuery(query) {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const mediaQuery = window.matchMedia(query);
const handler = (e) => setMatches(e.matches);
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [query]);
return matches;
}
// 使用
function ResponsiveComponent() {
const isMobile = useMediaQuery('(max-width: 768px)');
const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)');
const isDesktop = useMediaQuery('(min-width: 1025px)');
return (
<div>
{isMobile && <MobileLayout />}
{isTablet && <TabletLayout />}
{isDesktop && <DesktopLayout />}
</div>
);
}
2.7 useInterval
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => {
savedCallback.current();
}, delay);
return () => clearInterval(id);
}, [delay]);
}
// 使用
function Timer() {
const [count, setCount] = useState(0);
const [isRunning, setIsRunning] = useState(true);
useInterval(() => {
if (isRunning) {
setCount(prev => prev + 1);
}
}, 1000);
return (
<div>
<p>计数: {count}</p>
<button onClick={() => setIsRunning(!isRunning)}>
{isRunning ? '暂停' : '开始'}
</button>
</div>
);
}
2.8 useHover
function useHover() {
const [isHovered, setIsHovered] = useState(false);
const ref = useRef(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
const handleMouseEnter = () => setIsHovered(true);
const handleMouseLeave = () => setIsHovered(false);
element.addEventListener('mouseenter', handleMouseEnter);
element.addEventListener('mouseleave', handleMouseLeave);
return () => {
element.removeEventListener('mouseenter', handleMouseEnter);
element.removeEventListener('mouseleave', handleMouseLeave);
};
}, []);
return [ref, isHovered];
}
// 使用
function HoverCard() {
const [ref, isHovered] = useHover();
return (
<div
ref={ref}
style={{
padding: '20px',
background: isHovered ? '#f0f0f0' : 'white',
transition: '0.3s'
}}
>
{isHovered ? '鼠标悬停' : '鼠标移入'}
</div>
);
}
三、高级自定义 Hook 模式
3.1 组合多个 Hooks
function useUserData(userId) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// 组合多个自定义 Hook
const isOnline = useOnlineStatus();
const isMobile = useMediaQuery('(max-width: 768px)');
useEffect(() => {
if (!userId) return;
const fetchUser = async () => {
try {
setLoading(true);
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);
return {
user,
loading,
error,
isOnline,
isMobile
};
}
3.2 带配置的 Hook
function useLocalStorageWithExpiry(key, initialValue, expiryMs = 86400000) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = localStorage.getItem(key);
if (!item) return initialValue;
const { value, expiry } = JSON.parse(item);
if (expiry && Date.now() > expiry) {
localStorage.removeItem(key);
return initialValue;
}
return value;
} catch {
return initialValue;
}
});
const setValue = useCallback((value) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
const item = {
value: valueToStore,
expiry: Date.now() + expiryMs
};
localStorage.setItem(key, JSON.stringify(item));
} catch (error) {
console.error(error);
}
}, [key, storedValue, expiryMs]);
return [storedValue, setValue];
}
3.3 条件性 Hook
function useConditionalFetch(shouldFetch, url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!shouldFetch) return;
let isMounted = true;
setLoading(true);
fetch(url)
.then(res => res.json())
.then(result => {
if (isMounted) {
setData(result);
setLoading(false);
}
});
return () => { isMounted = false; };
}, [shouldFetch, url]);
return { data, loading };
}
// 使用
function UserDetails({ userId, isOpen }) {
const { data, loading } = useConditionalFetch(isOpen, `/api/users/${userId}`);
if (!isOpen) return null;
if (loading) return <div>加载中…</div>;
return <div>{data?.name}</div>;
}
四、自定义 Hook 测试
import { renderHook, act } from '@testing-library/react';
describe('useToggle', () => {
test('should initialize with default value', () => {
const { result } = renderHook(() => useToggle());
expect(result.current[0]).toBe(false);
});
test('should initialize with given value', () => {
const { result } = renderHook(() => useToggle(true));
expect(result.current[0]).toBe(true);
});
test('should toggle value', () => {
const { result } = renderHook(() => useToggle());
act(() => {
result.current[1]();
});
expect(result.current[0]).toBe(true);
act(() => {
result.current[1]();
});
expect(result.current[0]).toBe(false);
});
});
五、最佳实践
5.1 命名规范
// ✅ 好的命名
useLocalStorage // 用途清晰
useFetch // 做什么
useToggle // 返回什么
// ❌ 不好的命名
useStuff // 太模糊
getData // 不以 use 开头
5.2 返回值设计
// 返回数组(适合简单情况)
function useToggle(initial) {
const [value, setValue] = useState(initial);
const toggle = () => setValue(prev => !prev);
return [value, toggle];
}
// 返回对象(适合复杂情况)
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
return { data, loading, error };
}
5.3 参数设计
// 支持默认值
function useDebounce(value, delay = 500) { … }
// 支持配置对象
function useFetch(url, options = {}) { … }
六、练习题
七、小结
| 命名 | 必须以 use 开头 |
| 复用 | 提取重复逻辑 |
| 组合 | 可以调用其他 Hooks |
| 测试 | 使用 renderHook 测试 |
核心要点:
- 自定义 Hook 是逻辑复用的最佳方式
- 保持 Hook 单一职责
- 提供清晰的 API
- 编写测试确保正确性

