WinddSnow

React-Custom-Hooks-Design-Patterns

字数统计: 4.1k阅读时长: 17 min
2026/08/01

第95课:自定义 Hooks 设计模式——封装复用逻辑、命名规范、useLocalStorageuseDebounce 实战

React Hooks 的核心价值在于逻辑复用。内置的 useStateuseEffectuseRef 等提供了原子能力,而自定义 Hook 让你将这些原子能力组合成可复用的逻辑单元。自定义 Hook 本质上是一个use 开头、内部可以调用其他 Hooks 的 JavaScript 函数。它不共享状态,而是共享状态逻辑——每次调用自定义 Hook 都会创建完全独立的状态实例。本节课将深入讲解自定义 Hook 的设计原则、命名规范、如何从组件中提取逻辑、以及三个经典实战封装:useLocalStorage(持久化状态)、useDebounce(防抖)和 usePrevious(追踪前值)。


1. 自定义 Hook 的本质与规则

1.1 什么是自定义 Hook?

自定义 Hook 就是一个普通的 JavaScript 函数,其名称以 use 开头,内部可以调用 React 内置 Hook(useStateuseEffect 等)或其他自定义 Hook。它遵循与内置 Hook 完全相同的规则——只能在函数组件或自定义 Hook 的顶层调用,不能在条件、循环或 return 之后调用

1
2
3
4
5
6
7
8
9
10
// 最简单的自定义 Hook:封装一个计数逻辑
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);

const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
const reset = useCallback(() => setCount(initialValue), [initialValue]);

return { count, increment, decrement, reset };
}

在组件中使用它:

1
2
3
4
5
6
7
8
9
function CounterA() {
const { count, increment } = useCounter(10);
return <button onClick={increment}>Count: {count}</button>;
}

function CounterB() {
const { count, increment } = useCounter(5); // 完全独立的状态
return <button onClick={increment}>Count: {count}</button>;
}

CounterACounterB 各自拥有完全独立的 count 状态——自定义 Hook 复用的是逻辑(如何计数),而非状态本身

1.2 自定义 Hook 的三大规则

  • 命名必须以 use 开头:这是 React 约定,也是 ESLint 的 react-hooks 规则识别 Hook 并检查其调用是否合法的依据。不以 use 开头的函数不能被识别为 Hook,也就不能在其内部使用 useState 等内置 Hook。
  • 必须在顶层调用:自定义 Hook 内部的 useStateuseEffect 等同样受 Hook 调用规则约束,不能放在条件、循环或嵌套函数中。
  • 必须是纯函数(副作用除外):Hook 的主体逻辑应是纯的,副作用应封装在 useEffect 或事件处理函数中。

2. 从组件中提取逻辑:识别可复用的模式

自定义 Hook 的设计通常不是“从零创造”,而是从组件中提取。当你发现多个组件包含相似的 State 声明、相似的 useEffect 副作用、或相似的事件处理逻辑时,就是提取自定义 Hook 的信号。

2.1 提取前的组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);

useEffect(() => {
if (query.length < 2) {
setResults([]);
return;
}
setLoading(true);
const timer = setTimeout(async () => {
const res = await fetch(`/api/search?q=${query}`);
const data = await res.json();
setResults(data);
setLoading(false);
}, 300);
return () => clearTimeout(timer);
}, [query]);

// JSX 渲染...
}

2.2 提取为 useDebouncedSearch 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
33
34
35
36
37
38
39
40
41
42
43
function useDebouncedSearch(searchFn, delay = 300) {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);

useEffect(() => {
if (query.length < 2) {
setResults([]);
return;
}
setLoading(true);
const timer = setTimeout(async () => {
try {
const data = await searchFn(query);
setResults(data);
} finally {
setLoading(false);
}
}, delay);
return () => clearTimeout(timer);
}, [query, searchFn, delay]);

return { query, setQuery, results, loading };
}

