React 自定义 Hooks 生存指南:7 个让你少加班的"偷懒"神器

摘要:都 2026 年了,还在写重复代码?还在 useEffect 里疯狂 copy-paste?醒醒,自定义 Hooks 才是现代 React 开发者的"摸鱼"神器。本文手把手教你封装 7 个超实用的自定义 Hooks,从此告别 996,拥抱 WLB。代码即拿即用,CV 工程师狂喜。


引言:一个关于"偷懒"的故事

场景一: 产品经理:"这个搜索框要做防抖。" 你:"好的。"(打开 Google,搜索 "react debounce") 产品经理:"那个页面也要。" 你:"好的。"(再次 copy-paste) 产品经理:"还有这 10 个页面..." 你:(开始怀疑人生)

场景二: 你:"这个表单状态管理写得真优雅。" (三个月后) 你:"这 TM 是谁写的?!" Git blame:"是你自己。" 你:(沉默)

场景三: Code Review 时------ 同事:"这段逻辑我在另外 5 个文件里见过。" 你:"那个...我准备重构的..." 同事:"你三个月前也是这么说的。" 你:(想找个地缝钻进去)

如果你也有类似经历,恭喜你,这篇文章就是为你准备的。

今天,我要分享 7 个超实用的自定义 Hooks,让你:

  • 代码复用率提升 300%
  • 每天少写 200 行重复代码
  • 准时下班不是梦

第一章:自定义 Hooks 的"道"与"术"

1.1 什么是自定义 Hook?

简单说,自定义 Hook 就是一个以 use 开头的函数,里面可以调用其他 Hooks。

scss 复制代码
// 这就是一个最简单的自定义 Hook
function useMyHook() {
  const [state, setState] = useState(null)

  useEffect(() => {
    // 做一些事情
  }, [])

  return state
}

为什么要用自定义 Hook?

  1. 复用逻辑:同样的逻辑写一次,到处用
  2. 关注点分离:组件只管渲染,逻辑交给 Hook
  3. 更好测试:Hook 可以单独测试
  4. 代码更清晰:组件代码从 500 行变成 50 行

1.2 自定义 Hook 的命名规范

scss 复制代码
// ✅ 正确:以 use 开头
useLocalStorage()
useDebounce()
useFetch()

// ❌ 错误:不以 use 开头(React 不会识别为 Hook)
getLocalStorage()
debounceValue()
fetchData()

记住:use 开头不是装逼,是 React 识别 Hook 的方式。不这么写,React 的 Hooks 规则检查会失效。


第二章:7 个让你少加班的自定义 Hooks

Hook #1:useLocalStorage ------ 本地存储の优雅姿势

痛点: 每次用 localStorage 都要 JSON.parse、JSON.stringify,还要处理 SSR 报错。

解决方案:

javascript 复制代码
import { useState, useEffect, useCallback } from "react"

/**
 * 将状态同步到 localStorage 的 Hook
 * @param {string} key - localStorage 的键名
 * @param {any} initialValue - 初始值
 * @returns {[any, Function, Function]} [存储的值, 设置函数, 删除函数]
 */
function useLocalStorage(key, initialValue) {
  // 获取初始值(惰性初始化)
  const [storedValue, setStoredValue] = useState(() => {
    // SSR 环境下 window 不存在
    if (typeof window === "undefined") {
      return initialValue
    }

    try {
      const item = window.localStorage.getItem(key)
      // 如果存在则解析,否则返回初始值
      return item ? JSON.parse(item) : initialValue
    } catch (error) {
      console.warn(`Error reading localStorage key "${key}":`, error)
      return initialValue
    }
  })

  // 设置值的函数
  const setValue = useCallback(
    (value) => {
      try {
        // 支持函数式更新
        const valueToStore =
          value instanceof Function ? value(storedValue) : value
        setStoredValue(valueToStore)

        if (typeof window !== "undefined") {
          window.localStorage.setItem(key, JSON.stringify(valueToStore))
        }
      } catch (error) {
        console.warn(`Error setting localStorage key "${key}":`, error)
      }
    },
    [key, storedValue]
  )

  // 删除值的函数
  const removeValue = useCallback(() => {
    try {
      setStoredValue(initialValue)
      if (typeof window !== "undefined") {
        window.localStorage.removeItem(key)
      }
    } catch (error) {
      console.warn(`Error removing localStorage key "${key}":`, error)
    }
  }, [key, initialValue])

  return [storedValue, setValue, removeValue]
}

