React: 自定义 Hooks
最后更新:2026-08-26
1. 你将学到
- 自定义 Hook 的命名规范和规则
- 自定义 Hook 的参数和返回值设计
- 6 个实战自定义 Hook
- 自定义 Hook 的复用原则
2. 一个表单复用需求的故事
自定义 Hook 的复用模式
flowchart TD
A[重复逻辑] --> B[提取为 useXxx]
B --> C[组件A useXxx]
B --> D[组件B useXxx]
B --> E[组件C useXxx]
F[useXxx内部] --> G[useState]
F --> H[useEffect]
F --> I[useRef]
F --> J[useContext]
K[三次法则] --> L{逻辑重复次数?}
L -->|1次| M[不需要提取]
L -->|2次| N[考虑提取]
L -->|3次+| O[必须提取 ✅]
style B fill:#e8f5e9,stroke:#2e7d32
style O fill:#c8e6c9,stroke:#2e7d32
(1) 痛点:同样的逻辑在 5 个页面重复
Bob 要在 5 个不同页面都需要"获取本地存储的用户偏好":
JSX
// 页面 A:设置页
function SettingsPage() {
const [prefs, setPrefs] = useState(() => {
const saved = localStorage.getItem('preferences')
return saved ? JSON.parse(saved) : { theme: 'light', lang: 'zh' }
})
useEffect(() => {
localStorage.setItem('preferences', JSON.stringify(prefs))
}, [prefs])
// ... 页面 A 的具体 UI
}
// 页面 B:仪表盘
function Dashboard() {
const [prefs, setPrefs] = useState(() => {
const saved = localStorage.getItem('preferences')
return saved ? JSON.parse(saved) : { theme: 'light', lang: 'zh' }
})
useEffect(() => {
localStorage.setItem('preferences', JSON.stringify(prefs))
}, [prefs])
// ... 页面 B 的具体 UI(完全相同的逻辑!)
}
问题:localStorage 读写逻辑在 5 个页面复制粘贴了 5 次。如果要加一个"监听 storage 事件"的功能,要改 5 个文件。
(2) 自定义 Hook 的解法
JSX
// ---- 自定义 Hook:封装 localStorage 读写 ----
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const saved = localStorage.getItem(key)
return saved ? JSON.parse(saved) : initialValue
} catch {
return initialValue
}
})
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch (e) {
console.error('保存到 localStorage 失败:', e)
}
}, [key, value])
return [value, setValue]
}
// ---- 所有页面统一使用 ----
function SettingsPage() {
const [prefs, setPrefs] = useLocalStorage('preferences', {
theme: 'light', lang: 'zh'
})
// ... 具体 UI
}
function Dashboard() {
const [prefs, setPrefs] = useLocalStorage('preferences', {
theme: 'light', lang: 'zh'
})
// ... 具体 UI
}
收益:20 行逻辑 → 1 行调用。改逻辑时只改 useLocalStorage 一个文件。
3. 自定义 Hook 的规则
| 规则 | 说明 | 违反后果 |
|---|---|---|
以 use 开头 |
React 依赖命名识别 Hook | linter 不检查规则,条件调用不会报错 |
| 只在顶层调用 | 不在循环/条件/嵌套中调用 | Hooks 链表错位,状态混乱 |
| 只在函数组件/自定义 Hook 中调用 | 不在普通函数/类组件中使用 | 运行时报错:Invalid Hook call |
| 返回数据/函数,不返回 JSX | Hook 返回逻辑,组件返回 UI | 违反职责分离,难以复用 |
| 清理副作用 | useEffect return 清理函数 | 内存泄漏、事件监听累积 |
(1) 命名规范
JSX
// ✅ 必须以 use 开头
function useOnlineStatus() { }
function useWindowSize() { }
function useDebounce() { }
// ❌ 不以 use 开头 → React 不会检查 Hooks 规则
function onlineStatus() { }
function fetchData() { }
(2) 返回值设计
JSX
// 方式 1:返回数组(像 useState)
function useToggle(initial = false) {
const [value, setValue] = useState(initial)
const toggle = useCallback(() => setValue(v => !v), [])
return [value, toggle] // 数组,解构命名灵活
}
// 方式 2:返回对象(多个返回值时更清晰)
function useUser() {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
return { user, loading, error } // 对象,属性名明确
}
// 方式 3:返回单个值
function useDocumentTitle(title) {
useEffect(() => { document.title = title }, [title])
// 不需要返回值
}
(3) 内部可以使用任何 Hooks
JSX
function useData(url) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false
setLoading(true)
fetch(url)
.then(res => {
if (!res.ok) throw new Error('请求失败')
return res.json()
})
.then(data => { if (!cancelled) { setData(data); setLoading(false) }})
.catch(err => { if (!cancelled) { setError(err.message); setLoading(false) }})
return () => { cancelled = true }
}, [url])
return { data, loading, error }
}
// 内部用了 useState + useEffect + useCallback(如果需要)
// 自定义 Hook 可以组合任何内置 Hooks
4. 6 个实战自定义 Hook
| Hook | 功能 | 内部使用的 Hooks | 返回值 |
|---|---|---|---|
useToggle |
布尔开关切换 | useState, useCallback |
{ value, toggle, setTrue, setFalse } |
useDebounce |
输入防抖 | useState, useEffect |
debouncedValue |
useWindowSize |
窗口尺寸监听 | useState, useEffect |
{ width, height } |
useFetch |
数据请求三态 | useState, useEffect, useCallback |
{ data, loading, error, refetch } |
useIntersectionObserver |
可见性检测 | useState, useEffect |
isIntersecting |
useMediaQuery |
响应式断点 | useState, useEffect |
matches |
(1) Hook 1:useToggle(开关切换)
JSX
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => setValue(v => !v), [])
const setTrue = useCallback(() => setValue(true), [])
const setFalse = useCallback(() => setValue(false), [])
return { value, toggle, setTrue, setFalse }
}
// 使用
function ModalExample() {
const { value: isOpen, setTrue: open, setFalse: close } = useToggle(false)
return (
<div>
<button onClick={open}>打开弹窗</button>
{isOpen && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ backgroundColor: 'white', padding: '24px', borderRadius: '8px' }}>
<h2>弹窗标题</h2>
<p>弹窗内容</p>
<button onClick={close}>关闭</button>
</div>
</div>
)}
</div>
)
}
(2) Hook 2:useDebounce(防抖)
JSX
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 SearchBox() {
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 500)
// 只在 debouncedQuery 变化时才请求(用户停止输入 500ms 后)
useEffect(() => {
if (debouncedQuery) {
fetch('/api/search?q=' + debouncedQuery).then(/* ... */)
}
}, [debouncedQuery])
return <input value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索..." />
}
(3) Hook 3:useWindowSize(窗口尺寸)
JSX
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
})
useEffect(() => {
let timeoutId
function handleResize() {
// 防抖:50ms 内多次 resize 只执行一次
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
setSize({ width: window.innerWidth, height: window.innerHeight })
}, 50)
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
clearTimeout(timeoutId)
}
}, [])
return size
}
// 使用
function ResponsiveComponent() {
const { width } = useWindowSize()
return (
<p>
当前窗口宽度:{width}px
{width < 768 ? '(移动端视图)' : width < 1024 ? '(平板视图)' : '(桌面视图)'}
</p>
)
}
(4) Hook 4:useFetch(数据请求)
JSX
function useFetch(url, options = {}) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
fetch(url, options)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
return res.json()
})
.then(data => { if (!cancelled) { setData(data); setLoading(false) }})
.catch(err => { if (!cancelled) { setError(err.message); setLoading(false) }})
return () => { cancelled = true }
}, [url])
// 重新请求
const refetch = useCallback(() => {
setLoading(true)
fetch(url, options)
.then(res => res.json())
.then(data => { setData(data); setLoading(false) })
.catch(err => { setError(err.message); setLoading(false) })
}, [url])
return { data, loading, error, refetch }
}
// 使用
function UserProfile({ userId }) {
const { data: user, loading, error, refetch } = useFetch(`/api/users/${userId}`)
if (loading) return <p>加载中...</p>
if (error) return <p>错误:{error} <button onClick={refetch}>重试</button></p>
return <h1>{user.name}</h1>
}
(5) Hook 5:useIntersectionObserver(可见性检测)
JSX
function useIntersectionObserver(ref, options = {}) {
const [isIntersecting, setIsIntersecting] = useState(false)
useEffect(() => {
if (!ref.current) return
const observer = new IntersectionObserver(([entry]) => {
setIsIntersecting(entry.isIntersecting)
}, { threshold: options.threshold || 0.1, ...options })
observer.observe(ref.current)
return () => observer.disconnect()
}, [ref, options.threshold])
return isIntersecting
}
// 使用:图片懒加载
function LazyImage({ src, alt }) {
const imgRef = useRef(null)
const isVisible = useIntersectionObserver(imgRef)
const [loaded, setLoaded] = useState(false)
return (
<div ref={imgRef} style={{ minHeight: '200px', backgroundColor: '#f5f5f5' }}>
{isVisible && (
<img
src={src}
alt={alt}
onLoad={() => setLoaded(true)}
style={{ width: '100%', opacity: loaded ? 1 : 0, transition: 'opacity 0.3s' }}
/>
)}
</div>
)
}
(6) Hook 6:useMediaQuery(响应式断点)
JSX
function useMediaQuery(query) {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches)
useEffect(() => {
const mql = window.matchMedia(query)
function handler(e) { setMatches(e.matches) }
mql.addEventListener('change', handler)
return () => mql.removeEventListener('change', handler)
}, [query])
return matches
}
// 使用
function ResponsiveLayout() {
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 && <MobileMenu />}
{isDesktop && <FullSidebar />}
<p>
{isMobile ? '📱 手机' : isTablet ? '📟 平板' : '💻 电脑'}
</p>
</div>
)
}
5. 自定义 Hook 复用原则
| 原则 | 说明 | 坏例子 | 好例子 |
|---|---|---|---|
| 单一职责 | 一个 Hook 只做一件事 | useUserAndTheme() |
useUser() + useTheme() |
| 参数灵活 | 提供合理的默认值 | useFetch('/api/data') 硬编码 URL |
useFetch(url) URL 参数化 |
| 返回值完整 | 返回 loading/error/data 三态 | useFetch() 只返回 data |
useFetch() 返回 { data, loading, error } |
| 资源清理 | 组件卸载时清理副作用 | 不清理定时器/事件监听 | useEffect return cleanup |
| 可组合 | 小 Hook 可以组合成大 Hook | useComplexData() 100 行 |
useFetch() + useDebounce() 组合 |
▶ 示例:组合多个小 Hook
JSX
// ============================================
// 示例:组合多个小 Hook 构建复杂的搜索功能
// ============================================
// 1. 小 Hook:输入防抖
function useDebounce(value, delay) { /* ... */ }
// 2. 小 Hook:数据请求
function useFetch(url) { /* ... */ }
// 3. 小 Hook:本地存储
function useLocalStorage(key, initial) { /* ... */ }
// 4. 组合成大 Hook:搜索
function useSearch(defaultQuery = '') {
const [query, setQuery] = useState(defaultQuery)
const [history, setHistory] = useLocalStorage('search_history', [])
const debouncedQuery = useDebounce(query, 500)
const { data, loading, error } = useFetch(
debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
)
function search(newQuery) {
setQuery(newQuery)
// 保存搜索历史
if (newQuery.trim()) {
setHistory(prev => [newQuery, ...prev.filter(h => h !== newQuery)].slice(0, 10))
}
}
return { query, setQuery: search, results: data, loading, error, history }
}
// useSearch 内部组合了 useState + useDebounce + useFetch + useLocalStorage
6. 完整示例:图片画廊(组合多个自定义 Hook)
JSX
// ============================================
// 完整示例:图片画廊(组合 3 个自定义 Hook)
// ============================================
import { useState, useEffect, useCallback, useRef } from 'react'
// ---- 自定义 Hook 1:无限滚动 ----
function useInfiniteScroll(callback) {
const sentinelRef = useRef(null)
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel) return
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) callback()
}, { threshold: 0.1 })
observer.observe(sentinel)
return () => observer.disconnect()
}, [callback])
return sentinelRef
}
// ---- 自定义 Hook 2:键盘导航 ----
function useKeyPress(targetKey, callback) {
useEffect(() => {
function handler(e) {
if (e.key === targetKey) callback(e)
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [targetKey, callback])
}
// ---- 自定义 Hook 3:点击外部 ----
function useClickOutside(callback) {
const ref = useRef(null)
useEffect(() => {
function handler(e) {
if (ref.current && !ref.current.contains(e.target)) {
callback()
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [callback])
return ref
}
// ---- 图片画廊组件 ----
function ImageGallery() {
const [images, setImages] = useState([])
const [page, setPage] = useState(1)
const [selected, setSelected] = useState(null)
const [loading, setLoading] = useState(false)
// 加载图片
const loadMore = useCallback(() => {
if (loading) return
setLoading(true)
const newImages = Array.from({ length: 8 }, (_, i) => ({
id: images.length + i + 1,
url: `https://picsum.photos/seed/${images.length + i + 1}/300/200`,
title: `图片 ${images.length + i + 1}`
}))
setTimeout(() => {
setImages(prev => [...prev, ...newImages])
setPage(p => p + 1)
setLoading(false)
}, 800)
}, [loading, images.length])
// 首次加载
useEffect(() => { loadMore() }, [])
// 无限滚动加载更多
const sentinelRef = useInfiniteScroll(loadMore)
// 点击外部关闭预览
const previewRef = useClickOutside(() => setSelected(null))
// Escape 键关闭预览
useKeyPress('Escape', () => setSelected(null))
// 左右箭头键切换图片
useKeyPress('ArrowLeft', () => {
if (selected) {
const idx = images.findIndex(i => i.id === selected.id)
if (idx > 0) setSelected(images[idx - 1])
}
})
useKeyPress('ArrowRight', () => {
if (selected) {
const idx = images.findIndex(i => i.id === selected.id)
if (idx < images.length - 1) setSelected(images[idx + 1])
}
})
return (
<div style={{ maxWidth: '900px', margin: '0 auto' }}>
<h2>🖼️ 图片画廊</h2>
<p style={{ color: '#666', fontSize: '13px' }}>已加载 {images.length} 张 | 键盘:← 上一张 → 下一张 Escape 关闭</p>
{/* 图片网格 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '12px' }}>
{images.map(img => (
<div key={img.id} onClick={() => setSelected(img)}
style={{ cursor: 'pointer', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<img src={img.url} alt={img.title}
style={{ width: '100%', height: '150px', objectFit: 'cover', transition: 'transform 0.3s' }}
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.05)'}
onMouseLeave={e => e.currentTarget.style.transform = 'scale(1)'} />
<p style={{ padding: '8px', margin: 0, fontSize: '13px', textAlign: 'center' }}>{img.title}</p>
</div>
))}
</div>
{/* 加载更多触发器 */}
<div ref={sentinelRef} style={{ textAlign: 'center', padding: '20px' }}>
{loading ? <p>⏳ 加载中...</p> : <p style={{ color: '#999' }}>↓ 滚动加载更多</p>}
</div>
{/* 图片预览(点击外部关闭) */}
{selected && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.8)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 1000
}}>
<div ref={previewRef} style={{ maxWidth: '80vw', maxHeight: '80vh' }}>
<img src={selected.url.replace('300/200', '600/400')} alt={selected.title}
style={{ maxWidth: '100%', maxHeight: '80vh', borderRadius: '8px' }} />
<p style={{ color: 'white', textAlign: 'center', marginTop: '12px' }}>
{selected.title}(点击外部关闭)
</p>
</div>
</div>
)}
</div>
)
}
预期输出:图片画廊,首次加载 8 张图片,滚动到底部自动加载更多,点击图片可预览(支持键盘导航),点击预览外部区域关闭。
▶ 示例 2:useClipboard Hook——复制到剪贴板
JSX
function useClipboard() {
const [copied, setCopied] = useState(false)
async function copy(text) {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
setCopied(false)
}
}
return { copied, copy }
}
function CopyButton({ text }) {
const { copied, copy } = useClipboard()
return (
<button onClick={() => copy(text)}
style={{ padding: '4px 12px', cursor: 'pointer', background: copied ? '#52c41a' : '#f0f0f0', color: copied ? 'white' : '#333', border: 'none', borderRadius: 4 }}>
{copied ? 'Copied!' : 'Copy'}
</button>
)
}
▶ 示例 3:useOnlineStatus Hook——网络状态检测
JSX
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine)
useEffect(() => {
function handleOnline() { setIsOnline(true) }
function handleOffline() { setIsOnline(false) }
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
return isOnline
}
function StatusBar() {
const isOnline = useOnlineStatus()
return (
<div style={{ padding: '8px 16px', background: isOnline ? '#f6ffed' : '#fff2f0', borderBottom: `1px solid ${isOnline ? '#b7eb8f' : '#ffccc7'}` }}>
{isOnline ? '🟢 Online' : '🔴 Offline — changes will sync when reconnected'}
</div>
)
}
▶ 示例 4:useLocalStorage Hook——持久化状态
JSX
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const saved = localStorage.getItem(key)
return saved !== null ? JSON.parse(saved) : initialValue
} catch { return initialValue }
})
useEffect(() => {
try { localStorage.setItem(key, JSON.stringify(value)) }
catch (e) { console.error('localStorage save failed:', e) }
}, [key, value])
return [value, setValue]
}
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light')
return (
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}
style={{
padding: '8px 16px', cursor: 'pointer', borderRadius: 4, border: 'none',
background: theme === 'dark' ? '#333' : '#f0f0f0',
color: theme === 'dark' ? 'white' : '#333',
}}>
{theme === 'dark' ? '🌙 Dark' : '☀️ Light'} (saved)
</button>
)
}
▶ 示例 5:useCountdown Hook——倒计时
JSX
function useCountdown(seconds, autostart = false) {
const [timeLeft, setTimeLeft] = useState(seconds)
const [isRunning, setIsRunning] = useState(autostart)
useEffect(() => {
if (!isRunning || timeLeft <= 0) return
const timer = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 1) { setIsRunning(false); return 0 }
return prev - 1
})
}, 1000)
return () => clearInterval(timer)
}, [isRunning, timeLeft])
function start() { setTimeLeft(seconds); setIsRunning(true) }
function pause() { setIsRunning(false) }
function resume() { setIsRunning(true) }
const minutes = Math.floor(timeLeft / 60)
const secs = timeLeft % 60
const formatted = `${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
return { timeLeft, formatted, isRunning, start, pause, resume }
}
function CountdownTimer() {
const { formatted, isRunning, start, pause, resume } = useCountdown(300)
return (
<div style={{ textAlign: 'center', padding: 20 }}>
<p style={{ fontSize: 48, fontFamily: 'monospace', margin: '0 0 16px' }}>{formatted}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
{!isRunning ? <button onClick={start} style={{ padding: '8px 16px', cursor: 'pointer' }}>Start</button>
: <button onClick={pause} style={{ padding: '8px 16px', cursor: 'pointer' }}>Pause</button>}
{isRunning || <button onClick={resume} style={{ padding: '8px 16px', cursor: 'pointer' }}>Resume</button>}
</div>
</div>
)
}
❓ 常见问题
Q 自定义 Hook 里可以使用 useEffect 吗?
A 可以,而且很常见。自定义 Hook 可以使用任何内置 Hook(useState、useEffect、useRef、useContext 等)。例如
useFetch Hook 内部用 useState 管理数据/加载/错误状态,用 useEffect 在挂载时请求数据,用 useRef 存储 AbortController。这正是自定义 Hook 的价值——把多个 Hook 组合成一个可复用的逻辑单元。📖 小节
- 自定义 Hook 是一个以
use开头的函数,内部可用任何内置 Hooks - 返回值可以是数组(像 useState)、对象(多个返回值)、单个值
- 6 个实战:useToggle、useDebounce、useWindowSize、useFetch、useIntersectionObserver、useMediaQuery
- 遵循"三次法则"——逻辑复用 3 次以上就提取成 Hook
- 小 Hook 可以组合成大 Hook,遵循单一职责原则
📝 作业
- 基础题(难度⭐):创建一个
useCounterHook,返回{ count, increment, decrement, reset },初始值可配置。 - 进阶题(难度⭐⭐):创建一个
useClipboardHook,封装navigator.clipboardAPI,返回{ copied, copy(text) },支持复制文本到剪贴板。 - 挑战题(难度⭐⭐⭐):创建一个
useWebSocketHook,封装 WebSocket 连接,返回{ data, isConnected, send(message), reconnect() },支持自动重连和心跳检测。