// 使用
function SearchPage() {
const { query, setQuery, results, loading } = useDebouncedSearch(
async (q) => {
const res = await fetch(`/api/search?q=${q}`);
return res.json();
},
300
);

return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
{loading && <p>加载中...</p>}
<ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>
</div>
);
}

提取后,SearchPage 组件变得极其简洁——它只关心渲染,搜索逻辑完全交给 Hook。useDebouncedSearch 可以在任何需要防抖搜索的组件中复用。


3. 实战封装一:useLocalStorage

将 State 自动同步到 localStorage,实现持久化。这是自定义 Hook 最经典的封装之一。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function useLocalStorage(key, initialValue) {
// 惰性初始化:仅在首次渲染时从 localStorage 读取
const [storedValue, setStoredValue] = useState(() => {
try {
const item = localStorage.getItem(key);
return item !== null ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});

// 每次存储值变化时,同步到 localStorage
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(storedValue));
} catch {
console.error(`Failed to save key "${key}" to localStorage`);
}
}, [key, storedValue]);

return [storedValue, setStoredValue];
}

使用示例

1
2
3
4
5
6
7
8
9
10
11
12
function ThemeSwitcher() {
const [theme, setTheme] = useLocalStorage('app-theme', 'light');

return (
<div>
<p>当前主题:{theme}</p>
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
切换主题
</button>
</div>
);
}

设计要点

  • 使用 useState 的惰性初始化函数,仅在组件首次挂载时读取 localStorage,避免每次渲染都读取。
  • useEffect 监听 keystoredValue 的变化,将最新值写入 localStorage。写入操作包裹在 try-catch 中,处理存储配额溢出或隐私模式下 localStorage 不可用的情况。
  • 由于 JSON.parseJSON.stringify 的性能开销极小,这个 Hook 适合存储配置、主题、用户偏好等小型数据。不适合存储大量数据或频繁变化的值。

3.1 跨标签页同步

如果需要多个标签页共享 localStorage 的变化,可以监听 storage 事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => { /* 同上 */ });

// 写入
useEffect(() => {
localStorage.setItem(key, JSON.stringify(storedValue));
}, [key, storedValue]);

// 跨标签页同步
useEffect(() => {
function handleStorageChange(e) {
if (e.key === key && e.newValue !== null) {
setStoredValue(JSON.parse(e.newValue));
}
}
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key]);

return [storedValue, setStoredValue];
}

4. 实战封装二:useDebounce

防抖(Debounce)是前端最常见的性能优化手段——延迟执行某个操作,直到用户停止触发一段时间后才真正执行。输入框搜索、窗口 resize 回调、按钮防重复点击都是典型场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function useDebounce(value, delay = 300) {
const [debouncedValue, setDebouncedValue] = useState(value);

useEffect(() => {
// 每次 value 变化时,设置一个新的定时器
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);

// 清理函数:如果 value 在 delay 内再次变化,清除上一次定时器
return () => clearTimeout(timer);
}, [value, delay]);

return debouncedValue;
}

使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function SearchInput() {
const [input, setInput] = useState('');
const debouncedInput = useDebounce(input, 500);

useEffect(() => {
if (debouncedInput) {
console.log('发起搜索请求:', debouncedInput);
// fetch(`/api/search?q=${debouncedInput}`)
}
}, [debouncedInput]);

return (
<input
value={input}
onChange={e => setInput(e.target.value)}
placeholder="输入关键词搜索..."
/>
);
}

设计要点

  • value 是原始值(每次按键都变化),debouncedValue 是延迟后的稳定值。
  • 每次 value 变化,useEffect 中的清理函数会清除上一个定时器,仅最后一个定时器能成功执行。
  • delay 加入依赖数组,允许动态调整防抖延迟时间。
  • 这个 Hook 返回的是一个,而非函数。这是一种“声明式”的防抖模式——你声明“我需要这个值的防抖版本”,Hook 负责计算。

4.1 useDebouncedCallback(函数式防抖)