export default useLocalStorage

使用示例:

javascript 复制代码
function App() {
  // 就像 useState 一样简单!
  const [theme, setTheme, removeTheme] = useLocalStorage("theme", "light")
  const [user, setUser] = useLocalStorage("user", null)

  return (
    <div className={`app ${theme}`}>
      <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
        切换主题:{theme}
      </button>

      <button onClick={() => setUser({ name: "张三", age: 25 })}>登录</button>

      <button onClick={removeTheme}>重置主题</button>

      {user && <p>欢迎,{user.name}!</p>}
    </div>
  )
}

为什么这个 Hook 香?

  • 自动处理 JSON 序列化/反序列化
  • 支持 SSR(不会报 window is not defined)
  • 支持函数式更新(和 useState 一样)
  • 提供删除功能

Hook #2:useDebounce ------ 防抖の终极方案

痛点: 搜索框输入时,每敲一个字就发请求,服务器直接被你打爆。

解决方案:

javascript 复制代码
import { useState, useEffect } from "react"

/**
 * 防抖 Hook:延迟更新值,避免频繁触发
 * @param {any} value - 需要防抖的值
 * @param {number} delay - 延迟时间(毫秒)
 * @returns {any} 防抖后的值
 */
function useDebounce(value, delay = 500) {
  const [debouncedValue, setDebouncedValue] = useState(value)

  useEffect(() => {
    // 设置定时器
    const timer = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    // 清理函数:值变化时清除上一个定时器
    return () => {
      clearTimeout(timer)
    }
  }, [value, delay])

  return debouncedValue
}

export default useDebounce

使用示例:

scss 复制代码
function SearchComponent() {
  const [searchTerm, setSearchTerm] = useState("")
  const [results, setResults] = useState([])
  const [loading, setLoading] = useState(false)

  // 防抖处理:用户停止输入 500ms 后才触发
  const debouncedSearchTerm = useDebounce(searchTerm, 500)

  useEffect(() => {
    if (debouncedSearchTerm) {
      setLoading(true)
      // 模拟 API 请求
      fetch(`/api/search?q=${debouncedSearchTerm}`)
        .then((res) => res.json())
        .then((data) => {
          setResults(data)
          setLoading(false)
        })
    } else {
      setResults([])
    }
  }, [debouncedSearchTerm]) // 只在防抖值变化时触发

  return (
    <div>
      <input
        type='text'
        placeholder='搜索...'
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
      />

      {loading && <p>搜索中...</p>}

      <ul>
        {results.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  )
}

进阶版:带回调的防抖

scss 复制代码
import { useCallback, useRef, useEffect } from "react"

/**
 * 防抖函数 Hook:返回一个防抖处理后的函数
 * @param {Function} callback - 需要防抖的回调函数
 * @param {number} delay - 延迟时间(毫秒)
 * @returns {Function} 防抖后的函数
 */
function useDebouncedCallback(callback, delay = 500) {
  const timeoutRef = useRef(null)
  const callbackRef = useRef(callback)

  // 保持 callback 最新
  useEffect(() => {
    callbackRef.current = callback
  }, [callback])

  // 清理定时器
  useEffect(() => {
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current)
      }
    }
  }, [])

  const debouncedCallback = useCallback(
    (...args) => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current)
      }

      timeoutRef.current = setTimeout(() => {
        callbackRef.current(...args)
      }, delay)
    },
    [delay]
  )

  return debouncedCallback
}

// 使用示例
function SearchWithCallback() {
  const [results, setResults] = useState([])

  const handleSearch = useDebouncedCallback((term) => {
    console.log("搜索:", term)
    // 发起请求...
  }, 500)

  return (
    <input
      type='text'
      onChange={(e) => handleSearch(e.target.value)}
      placeholder='输入搜索...'
    />
  )
}

Hook #3:useFetch ------ 数据请求の瑞士军刀

痛点: 每个组件都要写 loading、error、data 三件套,烦死了。

