为什么要封装自定义 Hook

React 内置的 useState、useEffect 等 Hook 解决的是”状态”和”副作用”问题,但业务里大量重复逻辑——发请求、防抖、读写本地存储——如果每个组件都写一遍,代码会迅速失控。自定义 Hook 就是把可复用的逻辑抽出来,本质是一个以 use 开头的普通函数,内部可以调用其他 Hook。

自定义 Hook 的基本规则

  1. 必须以 use 开头(React 靠命名识别 Hook,配合 ESLint 插件检查);
  2. 内部可以调用其他 Hook,也可以组合多个自定义 Hook;
  3. 遵循 Hook 规则:只能在组件顶层调用 Hook,不能在条件、循环里调用;
  4. 返回什么由自己决定,通常返回状态和操作方法。

案例一:useFetch 请求封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// useFetch.js
import { useState, useEffect, useCallback } from 'react'

export function useFetch(url) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)

useEffect(() => {
let ignore = false // 防止竞态:组件卸载或 url 变化后不更新旧结果

async function load() {
setLoading(true)
setError(null)
try {
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const json = await res.json()
if (!ignore) setData(json)
} catch (e) {
if (!ignore) setError(e)
} finally {
if (!ignore) setLoading(false)
}
}

load()
return () => {
ignore = true // 清理函数:下一次 effect 运行前标记旧请求失效
}
}, [url])

const refresh = useCallback(() => {
// 触发重新请求:改一个内部 state 即可
setVersion((v) => v + 1)
}, [])
// 实际使用时可把 version 也放进依赖里

return { data, loading, error, refresh }
}

组件里一行代码就拿到请求状态:

1
2
3
4
5
6
7
8
9
import { useFetch } from './useFetch'

function UserList() {
const { data, loading, error } = useFetch('/api/users')

if (loading) return <div>加载中...</div>
if (error) return <div>出错了:{error.message}</div>
return <ul>{data?.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
}

关于竞态:请求是异步的,如果 url 很快变化或组件卸载,旧请求的结果可能覆盖新状态。上面的 ignore 标记是 React 官方推荐的防竞态写法。

案例二:useDebounce 防抖

搜索输入场景,防抖是最高频的需求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// useDebounce.js
import { useState, useEffect } from 'react'

export function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value)

useEffect(() => {
const timer = setTimeout(() => {
setDebounced(value)
}, delay)

// 每次 value 变化都会清除上一个定时器,实现防抖
return () => clearTimeout(timer)
}, [value, delay])

return debounced
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { useState, useEffect } from 'react'
import { useDebounce } from './useDebounce'

function SearchBox() {
const [keyword, setKeyword] = useState('')
const debouncedKeyword = useDebounce(keyword, 500)

useEffect(() => {
if (!debouncedKeyword) return
// 只有停止输入 500ms 后才发起搜索请求
fetch(`/api/search?q=${debouncedKeyword}`)
}, [debouncedKeyword])

return <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
}

案例三:useLocalStorage 同步本地存储

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// useLocalStorage.js
import { useState, useEffect } from 'react'

export function useLocalStorage(key, initialValue) {
// 惰性初始化:第一次渲染时从 localStorage 读取
const [value, setValue] = useState(() => {
try {
const stored = window.localStorage.getItem(key)
return stored !== null ? JSON.parse(stored) : initialValue
} catch {
return initialValue
}
})

// 值变化时写回,并监听其他标签页的修改
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value))
} catch {
// 隐私模式或存储满时静默失败
}
}, [key, value])

return [value, setValue]
}
1
2
3
4
5
6
7
8
function ThemeSwitch() {
const [theme, setTheme] = useLocalStorage('theme', 'light')
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
当前主题:{theme}
</button>
)
}

组合多个 Hook

自定义 Hook 可以互相组合,形成更上层的抽象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// useSearchUsers.js —— 把防抖 + 请求 + 状态串成一条完整的搜索链路
import { useState, useEffect } from 'react'
import { useDebounce } from './useDebounce'

export function useSearchUsers() {
const [keyword, setKeyword] = useState('')
const debouncedKeyword = useDebounce(keyword, 400)
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(false)

useEffect(() => {
if (!debouncedKeyword) {
setUsers([])
return
}
let ignore = false
setLoading(true)
fetch(`/api/users?q=${debouncedKeyword}`)
.then((r) => r.json())
.then((data) => {
if (!ignore) setUsers(data)
})
.finally(() => {
if (!ignore) setLoading(false)
})
return () => {
ignore = true
}
}, [debouncedKeyword])

return { keyword, setKeyword, users, loading }
}

常见误区

  1. 在自定义 Hook 里返回 useCallback/useMemo 的依赖漏写:依赖数组不全会导致闭包捕获旧值,用 eslint-plugin-react-hooks 的 exhaustive-deps 规则可以兜底;
  2. 把所有逻辑都塞进一个 Hook:Hook 也讲究单一职责,一个 Hook 做一件事;
  3. 忘记清理副作用:定时器、订阅、事件监听都要在清理函数中解除,否则组件卸载后仍会执行。

总结

自定义 Hook 让 React 组件”瘦身”、逻辑”复用”。建议按这个顺序上手:先封装 useFetch 类的基础逻辑,再封装 useDebounce 这类通用工具,最后根据业务组合出领域 Hook。命名统一 useXxx,放在 src/hooks/ 目录下管理。