如果需要防抖一个回调函数而非一个值,可以使用另一种封装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function useDebouncedCallback(callback, delay = 300) {
const timerRef = useRef(null);

const debouncedFn = useCallback((...args) => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
callback(...args);
}, delay);
}, [callback, delay]);

// 组件卸载时清除定时器
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);

return debouncedFn;
}

5. 实战封装三:usePrevious

追踪某个 Props 或 State 的上一次值。这在需要比较新旧值的场景(如检测某个值是否发生了变化)中非常有用。

1
2
3
4
5
6
7
8
9
10
function usePrevious(value) {
const ref = useRef();

useEffect(() => {
ref.current = value; // 效果执行后将当前值存入 ref
}, [value]);

// 返回上一次的值(效果执行前 ref 中存储的旧值)
return ref.current;
}

使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function ScoreBoard({ score }) {
const prevScore = usePrevious(score);

const direction = prevScore === undefined
? '—'
: score > prevScore
? '↑'
: score < prevScore
? '↓'
: '—';

return (
<p>
分数:{score} {direction}(上次:{prevScore ?? '—'})
</p>
);
}

原理useRef 在组件的整个生命周期中保持同一个引用。useEffect 在 DOM 更新完成后执行,此时 ref.current 仍然是上一次渲染时存入的值。因此 return ref.current 返回的是旧值,而 ref.current = value 为下一次渲染准备新值。


6. 自定义 Hook 设计原则

6.1 单一职责

每个自定义 Hook 应只做一件事,并提供清晰的输入输出接口。如果一个 Hook 同时管理表单状态、网络请求和本地存储,它应该被拆分为多个更小的 Hook。

1
2
3
4
5
6
7
8
9
// ❌ 职责不清
function useUserStuff(userId) {
// 同时处理了数据获取、表单状态、权限检查...
}

// ✅ 职责单一
function useUser(userId) { /* 获取用户数据 */ }
function useUserForm(initialData) { /* 管理表单状态 */ }
function usePermissions(user) { /* 检查权限 */ }

6.2 返回值设计

自定义 Hook 的返回值应该是直观且易于使用的。常见模式:

  • 返回数组(类似 useState):适合简单的键值对,调用方可以自由命名。
  • 返回对象:适合包含多个属性/方法的复杂 Hook,调用方可以按需解构,无需记住顺序。
1
2
3
4
5
// 返回数组(适合简单场景)
const [value, setValue] = useLocalStorage('key', 'default');

// 返回对象(适合复杂场景)
const { data, loading, error, refetch } = useFetch('/api/users');

6.3 依赖管理

自定义 Hook 中如果包含 useEffectuseCallback,必须正确声明依赖。尤其注意从外部传入的函数对象——它们的引用可能在每次渲染时变化,导致效果频繁执行。解决方案:

  • 要求调用方通过 useCallback/useMemo 稳定引用。
  • 或在 Hook 内部使用 useRef 存储最新值以避免将其加入依赖数组。

7. 综合示例:useMediaQuery

封装一个响应式媒体查询 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
33
34
35
36
37
38
39
function useMediaQuery(query) {
const [matches, setMatches] = useState(() => {
// 惰性初始化:仅在挂载时查询一次
if (typeof window !== 'undefined') {
return window.matchMedia(query).matches;
}
return false;
});

useEffect(() => {
const mediaQueryList = window.matchMedia(query);

// 设置初始值(处理在初始化期间变化的情况)
setMatches(mediaQueryList.matches);

function handleChange(e) {
setMatches(e.matches);
}

// 现代浏览器使用 addEventListener
mediaQueryList.addEventListener('change', handleChange);
return () => mediaQueryList.removeEventListener('change', handleChange);
}, [query]);

return matches;
}

