第95课:自定义 Hooks 设计模式——封装复用逻辑、命名规范、useLocalStorage、useDebounce 实战
React Hooks 的核心价值在于逻辑复用。内置的 useState、useEffect、useRef 等提供了原子能力,而自定义 Hook 让你将这些原子能力组合成可复用的逻辑单元。自定义 Hook 本质上是一个以 use 开头、内部可以调用其他 Hooks 的 JavaScript 函数。它不共享状态,而是共享状态逻辑——每次调用自定义 Hook 都会创建完全独立的状态实例。本节课将深入讲解自定义 Hook 的设计原则、命名规范、如何从组件中提取逻辑、以及三个经典实战封装:useLocalStorage(持久化状态)、useDebounce(防抖)和 usePrevious(追踪前值)。
1. 自定义 Hook 的本质与规则
1.1 什么是自定义 Hook?
自定义 Hook 就是一个普通的 JavaScript 函数,其名称以 use 开头,内部可以调用 React 内置 Hook(useState、useEffect 等)或其他自定义 Hook。它遵循与内置 Hook 完全相同的规则——只能在函数组件或自定义 Hook 的顶层调用,不能在条件、循环或 return 之后调用。
1 | // 最简单的自定义 Hook:封装一个计数逻辑 |
在组件中使用它:
1 | function CounterA() { |
CounterA 和 CounterB 各自拥有完全独立的 count 状态——自定义 Hook 复用的是逻辑(如何计数),而非状态本身。
1.2 自定义 Hook 的三大规则
- 命名必须以
use开头:这是 React 约定,也是 ESLint 的react-hooks规则识别 Hook 并检查其调用是否合法的依据。不以use开头的函数不能被识别为 Hook,也就不能在其内部使用useState等内置 Hook。 - 必须在顶层调用:自定义 Hook 内部的
useState、useEffect等同样受 Hook 调用规则约束,不能放在条件、循环或嵌套函数中。 - 必须是纯函数(副作用除外):Hook 的主体逻辑应是纯的,副作用应封装在
useEffect或事件处理函数中。
2. 从组件中提取逻辑:识别可复用的模式
自定义 Hook 的设计通常不是“从零创造”,而是从组件中提取。当你发现多个组件包含相似的 State 声明、相似的 useEffect 副作用、或相似的事件处理逻辑时,就是提取自定义 Hook 的信号。
2.1 提取前的组件
1 | function SearchPage() { |
2.2 提取为 useDebouncedSearch Hook
1 | function useDebouncedSearch(searchFn, delay = 300) { |
提取后,SearchPage 组件变得极其简洁——它只关心渲染,搜索逻辑完全交给 Hook。useDebouncedSearch 可以在任何需要防抖搜索的组件中复用。
3. 实战封装一:useLocalStorage
将 State 自动同步到 localStorage,实现持久化。这是自定义 Hook 最经典的封装之一。
1 | function useLocalStorage(key, initialValue) { |
使用示例:
1 | function ThemeSwitcher() { |
设计要点:
- 使用
useState的惰性初始化函数,仅在组件首次挂载时读取localStorage,避免每次渲染都读取。 useEffect监听key和storedValue的变化,将最新值写入localStorage。写入操作包裹在try-catch中,处理存储配额溢出或隐私模式下localStorage不可用的情况。- 由于
JSON.parse和JSON.stringify的性能开销极小,这个 Hook 适合存储配置、主题、用户偏好等小型数据。不适合存储大量数据或频繁变化的值。
3.1 跨标签页同步
如果需要多个标签页共享 localStorage 的变化,可以监听 storage 事件:
1 | function useLocalStorage(key, initialValue) { |
4. 实战封装二:useDebounce
防抖(Debounce)是前端最常见的性能优化手段——延迟执行某个操作,直到用户停止触发一段时间后才真正执行。输入框搜索、窗口 resize 回调、按钮防重复点击都是典型场景。
1 | function useDebounce(value, delay = 300) { |
使用示例:
1 | function SearchInput() { |
设计要点:
value是原始值(每次按键都变化),debouncedValue是延迟后的稳定值。- 每次
value变化,useEffect中的清理函数会清除上一个定时器,仅最后一个定时器能成功执行。 delay加入依赖数组,允许动态调整防抖延迟时间。- 这个 Hook 返回的是一个值,而非函数。这是一种“声明式”的防抖模式——你声明“我需要这个值的防抖版本”,Hook 负责计算。
4.1 useDebouncedCallback(函数式防抖)
如果需要防抖一个回调函数而非一个值,可以使用另一种封装:
1 | function useDebouncedCallback(callback, delay = 300) { |
5. 实战封装三:usePrevious
追踪某个 Props 或 State 的上一次值。这在需要比较新旧值的场景(如检测某个值是否发生了变化)中非常有用。
1 | function usePrevious(value) { |
使用示例:
1 | function ScoreBoard({ score }) { |
原理:useRef 在组件的整个生命周期中保持同一个引用。useEffect 在 DOM 更新完成后执行,此时 ref.current 仍然是上一次渲染时存入的值。因此 return ref.current 返回的是旧值,而 ref.current = value 为下一次渲染准备新值。
6. 自定义 Hook 设计原则
6.1 单一职责
每个自定义 Hook 应只做一件事,并提供清晰的输入输出接口。如果一个 Hook 同时管理表单状态、网络请求和本地存储,它应该被拆分为多个更小的 Hook。
1 | // ❌ 职责不清 |
6.2 返回值设计
自定义 Hook 的返回值应该是直观且易于使用的。常见模式:
- 返回数组(类似
useState):适合简单的键值对,调用方可以自由命名。 - 返回对象:适合包含多个属性/方法的复杂 Hook,调用方可以按需解构,无需记住顺序。
1 | // 返回数组(适合简单场景) |
6.3 依赖管理
自定义 Hook 中如果包含 useEffect 或 useCallback,必须正确声明依赖。尤其注意从外部传入的函数和对象——它们的引用可能在每次渲染时变化,导致效果频繁执行。解决方案:
- 要求调用方通过
useCallback/useMemo稳定引用。 - 或在 Hook 内部使用
useRef存储最新值以避免将其加入依赖数组。
7. 综合示例:useMediaQuery
封装一个响应式媒体查询 Hook,自动监听视口变化并返回匹配结果。
1 | function useMediaQuery(query) { |
课后练习
一、概念自测(选择题 / 填空题)
(单选) 自定义 Hook 的命名必须以什么开头?
A.handle
B.on
C.use
D.get(单选) 以下关于自定义 Hook 的描述,哪项是错误的?
A. 自定义 Hook 可以调用其他内置 Hook。
B. 自定义 Hook 可以在条件语句中调用。
C. 每次调用自定义 Hook 会创建独立的状态实例。
D. 自定义 Hook 复用的是逻辑,而非状态本身。(填空) 在
useDebounce(value, delay)Hook 中,当value在delay毫秒内多次变化时,只有______次变化会触发setDebouncedValue。(多选) 以下哪些是提取自定义 Hook 的合理信号?
A. 多个组件包含相同的useState+useEffect模式。
B. 一个组件内的 JSX 代码超过 100 行。
C. 一段数据获取逻辑在三个不同的组件中重复出现。
D. 需要在多个组件间共享状态对象本身。
二、AI 编程任务:编写面向 AI 的提示词
场景:你需要封装一个 useFetch 自定义 Hook,用于通用的数据获取。要求如下:
- 接收一个
url字符串和一个可选的options对象(包含method、headers、body等)。 - 返回
{ data, loading, error, refetch }对象。 refetch函数调用后重新发起请求(使用相同的 URL 和 options)。- 当
url或options发生变化时自动重新请求。 - 在组件卸载时取消未完成的请求(使用
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管理data、loading、error。- 使用
useRef存储AbortController实例。- 使用
useEffect监听url和序列化后的options(使用JSON.stringify比较),发起fetch请求。在效果开始时创建新的AbortController,在清理函数中调用abort()。- 请求成功:设置
data、loading: false、error: null。请求失败且不是AbortError:设置error、loading: false。refetch函数使用useCallback实现:强制触发重新请求(通过递增一个内部retryCountstate 来触发useEffect重新执行)。- 添加 JSDoc 注释,说明泛型参数和返回值。确保代码可在严格 TypeScript 下编译。完成后输出完整文件内容。
四、面试真题与参考答案
题目(字节跳动前端面试题):
请解释 React 自定义 Hook 的设计原则,并举例说明如何从两个包含重复逻辑的组件中提取自定义 Hook。自定义 Hook 与普通工具函数(Utility Function)的本质区别是什么?为什么自定义 Hook 的命名必须以
use开头?
参考答案:
设计原则:
- 单一职责:每个 Hook 只做一件事,有清晰的输入输出。
- 可组合:自定义 Hook 可以调用其他 Hook,形成逻辑层次。
- 遵循 Hook 规则:只在顶层调用,不以
use开头的函数不能调用 Hook。 - 返回值直观:返回数组(类
useState)或对象(复杂场景),便于调用方使用。
提取示例:假设 ComponentA 和 ComponentB 都包含 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 拒绝。
课后练习答案
一、概念自测答案
C
- 解析:自定义 Hook 必须以
use开头,这是 React 的约定和 lint 规则的要求。
- 解析:自定义 Hook 必须以
B
- 解析:自定义 Hook 同样遵循 Hook 调用规则,必须在顶层调用,不能在条件、循环或 return 之后调用。A、C、D 均为正确描述。
最后一
- 解析:防抖逻辑中,每次新变化会清除上一次的定时器,仅在最后一次变化后的
delay毫秒内无新变化时,定时器才会触发更新。
- 解析:防抖逻辑中,每次新变化会清除上一次的定时器,仅在最后一次变化后的
A、C
- 解析:A 和 C 都是逻辑重复的信号,适合提取自定义 Hook。B 是 JSX 规模问题,应通过组件拆分解决;D 描述的是状态共享,需要 Context 或状态管理库,而非自定义 Hook。
二、AI 编程任务参考答案(提示词示例)
示例提示词:
“请用 TypeScript 封装一个通用数据获取 HookuseFetch<T>(url, options?)。要求:
- 返回
{ data: T | null, loading: boolean, error: Error | null, refetch: () => void }。- 使用
AbortController在组件卸载或依赖变化时取消请求。- 当
url或options变化时自动重新请求。- 使用泛型
T让调用方指定数据类型。- 添加 JSDoc 注释。输出完整代码。”