React: 自定义 Hooks

最后更新:2026-10-26

1. 你将学到



2. 一个表单复用需求的故事

自定义 Hook 的复用模式

100%
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(开关切换)

▶ 试一试

(2) Hook 2:useDebounce(防抖)

▶ 试一试

(3) Hook 3:useWindowSize(窗口尺寸)

▶ 试一试

(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>
  )
}
▶ 试一试

输出:

TEXT
状态:query。组件:useDebounce


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
▶ 试一试

输出:

TEXT
自定义 hook 返回 { data, loading, error }。用法:const { data, loading } = useFetch("/api/users")。挂载时自动发起请求。


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>
  )
}
▶ 试一试

输出:

TEXT
状态:query(setter:setQuery)

预期输出:图片画廊,首次加载 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>
  )
}
▶ 试一试

输出:

TEXT
useClipboard hook:点击复制文本 → "Copied!" 反馈 → 超时后自动重置。封装 navigator.clipboard API

输出:

TEXT
useOnlineStatus:"Online"(绿色)/ "Offline"(红色)。监听 navigator.onLine 事件

▶ 示例 3:useOnlineStatus Hook——网络状态检测

▶ 试一试

输出:

TEXT
useOnlineStatus hook:显示 "Online"(绿色)或 "Offline"(红色)。监听 navigator.onLine 以及 online/offline 事件

输出:

TEXT
状态:isOnline(setter:setIsOnline)。useEffect 管理副作用

▶ 示例 4:useLocalStorage Hook——持久化状态

▶ 试一试

输出:

TEXT
useLocalStorage("theme", "light") → 刷新后依然保留。切换 → "dark"。重新打开页面会恢复已保存的值。

输出:

TEXT
状态:timeleft、isrunning。按钮:start、pause、resume。使用 useeffect

▶ 示例 5:useCountdown Hook——倒计时

▶ 试一试

输出:

TEXT
CountdownTimer 组件的状态为:seconds、autostart。渲染可交互界面。

❓ 常见问题

Q 自定义 Hook 里可以使用 useEffect 吗?
A 可以,而且很常见。自定义 Hook 可以使用任何内置 Hook(useState、useEffect、useRef、useContext 等)。例如 useFetch Hook 内部用 useState 管理数据/加载/错误状态,用 useEffect 在挂载时请求数据,用 useRef 存储 AbortController。这正是自定义 Hook 的价值——把多个 Hook 组合成一个可复用的逻辑单元。

📖 小节


📝 作业

  1. 基础题(难度⭐):创建一个 useCounter Hook,返回 { count, increment, decrement, reset },初始值可配置。
  2. 进阶题(难度⭐⭐):创建一个 useClipboard Hook,封装 navigator.clipboard API,返回 { copied, copy(text) },支持复制文本到剪贴板。
  3. 挑战题(难度⭐⭐⭐):创建一个 useWebSocket Hook,封装 WebSocket 连接,返回 { data, isConnected, send(message), reconnect() },支持自动重连和心跳检测。
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