// 使用
function ResponsiveLayout() {
const isMobile = useMediaQuery('(max-width: 768px)');
const isDarkMode = useMediaQuery('(prefers-color-scheme: dark)');

return (
<div>
<p>当前设备:{isMobile ? '移动端' : '桌面端'}</p>
<p>颜色模式:{isDarkMode ? '暗色' : '浅色'}</p>
</div>
);
}

课后练习

一、概念自测(选择题 / 填空题)

  1. (单选) 自定义 Hook 的命名必须以什么开头?
    A. handle
    B. on
    C. use
    D. get

  2. (单选) 以下关于自定义 Hook 的描述,哪项是错误的?
    A. 自定义 Hook 可以调用其他内置 Hook。
    B. 自定义 Hook 可以在条件语句中调用。
    C. 每次调用自定义 Hook 会创建独立的状态实例。
    D. 自定义 Hook 复用的是逻辑,而非状态本身。

  3. (填空)useDebounce(value, delay) Hook 中,当 valuedelay 毫秒内多次变化时,只有 ______ 次变化会触发 setDebouncedValue

  4. (多选) 以下哪些是提取自定义 Hook 的合理信号?
    A. 多个组件包含相同的 useState + useEffect 模式。
    B. 一个组件内的 JSX 代码超过 100 行。
    C. 一段数据获取逻辑在三个不同的组件中重复出现。
    D. 需要在多个组件间共享状态对象本身。

二、AI 编程任务:编写面向 AI 的提示词

场景:你需要封装一个 useFetch 自定义 Hook,用于通用的数据获取。要求如下:

  • 接收一个 url 字符串和一个可选的 options 对象(包含 methodheadersbody 等)。
  • 返回 { data, loading, error, refetch } 对象。
  • refetch 函数调用后重新发起请求(使用相同的 URL 和 options)。
  • urloptions 发生变化时自动重新请求。
  • 在组件卸载时取消未完成的请求(使用 AbortController),避免对已卸载组件的 setState
  • 使用 TypeScript 泛型,允许调用方指定 data 的类型。

任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成符合上述要求的自定义 Hook 代码。提示词中需明确指定泛型使用方式、AbortController 的集成以及返回值的结构。

三、Agent 模式下的提示词示例

你是一个资深前端开发 Agent。请创建一个通用的数据获取自定义 Hook useFetch。需要创建文件 src/hooks/useFetch.ts

  • 使用 TypeScript,导出函数 useFetch<T = unknown>(url: string, options?: RequestInit)
  • 返回 { data: T | null, loading: boolean, error: Error | null, refetch: () => void }
  • 使用 useState 管理 dataloadingerror
  • 使用 useRef 存储 AbortController 实例。
  • 使用 useEffect 监听 url 和序列化后的 options(使用 JSON.stringify 比较),发起 fetch 请求。在效果开始时创建新的 AbortController,在清理函数中调用 abort()
  • 请求成功:设置 dataloading: falseerror: null。请求失败且不是 AbortError:设置 errorloading: false
  • refetch 函数使用 useCallback 实现:强制触发重新请求(通过递增一个内部 retryCount state 来触发 useEffect 重新执行)。
  • 添加 JSDoc 注释,说明泛型参数和返回值。确保代码可在严格 TypeScript 下编译。完成后输出完整文件内容。

四、面试真题与参考答案

题目(字节跳动前端面试题):

请解释 React 自定义 Hook 的设计原则,并举例说明如何从两个包含重复逻辑的组件中提取自定义 Hook。自定义 Hook 与普通工具函数(Utility Function)的本质区别是什么?为什么自定义 Hook 的命名必须以 use 开头?

参考答案

设计原则

  • 单一职责:每个 Hook 只做一件事,有清晰的输入输出。
  • 可组合:自定义 Hook 可以调用其他 Hook,形成逻辑层次。
  • 遵循 Hook 规则:只在顶层调用,不以 use 开头的函数不能调用 Hook。
  • 返回值直观:返回数组(类 useState)或对象(复杂场景),便于调用方使用。