解决方案:

scss 复制代码
import { useState, useEffect, useCallback, useRef } from "react"

/**
 * 数据请求 Hook
 * @param {string} url - 请求地址
 * @param {object} options - fetch 选项
 * @returns {object} { data, loading, error, refetch }
 */
function useFetch(url, options = {}) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  // 用 ref 存储 options,避免无限循环
  const optionsRef = useRef(options)
  optionsRef.current = options

  const fetchData = useCallback(async () => {
    setLoading(true)
    setError(null)

    try {
      const response = await fetch(url, optionsRef.current)

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }

      const result = await response.json()
      setData(result)
    } catch (err) {
      setError(err.message || "请求失败")
    } finally {
      setLoading(false)
    }
  }, [url])

  useEffect(() => {
    fetchData()
  }, [fetchData])

  // 手动重新请求
  const refetch = useCallback(() => {
    fetchData()
  }, [fetchData])

  return { data, loading, error, refetch }
}

export default useFetch

使用示例:

javascript 复制代码
function UserProfile({ userId }) {
  const {
    data: user,
    loading,
    error,
    refetch,
  } = useFetch(`https://jsonplaceholder.typicode.com/users/${userId}`)

  if (loading) return <div className='skeleton'>加载中...</div>
  if (error) return <div className='error'>错误:{error}</div>
  if (!user) return null

  return (
    <div className='user-profile'>
      <h2>{user.name}</h2>
      <p>📧 {user.email}</p>
      <p>📱 {user.phone}</p>
      <p>🏢 {user.company?.name}</p>

      <button onClick={refetch}>刷新数据</button>
    </div>
  )
}

进阶版:支持缓存和自动重试

javascript 复制代码
import { useState, useEffect, useCallback, useRef } from "react"

// 简单的内存缓存
const cache = new Map()

/**
 * 增强版数据请求 Hook
 * @param {string} url - 请求地址
 * @param {object} config - 配置项
 */
