摘要:都 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。
// 这就是一个最简单的自定义 Hook
function useMyHook() {
const [state, setState] = useState(null)
useEffect(() => {
// 做一些事情
}, [])
return state
}
为什么要用自定义 Hook?
-
复用逻辑:同样的逻辑写一次,到处用
-
关注点分离:组件只管渲染,逻辑交给 Hook
-
更好测试:Hook 可以单独测试
-
代码更清晰:组件代码从 500 行变成 50 行
1.2 自定义 Hook 的命名规范
// ✅ 正确:以 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 报错。
解决方案:
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
使用示例:
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 —— 防抖の终极方案
痛点: 搜索框输入时,每敲一个字就发请求,服务器直接被你打爆。
解决方案:
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
使用示例:
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>
)
}
进阶版:带回调的防抖
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 三件套,烦死了。
解决方案:
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
使用示例:
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>
)
}
进阶版:支持缓存和自动重试
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 遍,手都酸了。
解决方案:
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
使用示例:
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 —— 点击外部关闭の神器
痛点: 下拉菜单、弹窗点击外部关闭,每次都要写一堆事件监听。
解决方案:
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
使用示例:
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
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 不给你上一次的值。
解决方案:
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
使用示例:
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 变化
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 里想根据屏幕尺寸做逻辑判断就麻烦了。
解决方案:
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
使用示例:
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>
)
}
封装常用断点:
// 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 解决复杂问题
场景: 一个带搜索、分页、缓存的列表组件
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 工厂
场景: 多个表单都需要类似的验证逻辑
/**
* 表单验证 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
// ❌ 错误:条件调用 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:忘记依赖项
// ❌ 错误:缺少依赖项,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:闭包陷阱
// ❌ 错误: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:无限循环
// ❌ 错误:每次渲染都创建新对象,导致无限循环
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 实现准时下班的前端开发。关注我,一起用更少的代码,写更好的应用。