提取示例:假设 ComponentAComponentB 都包含 const [data, setData] = useState(null); useEffect(() => { fetch(url).then(setData); }, [url]);。可以将这段逻辑提取为 useFetch(url) Hook,两个组件各自调用 useFetch,每个实例拥有独立的 data 状态。提取后组件只关心渲染,数据获取逻辑被封装。

与普通工具函数的本质区别:自定义 Hook 可以调用 React 的内置 Hook(useState、useEffect 等),从而与 React 的渲染周期和状态系统深度集成。普通工具函数不能调用 Hook——如果工具函数内部使用了 useState,React 会报错,因为它脱离了组件的调用上下文。

必须以 use 开头的原因:React 和 ESLint 的 react-hooks 插件依赖命名来识别函数是否为 Hook。只有以 use 开头的函数,lint 规则才会检查其内部的 Hook 调用是否符合规则(是否在顶层、是否在条件中调用等)。不使用 use 前缀的函数即使内部调用了 Hook,也会在运行时被 React 拒绝。


课后练习答案

一、概念自测答案

  1. C

    • 解析:自定义 Hook 必须以 use 开头,这是 React 的约定和 lint 规则的要求。
  2. B

    • 解析:自定义 Hook 同样遵循 Hook 调用规则,必须在顶层调用,不能在条件、循环或 return 之后调用。A、C、D 均为正确描述。
  3. 最后一

    • 解析:防抖逻辑中,每次新变化会清除上一次的定时器,仅在最后一次变化后的 delay 毫秒内无新变化时,定时器才会触发更新。
  4. A、C

    • 解析:A 和 C 都是逻辑重复的信号,适合提取自定义 Hook。B 是 JSX 规模问题,应通过组件拆分解决;D 描述的是状态共享,需要 Context 或状态管理库,而非自定义 Hook。

二、AI 编程任务参考答案(提示词示例)

示例提示词
“请用 TypeScript 封装一个通用数据获取 Hook useFetch<T>(url, options?)。要求:

  • 返回 { data: T | null, loading: boolean, error: Error | null, refetch: () => void }
  • 使用 AbortController 在组件卸载或依赖变化时取消请求。
  • urloptions 变化时自动重新请求。
  • 使用泛型 T 让调用方指定数据类型。
  • 添加 JSDoc 注释。输出完整代码。”
CATALOG
  1. 1. 第95课:自定义 Hooks 设计模式——封装复用逻辑、命名规范、useLocalStorage、useDebounce 实战
    1. 1.1. 1. 自定义 Hook 的本质与规则
      1. 1.1.1. 1.1 什么是自定义 Hook?
      2. 1.1.2. 1.2 自定义 Hook 的三大规则
    2. 1.2. 2. 从组件中提取逻辑:识别可复用的模式
      1. 1.2.1. 2.1 提取前的组件
      2. 1.2.2. 2.2 提取为 useDebouncedSearch Hook
    3. 1.3. 3. 实战封装一:useLocalStorage
      1. 1.3.1. 3.1 跨标签页同步
    4. 1.4. 4. 实战封装二:useDebounce
      1. 1.4.1. 4.1 useDebouncedCallback(函数式防抖)
    5. 1.5. 5. 实战封装三:usePrevious
    6. 1.6. 6. 自定义 Hook 设计原则
      1. 1.6.1. 6.1 单一职责
      2. 1.6.2. 6.2 返回值设计
      3. 1.6.3. 6.3 依赖管理
    7. 1.7. 7. 综合示例:useMediaQuery
    8. 1.8. 课后练习
      1. 1.8.1. 一、概念自测(选择题 / 填空题)
      2. 1.8.2. 二、AI 编程任务:编写面向 AI 的提示词
      3. 1.8.3. 三、Agent 模式下的提示词示例
      4. 1.8.4. 四、面试真题与参考答案
    9. 1.9. 课后练习答案
      1. 1.9.1. 一、概念自测答案
      2. 1.9.2. 二、AI 编程任务参考答案(提示词示例)