function useFetchAdvanced(url, config = {}) {
  const {
    enabled = true, // 是否启用请求
    cacheTime = 5 * 60 * 1000, // 缓存时间(默认 5 分钟)
    retry = 3, // 重试次数
    retryDelay = 1000, // 重试延迟
    onSuccess, // 成功回调
    onError, // 失败回调
  } = config

  const [state, setState] = useState({
    data: null,
    loading: enabled,
    error: null,
  })

  const retryCountRef = useRef(0)

  const fetchData = useCallback(async () => {
    // 检查缓存
    const cached = cache.get(url)
    if (cached && Date.now() - cached.timestamp < cacheTime) {
      setState({ data: cached.data, loading: false, error: null })
      return
    }

    setState((prev) => ({ ...prev, loading: true, error: null }))

    try {
      const response = await fetch(url)

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`)
      }

      const data = await response.json()

      // 存入缓存
      cache.set(url, { data, timestamp: Date.now() })

      setState({ data, loading: false, error: null })
      retryCountRef.current = 0
      onSuccess?.(data)
    } catch (err) {
      // 重试逻辑
      if (retryCountRef.current < retry) {
        retryCountRef.current++
        console.log(
          `请求失败,${retryDelay}ms 后重试 (${retryCountRef.current}/${retry})`
        )
        setTimeout(fetchData, retryDelay)
        return
      }

      setState({ data: null, loading: false, error: err.message })
      onError?.(err)
    }
  }, [url, cacheTime, retry, retryDelay, onSuccess, onError])

  useEffect(() => {
    if (enabled) {
      fetchData()
    }
  }, [enabled, fetchData])

  return { ...state, refetch: fetchData }
}

Hook #4:useToggle ------ 布尔值の优雅切换

痛点: setIsOpen(!isOpen) 写了 100 遍,手都酸了。

解决方案:

scss 复制代码
import { useState, useCallback } from "react"

/**
 * 布尔值切换 Hook
 * @param {boolean} initialValue - 初始值
 * @returns {[boolean, Function, Function, Function]} [值, 切换, 设为true, 设为false]
 */
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]
}

export default useToggle

使用示例:

javascript 复制代码
function Modal() {
  const [isOpen, toggle, open, close] = useToggle(false)
  const [isDarkMode, toggleDarkMode] = useToggle(false)

  return (
    <div className={isDarkMode ? "dark" : "light"}>
      <button onClick={toggleDarkMode}>
        {isDarkMode ? "🌙" : "☀️"} 切换主题
      </button>

      <button onClick={open}>打开弹窗</button>

      {isOpen && (
        <div className='modal-overlay' onClick={close}>
          <div className='modal' onClick={(e) => e.stopPropagation()}>
            <h2>我是弹窗</h2>
            <p>点击遮罩层或按钮关闭</p>
            <button onClick={close}>关闭</button>
          </div>
        </div>
      )}
    </div>
  )
}

Hook #5:useClickOutside ------ 点击外部关闭の神器

痛点: 下拉菜单、弹窗点击外部关闭,每次都要写一堆事件监听。

解决方案:

javascript 复制代码
import { useEffect, useRef } from "react"

/**
 * 点击元素外部时触发回调
 * @param {Function} callback - 点击外部时的回调函数
 * @returns {React.RefObject} 需要绑定到目标元素的 ref
 */
function useClickOutside(callback) {
  const ref = useRef(null)

  useEffect(() => {
    const handleClick = (event) => {
      // 如果点击的不是 ref 元素内部,则触发回调
      if (ref.current && !ref.current.contains(event.target)) {
        callback(event)
      }
    }

    // 使用 mousedown 而不是 click,响应更快
    document.addEventListener("mousedown", handleClick)
    document.addEventListener("touchstart", handleClick)

    return () => {
      document.removeEventListener("mousedown", handleClick)
      document.removeEventListener("touchstart", handleClick)
    }
  }, [callback])

  return ref
}

export default useClickOutside

使用示例:

javascript 复制代码
function Dropdown() {
  const [isOpen, setIsOpen] = useState(false)

  // 点击下拉菜单外部时关闭
  const dropdownRef = useClickOutside(() => {
    setIsOpen(false)
  })

  return (
    <div className='dropdown-container' ref={dropdownRef}>
      <button onClick={() => setIsOpen(!isOpen)}>
        选择选项 {isOpen ? "▲" : "▼"}
      </button>

      {isOpen && (
        <ul className='dropdown-menu'>
          <li onClick={() => setIsOpen(false)}>选项 1</li>
          <li onClick={() => setIsOpen(false)}>选项 2</li>
          <li onClick={() => setIsOpen(false)}>选项 3</li>
        </ul>
      )}
    </div>
  )
}

进阶:支持多个 ref

javascript 复制代码
import { useEffect, useRef, useCallback } from "react"

/**
 * 支持多个元素的点击外部检测
 * @param {Function} callback - 点击外部时的回调
 * @returns {Function} 返回一个函数,调用它获取 ref
 */
function useClickOutsideMultiple(callback) {
  const refs = useRef([])

  const addRef = useCallback((element) => {
    if (element && !refs.current.includes(element)) {
      refs.current.push(element)
    }
  }, [])

  useEffect(() => {
    const handleClick = (event) => {
      const isOutside = refs.current.every(
        (ref) => ref && !ref.contains(event.target)
      )

      if (isOutside) {
        callback(event)
      }
    }

    document.addEventListener("mousedown", handleClick)
    return () => document.removeEventListener("mousedown", handleClick)
  }, [callback])

  return addRef
}

// 使用示例:弹窗 + 触发按钮都不算"外部"
function PopoverWithTrigger() {
  const [isOpen, setIsOpen] = useState(false)
  const addRef = useClickOutsideMultiple(() => setIsOpen(false))

  return (
    <>
      <button ref={addRef} onClick={() => setIsOpen(!isOpen)}>
        触发按钮
      </button>

      {isOpen && (
        <div ref={addRef} className='popover'>
          点击这里不会关闭
        </div>
      )}
    </>
  )
}

Hook #6:usePrevious ------ 获取上一次的值

痛点: 想对比新旧值做一些操作,但 React 不给你上一次的值。

解决方案:

javascript 复制代码
import { useRef, useEffect } from "react"

/**
 * 获取上一次渲染时的值
 * @param {any} value - 当前值
 * @returns {any} 上一次的值
 */
function usePrevious(value) {
  const ref = useRef()

  useEffect(() => {
    ref.current = value
  }, [value])

  // 返回上一次的值(在 useEffect 更新之前)
  return ref.current
}

export default usePrevious

使用示例:

javascript 复制代码
function Counter() {
  const [count, setCount] = useState(0)
  const prevCount = usePrevious(count)

  return (
    <div>
      <p>当前值:{count}</p>
      <p>上一次:{prevCount ?? "无"}</p>
      <p>
        变化趋势:
        {prevCount !== undefined &&
          (count > prevCount
            ? "📈 上升"
            : count < prevCount
            ? "📉 下降"
            : "➡️ 不变")}
      </p>

      <button onClick={() => setCount((c) => c + 1)}>+1</button>
      <button onClick={() => setCount((c) => c - 1)}>-1</button>
    </div>
  )
}

实际应用:检测 props 变化

scss 复制代码
function UserProfile({ userId }) {
  const prevUserId = usePrevious(userId)
  const [user, setUser] = useState(null)

  useEffect(() => {
    // 只有当 userId 真正变化时才重新请求
    if (userId !== prevUserId) {
      console.log(`用户 ID 从 ${prevUserId} 变为 ${userId}`)
      fetchUser(userId).then(setUser)
    }
  }, [userId, prevUserId])

  return <div>{user?.name}</div>
}

Hook #7:useMediaQuery ------ 响应式の优雅方案

痛点: CSS 媒体查询很方便,但 JS 里想根据屏幕尺寸做逻辑判断就麻烦了。

解决方案:

javascript 复制代码
import { useState, useEffect } from "react"

/**
 * 媒体查询 Hook
 * @param {string} query - CSS 媒体查询字符串
 * @returns {boolean} 是否匹配
 */
function useMediaQuery(query) {
  const [matches, setMatches] = useState(() => {
    // SSR 环境下返回 false
    if (typeof window === "undefined") return false
    return window.matchMedia(query).matches
  })

  useEffect(() => {
    if (typeof window === "undefined") return

    const mediaQuery = window.matchMedia(query)

    // 初始化
    setMatches(mediaQuery.matches)

    // 监听变化
    const handler = (event) => setMatches(event.matches)

    // 现代浏览器用 addEventListener
    if (mediaQuery.addEventListener) {
      mediaQuery.addEventListener("change", handler)
      return () => mediaQuery.removeEventListener("change", handler)
    } else {
      // 兼容旧浏览器
      mediaQuery.addListener(handler)
      return () => mediaQuery.removeListener(handler)
    }
  }, [query])

  return matches
}

export default useMediaQuery

使用示例:

javascript 复制代码
function ResponsiveComponent() {
  const isMobile = useMediaQuery("(max-width: 768px)")
  const isTablet = useMediaQuery("(min-width: 769px) and (max-width: 1024px)")
  const isDesktop = useMediaQuery("(min-width: 1025px)")
  const prefersDark = useMediaQuery("(prefers-color-scheme: dark)")

  return (
    <div className={prefersDark ? "dark-theme" : "light-theme"}>
      {isMobile && <MobileNav />}
      {isTablet && <TabletNav />}
      {isDesktop && <DesktopNav />}

      <main>
        <p>
          当前设备:{isMobile ? "📱 手机" : isTablet ? "📱 平板" : "💻 桌面"}
        </p>
        <p>主题偏好:{prefersDark ? "🌙 深色" : "☀️ 浅色"}</p>
      </main>
    </div>
  )
}

封装常用断点:

javascript 复制代码
// hooks/useBreakpoint.js
import useMediaQuery from "./useMediaQuery"

export function useBreakpoint() {
  const breakpoints = {
    xs: useMediaQuery("(max-width: 575px)"),
    sm: useMediaQuery("(min-width: 576px) and (max-width: 767px)"),
    md: useMediaQuery("(min-width: 768px) and (max-width: 991px)"),
    lg: useMediaQuery("(min-width: 992px) and (max-width: 1199px)"),
    xl: useMediaQuery("(min-width: 1200px)"),
  }

  // 返回当前断点名称
  const current =
    Object.entries(breakpoints).find(([, matches]) => matches)?.[0] || "xs"

  return {
    ...breakpoints,
    current,
    isMobile: breakpoints.xs || breakpoints.sm,
    isTablet: breakpoints.md,
    isDesktop: breakpoints.lg || breakpoints.xl,
  }
}

// 使用
function App() {
  const { isMobile, isDesktop, current } = useBreakpoint()

  return (
    <div>
      <p>当前断点:{current}</p>
      {isMobile ? <MobileLayout /> : <DesktopLayout />}
    </div>
  )
}

第三章:Hooks 组合の艺术

3.1 组合多个 Hooks 解决复杂问题

场景: 一个带搜索、分页、缓存的列表组件

javascript 复制代码
import { useState, useEffect, useMemo } from "react"

// 组合使用多个自定义 Hooks
function useSearchableList(fetchFn, options = {}) {
  const { pageSize = 10, debounceMs = 300 } = options

  // 搜索关键词
  const [searchTerm, setSearchTerm] = useState("")
  const debouncedSearch = useDebounce(searchTerm, debounceMs)

  // 分页
  const [page, setPage] = useState(1)

  // 数据请求
  const { data, loading, error, refetch } = useFetch(
    `${fetchFn}?search=${debouncedSearch}&page=${page}&pageSize=${pageSize}`
  )

  // 搜索时重置页码
  const prevSearch = usePrevious(debouncedSearch)
  useEffect(() => {
    if (prevSearch !== undefined && prevSearch !== debouncedSearch) {
      setPage(1)
    }
  }, [debouncedSearch, prevSearch])

  // 计算总页数
  const totalPages = useMemo(() => {
    return data?.total ? Math.ceil(data.total / pageSize) : 0
  }, [data?.total, pageSize])

  return {
    // 数据
    items: data?.items || [],
    total: data?.total || 0,
    loading,
    error,

    // 搜索
    searchTerm,
    setSearchTerm,

    // 分页
    page,
    setPage,
    totalPages,
    hasNextPage: page < totalPages,
    hasPrevPage: page > 1,

    // 操作
    refetch,
    nextPage: () => setPage((p) => Math.min(p + 1, totalPages)),
    prevPage: () => setPage((p) => Math.max(p - 1, 1)),
  }
}

// 使用示例
function UserList() {
  const {
    items,
    loading,
    error,
    searchTerm,
    setSearchTerm,
    page,
    totalPages,
    hasNextPage,
    hasPrevPage,
    nextPage,
    prevPage,
  } = useSearchableList("/api/users", { pageSize: 20 })

  return (
    <div className='user-list'>
      <input
        type='text'
        placeholder='搜索用户...'
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
      />

      {loading && <div className='loading'>加载中...</div>}
      {error && <div className='error'>{error}</div>}

      <ul>
        {items.map((user) => (
          <li key={user.id}>
            {user.name} - {user.email}
          </li>
        ))}
      </ul>

      <div className='pagination'>
        <button onClick={prevPage} disabled={!hasPrevPage}>
          上一页
        </button>
        <span>
          {page} / {totalPages}
        </span>
        <button onClick={nextPage} disabled={!hasNextPage}>
          下一页
        </button>
      </div>
    </div>
  )
}

3.2 创建 Hook 工厂

场景: 多个表单都需要类似的验证逻辑

javascript 复制代码
/**
 * 表单验证 Hook 工厂
 * @param {object} validationRules - 验证规则
 * @returns {Function} 返回一个自定义 Hook
 */
function createFormValidation(validationRules) {
  return function useFormValidation(initialValues = {}) {
    const [values, setValues] = useState(initialValues)
    const [errors, setErrors] = useState({})
    const [touched, setTouched] = useState({})

    // 验证单个字段
    const validateField = (name, value) => {
      const rules = validationRules[name]
      if (!rules) return ""

      for (const rule of rules) {
        if (rule.required && !value) {
          return rule.message || "此字段必填"
        }
        if (rule.minLength && value.length < rule.minLength) {
          return rule.message || `最少 ${rule.minLength} 个字符`
        }
        if (rule.maxLength && value.length > rule.maxLength) {
          return rule.message || `最多 ${rule.maxLength} 个字符`
        }
        if (rule.pattern && !rule.pattern.test(value)) {
          return rule.message || "格式不正确"
        }
        if (rule.validate && !rule.validate(value, values)) {
          return rule.message || "验证失败"
        }
      }
      return ""
    }

    // 验证所有字段
    const validateAll = () => {
      const newErrors = {}
      let isValid = true

      Object.keys(validationRules).forEach((name) => {
        const error = validateField(name, values[name] || "")
        if (error) {
          newErrors[name] = error
          isValid = false
        }
      })

      setErrors(newErrors)
      return isValid
    }

    // 处理输入变化
    const handleChange = (name) => (e) => {
      const value = e.target ? e.target.value : e
      setValues((prev) => ({ ...prev, [name]: value }))

      // 实时验证已触碰的字段
      if (touched[name]) {
        const error = validateField(name, value)
        setErrors((prev) => ({ ...prev, [name]: error }))
      }
    }

    // 处理失焦
    const handleBlur = (name) => () => {
      setTouched((prev) => ({ ...prev, [name]: true }))
      const error = validateField(name, values[name] || "")
      setErrors((prev) => ({ ...prev, [name]: error }))
    }

    // 重置表单
    const reset = () => {
      setValues(initialValues)
      setErrors({})
      setTouched({})
    }

    return {
      values,
      errors,
      touched,
      handleChange,
      handleBlur,
      validateAll,
      reset,
      isValid: Object.keys(errors).length === 0,
      getFieldProps: (name) => ({
        value: values[name] || "",
        onChange: handleChange(name),
        onBlur: handleBlur(name),
      }),
    }
  }
}

// 创建登录表单验证 Hook
const useLoginForm = createFormValidation({
  email: [
    { required: true, message: "请输入邮箱" },
    { pattern: /^[^\s@]+@[^\s@]+.[^\s@]+$/, message: "邮箱格式不正确" },
  ],
  password: [
    { required: true, message: "请输入密码" },
    { minLength: 6, message: "密码至少 6 位" },
  ],
})

// 创建注册表单验证 Hook
const useRegisterForm = createFormValidation({
  username: [
    { required: true, message: "请输入用户名" },
    { minLength: 3, message: "用户名至少 3 个字符" },
    { maxLength: 20, message: "用户名最多 20 个字符" },
  ],
  email: [
    { required: true, message: "请输入邮箱" },
    { pattern: /^[^\s@]+@[^\s@]+.[^\s@]+$/, message: "邮箱格式不正确" },
  ],
  password: [
    { required: true, message: "请输入密码" },
    { minLength: 8, message: "密码至少 8 位" },
    {
      pattern: /(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
      message: "需包含大小写字母和数字",
    },
  ],
  confirmPassword: [
    { required: true, message: "请确认密码" },
    {
      validate: (value, values) => value === values.password,
      message: "两次密码不一致",
    },
  ],
})

// 使用示例
function LoginForm() {
  const { values, errors, touched, getFieldProps, validateAll } = useLoginForm()

  const handleSubmit = (e) => {
    e.preventDefault()
    if (validateAll()) {
      console.log("提交:", values)
      // 发起登录请求...
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <div className='form-group'>
        <input type='email' placeholder='邮箱' {...getFieldProps("email")} />
        {touched.email && errors.email && (
          <span className='error'>{errors.email}</span>
        )}
      </div>

      <div className='form-group'>
        <input
          type='password'
          placeholder='密码'
          {...getFieldProps("password")}
        />
        {touched.password && errors.password && (
          <span className='error'>{errors.password}</span>
        )}
      </div>

      <button type='submit'>登录</button>
    </form>
  )
}

第四章:避坑指南

4.1 常见错误 #1:在条件语句中调用 Hook

javascript 复制代码
// ❌ 错误:条件调用 Hook
function BadComponent({ shouldFetch }) {
  if (shouldFetch) {
    const data = useFetch("/api/data") // 💥 报错!
  }
  return <div>...</div>
}

// ✅ 正确:Hook 始终调用,用参数控制行为
function GoodComponent({ shouldFetch }) {
  const { data } = useFetch("/api/data", { enabled: shouldFetch })
  return <div>...</div>
}

4.2 常见错误 #2:忘记依赖项

scss 复制代码
// ❌ 错误:缺少依赖项,callback 永远是旧的
function BadHook(callback) {
  useEffect(() => {
    window.addEventListener("resize", callback)
    return () => window.removeEventListener("resize", callback)
  }, []) // callback 变了也不会更新!
}

// ✅ 正确:使用 ref 保持最新引用
function GoodHook(callback) {
  const callbackRef = useRef(callback)

  useEffect(() => {
    callbackRef.current = callback
  }, [callback])

  useEffect(() => {
    const handler = (...args) => callbackRef.current(...args)
    window.addEventListener("resize", handler)
    return () => window.removeEventListener("resize", handler)
  }, [])
}

4.3 常见错误 #3:闭包陷阱

scss 复制代码
// ❌ 错误:count 永远是 0
function BadCounter() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count) // 永远打印 0
      setCount(count + 1) // 永远设置为 1
    }, 1000)
    return () => clearInterval(timer)
  }, []) // 空依赖,count 被闭包捕获

  return <div>{count}</div>
}

// ✅ 正确:使用函数式更新
function GoodCounter() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const timer = setInterval(() => {
      setCount((c) => c + 1) // 函数式更新,不依赖外部 count
    }, 1000)
    return () => clearInterval(timer)
  }, [])

  return <div>{count}</div>
}

4.4 常见错误 #4:无限循环

scss 复制代码
// ❌ 错误:每次渲染都创建新对象,导致无限循环
function BadComponent() {
  const [data, setData] = useState(null)

  useEffect(() => {
    fetch("/api/data")
      .then((res) => res.json())
      .then(setData)
  }, [{ page: 1 }]) // 每次都是新对象!无限循环!

  return <div>{data}</div>
}

// ✅ 正确:使用原始值或 useMemo
function GoodComponent() {
  const [data, setData] = useState(null)
  const page = 1

  useEffect(() => {
    fetch(`/api/data?page=${page}`)
      .then((res) => res.json())
      .then(setData)
  }, [page]) // 原始值,不会无限循环

  return <div>{data}</div>
}

写在最后:Hook 的哲学

自定义 Hooks 不只是代码复用的工具,更是一种思维方式:

1. 关注点分离

  • 组件负责"长什么样"(UI)
  • Hook 负责"怎么工作"(逻辑)

2. 组合优于继承

  • 小而专注的 Hook 可以自由组合
  • 比 HOC 和 Render Props 更灵活

3. 声明式思维

  • 描述"要什么",而不是"怎么做"
  • useDebounce(value, 500) 比手写 setTimeout 清晰 100 倍

最后,送你一句话:

"好的代码不是写出来的,是删出来的。"

当你发现自己在 copy-paste 时,就是该写自定义 Hook 的时候了。


💬 互动时间:你在项目中封装过哪些好用的自定义 Hooks?评论区分享一下,让大家一起"偷懒"!

觉得这篇文章有用?点赞 + 在看 + 转发,让更多 React 开发者早点下班~


本文作者是一个靠自定义 Hooks 实现准时下班的前端开发。关注我,一起用更少的代码,写更好的应用。

相关推荐
戴维南3 小时前
TypeScript 在项目中的实际解决的问题
前端
晴殇i3 小时前
package.json 中的 dependencies 与 devDependencies:深度解析
前端·设计模式·前端框架
basestone3 小时前
🚀 从重复 CRUD 到工程化封装:我是如何设计 useTableList 统一列表逻辑的
javascript·react.js·ant design
pas1363 小时前
25-mini-vue fragment & Text
前端·javascript·vue.js
何贤3 小时前
2025 年终回顾:25 岁,从“混吃等死”到别人眼中的“技术专家”
前端·程序员·年终总结
软件开发技术深度爱好者3 小时前
JavaScript的p5.js库使用介绍
javascript·html
冴羽3 小时前
CSS 新特性!瀑布流布局的终极解决方案
前端·javascript·css
满天星辰4 小时前
Vue 响应式原理深度解析
前端·vue.js
怪可爱的地球人4 小时前
em,rem,px,rpx单位换算,你弄懂了吗?
前端
码途潇潇4 小时前
JavaScript有哪些数据类型?如何判断一个变量的数据类型?
前端·javascript