刚开始学 React Hooks 的时候,我以为
useState、useEffect、useContext就是全部了。直到项目越来越复杂,同样的逻辑在好几个组件里复制粘贴,我才发现------原来 React 还留了一手:"你可以自己造 Hook"。这篇文章用三个实战案例,把自定义 Hook 的封装思路一次讲透。
前言
写 React 写了几个月,有一天我打开项目一看,傻眼了:App.tsx 里塞了几百行代码,useState、useEffect 混在一起,增删改查的逻辑散落在各个角落。想复用一段逻辑?复制粘贴吧。想给另一个组件也加上主题切换?再写一遍 useContext。
后来才明白,React 的自定义 Hook 就是解决这个问题的------把逻辑打包,起个名字,到处用。
你将会收获:
- 🎯 彻底搞懂自定义 Hook 的本质------"用已有的 Hook 造新的 Hook"
- 🔥 用一个完整的 TypeScript 版
useTodos案例,掌握 Hook 封装的完整思路 - ⚡ 用
useTheme案例理解如何把 Context 消费封装成 Hook - 🧵 用鼠标追踪案例学会
useEffect事件监听 + 清理模式 - 🚀 用 Web Worker 案例搞懂
useRef+useEffect持久化实例 - 📦 掌握不可变状态更新、TypeScript 泛型约束在 Hook 中的运用
- 🐛 避开自定义 Hook 开发中的高频踩坑点
技术栈: React 19 + TypeScript 6 + Vite
目录
- [一、自定义 Hook 到底是什么?用大白话讲清楚](#一、自定义 Hook 到底是什么?用大白话讲清楚 "#%E4%B8%80%E8%87%AA%E5%AE%9A%E4%B9%89-hook-%E5%88%B0%E5%BA%95%E6%98%AF%E4%BB%80%E4%B9%88%E7%94%A8%E5%A4%A7%E7%99%BD%E8%AF%9D%E8%AE%B2%E6%B8%85%E6%A5%9A")
- [二、从 useTheme 开始------最简单的自定义 Hook](#二、从 useTheme 开始——最简单的自定义 Hook "#%E4%BA%8C%E4%BB%8E-usetheme-%E5%BC%80%E5%A7%8B%E6%9C%80%E7%AE%80%E5%8D%95%E7%9A%84%E8%87%AA%E5%AE%9A%E4%B9%89-hook")
- [三、useTodos 实战------TypeScript 加持的业务级 Hook](#三、useTodos 实战——TypeScript 加持的业务级 Hook "#%E4%B8%89usetodos-%E5%AE%9E%E6%88%98typescript-%E5%8A%A0%E6%8C%81%E7%9A%84%E4%B8%9A%E5%8A%A1%E7%BA%A7-hook")
- [四、useMouse------useEffect 事件监听 + 清理模式](#四、useMouse——useEffect 事件监听 + 清理模式 "#%E5%9B%9Busemouseuseeffect-%E4%BA%8B%E4%BB%B6%E7%9B%91%E5%90%AC--%E6%B8%85%E7%90%86%E6%A8%A1%E5%BC%8F")
- [五、useWorker------useRef 持久化 + 生命周期管理](#五、useWorker——useRef 持久化 + 生命周期管理 "#%E4%BA%94useworkeruseref-%E6%8C%81%E4%B9%85%E5%8C%96--%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F%E7%AE%A1%E7%90%86")
- [六、自定义 Hook 的常见坑](#六、自定义 Hook 的常见坑 "#%E5%85%AD%E8%87%AA%E5%AE%9A%E4%B9%89-hook-%E7%9A%84%E5%B8%B8%E8%A7%81%E5%9D%91")
- 总结
一、自定义 Hook 到底是什么?用大白话讲清楚
1.1 先看没有 Hook 封装的时候有多痛
假设你有一个 useContext 拿主题的需求,没有自定义 Hook 时,每个组件都要写两行:
jsx
// Page.jsx
import { useContext } from 'react'
import { ThemeContext } from '../ThemeContext'
const theme = useContext(ThemeContext) // 重复的样板代码
// Child.jsx
import { useContext } from 'react'
import { ThemeContext } from '../ThemeContext'
const theme = useContext(ThemeContext) // 又写一遍,一模一样
十个组件、二十个组件------import { useContext } 和 import { ThemeContext } 重复二十遍,又丑又容易出错。
1.2 自定义 Hook 就是"包一层"
markdown
React 内置 Hook(乐高基础颗粒)
│
├── useState ──┐
├── useEffect ├── 组合、封装 ──→ 你自己的 Hook(定制积木)
├── useContext ├── useTheme / useTodos / useMouse
└── useRef ────┘
js
// 把 useContext(ThemeContext) 包进一个叫 useTheme 的函数
export function useTheme() {
return useContext(ThemeContext)
}
组件里从两行变成一行:
jsx
// 之前:两行样板
import { useContext } from 'react'
import { ThemeContext } from '../ThemeContext'
const theme = useContext(ThemeContext)
// 之后:一行搞定
import { useTheme } from '../hooks/useTheme'
const theme = useTheme()
1.3 自定义 Hook 的两个铁律
| 规则 | 说明 | 类比 |
|---|---|---|
use 开头命名 |
React 靠 use 前缀识别 Hook,核心就是区分普通函数 |
交警看车牌识别车辆类型 |
| 只能在组件或 Hook 里调用 | 不能在外面、不能在 if 里、不能在普通函数里 |
Hook 有"家规",只能在 React 的地盘用 |
1.4 自定义 Hook 比普通函数好在哪?
js
// ❌ 普通函数:只能封装计算逻辑,没法用 React 的响应式能力
function getTheme() {
return ThemeContext // useContext 不能在这调用!React 会报错
}
// ✅ 自定义 Hook:能使用 React 所有内置 Hook,自带响应式
export function useTheme() {
return useContext(ThemeContext) // 合法!在 Hook 里调 Hook
}
自定义 Hook 就是 "能使用 React 内置 Hook 的函数" ------它可以把 useState、useEffect、useContext 这些基础 Hook 组合起来,封装成你自己的高级积木。
一句话记住:用已有的 Hook,造新的 Hook------包一层,省十行。
二、从 useTheme 开始------最简单的自定义 Hook
先从最简案例入手,理解封装的本质。
2.1 完整的文件关系
这个例子涉及 5 个文件,先看全景图:
scss
ThemeContext.jsx ← ① 挖管道:createContext("light")
│
App.jsx ← ② 灌数据:Provider + useState 驱动
│
├── Page.jsx ← ③ 中间层:不传 props,直接拿数据
│
└── Child.jsx ← ④ 消费端:useTheme() 一行拿到
↑
hooks/useTheme.js ← ⑤ 自定义 Hook:封装 useContext
2.2 第一步:创建 Context 管道
jsx
// ThemeContext.jsx ------ 将创建一个 Theme 上下文,为深层次的组件服务
import { createContext } from 'react'
export const ThemeContext = createContext("light")
// 默认值 ↑ 不传 Provider 的话就用这个
2.3 第二步:Provider 往管道里灌数据
jsx
// App.jsx ------ 上下文的提供者容器
import { useState } from 'react'
import { ThemeContext } from './ThemeContext'
import Page from './components/page'
function App() {
const [theme, setTheme] = useState("light")
return (
// 并不是需要全局------任何地方作为容器使用
// 默认值 light,可以通过 value 来改变
<ThemeContext.Provider value={theme}>
<Page />
<button onClick={() => setTheme("dark")}>切换主题</button>
</ThemeContext.Provider>
)
}
export default App
关键点:Provider 不是全局的,你在哪包就在哪生效,可以在不同区域提供不同的值。
2.4 第三步:自定义 Hook 封装 useContext
js
// hooks/useTheme.js ------ 自定义 Hook
import { ThemeContext } from '../ThemeContext'
import { useContext } from 'react'
export function useTheme() {
return useContext(ThemeContext) // 一行核心逻辑
}
就这么一行,但它解决的问题很大。
2.5 第四步:组件消费数据------中间层随意
jsx
// components/Page.jsx ------ 中间层
import Child from './Child'
import { useTheme } from '../hooks/useTheme'
const Page = () => {
const theme = useTheme() // 一行拿到,不用 props 中转
return (
<>
Page {theme}
<br />
<Child /> {/* Page 自己不传给 Child! */}
</>
)
}
export default Page
jsx
// components/Child.jsx ------ 深层消费者
import { useTheme } from '../hooks/useTheme'
function Child() {
const theme = useTheme() // 跨层级直接拿到!
return (
<>
Child
<button className={theme}>按钮 {theme}</button>
</>
)
}
export default Child
2.6 数据流图
scss
App.jsx
│ useState("light") → 响应式驱动
│ <ThemeContext.Provider value={theme}>
│ │
│ ├── Page.jsx
│ │ │ useTheme() → "light" → 显示 "Page light"
│ │ │
│ │ └── Child.jsx
│ │ │ useTheme() → "light" → 显示按钮
│ │
│ └── <button onClick> → setTheme("dark")
│ │
│ 所有 useTheme() 自动拿到 "dark" ← React 重渲染
│
└── 整个树共享一份 theme 值,中间不做 props 传递
2.7 对比:有无自定义 Hook
| 维度 | 没有 useTheme | 有 useTheme |
|---|---|---|
| 每个组件引用 | useContext + ThemeContext 两个 import |
useTheme 一个 import |
| 出错概率 | 可能拼错 Context 名字 | 不会,Hook 里就写死正确的那一个 |
| 语义 | useContext(ThemeContext) 技术化的读法 |
useTheme() 一看就知道是拿主题 |
| 切换 Context | 每个组件挨个改 | 只改 Hook 文件一处 |
一句话记住:useTheme = useContext(ThemeContext) 的包装纸------让使用侧更干净。
三、useTodos 实战------TypeScript 加持的业务级 Hook
useTheme 展示的是"包一层",真正的业务 Hook 会复杂得多------多个 state、多个操作函数、TypeScript 类型约束、不可变更新。useTodos 是理解这些的最佳案例。
3.1 先定义类型
ts
// types/todo.ts
export interface Todo {
id: string
text: string
completed: boolean
}
// 类型别名------简单数据类型用 type,复杂结构用 interface
// 联合类型:Filter 只能是这三个值之一
export type Filter = 'all' | 'completed' | 'uncompleted'
为什么 Todo 是 interface,Filter 是 type?
interface |
type |
|
|---|---|---|
| 适合描述 | 复杂的对象结构 | 简单类型、联合类型、交叉类型 |
| 扩展方式 | extends |
& 交叉 |
| 这里例子 | Todo 是一个对象,有三个属性 |
Filter 是三个字符串的联合 |
3.2 完整的 useTodos Hook
ts
// hooks/useTodos.ts
import { useState } from 'react'
import type { Todo, Filter } from '../types/todo'
// ↑ import type:告诉 TS 这些只在编译时用,不产生真实 JS 代码
export function useTodos() {
// ========== 状态定义 ==========
const [todos, setTodos] = useState<Todo[]>([])
// ^^^^^^ 泛型约束:这个数组存的是 Todo
const [filter, setFilter] = useState<Filter>('all')
// ^^^^^^ 约束 filter 只能是那三个值
// ========== 增:添加一条 ==========
const addTodo = (text: string) => {
if (!text.trim()) return // 空文本直接拦住
const newTodo: Todo = {
id: Date.now().toString(),
text: text.trim(),
completed: false,
}
// 传一个函数,拿到之前的状态,返回新的状态
setTodos(prev => [...prev, newTodo])
// ^^^^^^^^^^^^^^^^ 展开旧数组 + 新对象 = 创建新数组
}
// ========== 改:切换完成状态 ==========
const toggleTodo = (id: string) => {
setTodos(prev =>
prev.map(item => // map 返回新数组
item.id === id // 找到 id 匹配的那条
? { ...item, completed: !item.completed } // 匹配:翻 completed
: item // 不匹配:原样不动
)
)
}
// ========== 删:删除一条 ==========
const deleteTodo = (id: string) => {
setTodos(prev =>
prev.filter(item => item.id !== id)
// ^^^^^^ 保留 id 不等于要删的那条------等价于"除了它全留下"
)
}
// ========== 批量删:清除已完成 ==========
const clearCompleted = () => {
setTodos(prev => prev.filter(item => !item.completed))
}
// ========== 暴露给外部 ==========
return {
todos, // 数据
filter, // 数据
addTodo, // 操作方法
toggleTodo,
deleteTodo,
clearCompleted,
}
}
3.3 逐行精讲
useState<Todo[]>([]) ------ 为什么用泛型?
ts
// ❌ 不加泛型
const [todos, setTodos] = useState([])
// TS 推断:todos 是 never[](永远不放东西的数组)
todos.push({ id: '1', text: '学习', completed: false })
// 💥 类型报错:never[] 不能放 Todo
// ✅ 加了泛型
const [todos, setTodos] = useState<Todo[]>([])
// TS 知道:todos 是 Todo 数组,每个元素有 id、text、completed
todos[0].text // ✅ 有智能补全
泛型就是告诉 TypeScript 这个状态的形状,敲代码时有智能提示、写错了有红色提醒。
setTodos(prev => [...prev, newTodo]) ------ 为什么用箭头函数?
ts
// ❌ 直接传值:可能拿到过时的闭包值
setTodos([...todos, newTodo])
// 如果短时间内多次调用,todos 可能还是旧值
// ✅ 传函数:React 保证 prev 是最新值
setTodos(prev => [...prev, newTodo])
// 不管调用几次,每次 prev 都是最新的
{ ...item, completed: !item.completed } ------ 为什么先展开?
不可变更新的铁律:不改原对象,创建新对象 。{ ...item, completed: !item.completed } 意思是"把 item 的所有属性先复制过来,然后把 completed 覆盖掉"。
ts
// ❌ 直接修改原对象
item.completed = !item.completed // React 检测不到引用变化
// ✅ 创建新对象
{ ...item, completed: !item.completed }
3.4 调用端:一行拿到全部
tsx
// 任何组件里
const { todos, filter, addTodo, toggleTodo, deleteTodo, clearCompleted } = useTodos()
对比不抽 Hook 时,每个组件都得:
tsx
const [todos, setTodos] = useState<Todo[]>([])
const [filter, setFilter] = useState<Filter>('all')
const addTodo = (text: string) => { ... }
const toggleTodo = (id: string) => { ... }
const deleteTodo = (id: string) => { ... }
const clearCompleted = () => { ... }
// 几十行代码复制粘贴到每个需要 Todo 的组件......
3.5 不可变更新速查表
| 操作 | 武器 | 写法 | 原理 |
|---|---|---|---|
| 添加一項 | 展开运算符 | [...prev, newItem] |
创建新数组,返回新引用 |
| 修改一项 | map + ... |
prev.map(i => i.id===id ? {...i, k:v} : i) |
找到匹配的,展开覆盖 |
| 删除一项 | filter |
prev.filter(i => i.id !== id) |
保留不匹配的 |
| 清空条件 | filter 取反 |
prev.filter(i => !i.completed) |
保留不符合条件的 |
一句话记住:map 改、filter 删、
[...]加------每次都给 React 一个新引用。
四、useMouse------useEffect 事件监听 + 清理模式
如果说 useTodos 练的是一组 state 的管理,那这个案例练的就是 useEffect 的"挂载 → 清理"模式。
4.1 需求
追踪鼠标位置(x, y),显示在页面上。平时可以直接写在组件里,但用自定义 Hook 封装后,任何组件想用鼠标坐标都可以一行引入。
4.2 完整代码
jsx
// hooks/useMouse.js
import { useState, useEffect } from 'react'
export function useMouse() {
const [x, setX] = useState(null)
const [y, setY] = useState(null)
useEffect(() => {
function handleMouseMove(e) {
setX(e.clientX)
setY(e.clientY)
}
document.addEventListener('mousemove', handleMouseMove)
return () => {
// 组件卸载时手动回收事件监听
// 定时器、Worker、事件------都需要手动清理
document.removeEventListener('mousemove', handleMouseMove)
}
}, []) // 空数组 = 只在挂载时执行一次
return { x, y }
}
4.3 数据流
kotlin
组件挂载
│
▼
useEffect 执行 → addEventListener 绑定事件
│
▼
鼠标移动 → handleMouseMove → setX / setY → 返回 { x, y } → 组件重渲染
│
▼
组件卸载 → return 里的清理函数 → removeEventListener 解绑
4.4 为什么 return 里要清理?
没有清理:
组件挂载 → 绑定事件
组件卸载 → 事件还在!→ 内存泄漏
再次挂载 → 再绑定一个 → 两个事件在跑 → 越来越卡
有清理:
组件挂载 → 绑定事件
组件卸载 → 解绑事件 ✅
再次挂载 → 重新绑定 ✅(干净清爽)
4.5 对比:写在组件里 vs 抽成 Hook
jsx
// ❌ 写在组件里:跟 UI 混在一起,想复用只能复制粘贴
function App() {
const [x, setX] = useState(null)
const [y, setY] = useState(null)
useEffect(() => { ... }, [])
return <div>x:{x}, y:{y}</div>
}
// ✅ 抽成 Hook:鼠标逻辑独立,任何组件想用就引用
function App() {
const { x, y } = useMouse() // 一行
return <div>x:{x}, y:{y}</div>
}
一句话记住:useEffect 绑定、return 清理------来的时候打开,走的时候关上。
五、useWorker------useRef 持久化 + 生命周期管理
这个案例稍有不同------Worker 实例需要用 useRef 而不用 useState,因为它不需要触发渲染。
5.1 回顾:为什么 Worker 必须用 useRef?
| 存储方式 | 跨渲染持久化 | 不触发渲染 | 适合存 Worker |
|---|---|---|---|
普通 let 变量 |
❌ 每次渲染重新赋值 | ✅ | ❌ |
useState |
✅ | ❌ 会触发无用渲染 | ❌ |
useRef |
✅ | ✅ | ✅ |
Worker 实例不需要显示在页面上 ,改了它也不该触发渲染。useRef 刚好满足:持久化 + 非响应式。
5.2 完整代码
jsx
// hooks/useWorker.js
import { useRef, useEffect } from 'react'
export function useWorker(workerPath) {
const workerRef = useRef(null) // 固定停车位
useEffect(() => {
// 组件挂载后才创建 Worker------菜上桌了
workerRef.current = new Worker(
new URL(workerPath, import.meta.url)
)
return () => {
// 组件卸载时终止 Worker------关餐厅也关炉子
workerRef.current?.terminate()
workerRef.current = null // 手动回收
}
}, [workerPath]) // workerPath 变了才重新创建
// 暴露通信方法
const postMessage = (data) => {
workerRef.current?.postMessage(data)
}
const onMessage = (callback) => {
if (workerRef.current) {
workerRef.current.onmessage = callback
}
}
return { postMessage, onMessage, workerRef }
}
5.3 调用端
jsx
// App.jsx
function App() {
const { postMessage, onMessage } = useWorker('./worker.js')
useEffect(() => {
onMessage((e) => {
console.log('主线程收到结果:', e.data)
})
}, [])
const startCalc = () => {
postMessage({ num: 88 })
}
return <button onClick={startCalc}>启动计算</button>
}
5.4 为什么 useWorker 也算自定义 Hook?
因为它封装了三个能力:
useRef--- 持久化 Worker 实例,跨渲染不丢失useEffect--- 控制创建/销毁时机,加清理- 暴露 API ---
postMessage/onMessage让调用方不用管内部细节
调用方完全不需要知道 Worker 是怎么创建的------这就是封装的本质。
一句话记住:useRef 是固定停车位,Worker 是车。不管组件怎么渲染,车位里的车始终是那一辆。
六、自定义 Hook 的常见坑
坑1:在 Hook 外面直接调 Hook
js
// ❌ 错误:在普通函数里调用 Hook
function getTodos() {
const [todos, setTodos] = useState([]) // 💥 React 报错
}
// ✅ 正确:在自定义 Hook(use 开头)里调用
function useTodos() {
const [todos, setTodos] = useState([]) // ✅ 合法
}
坑2:忘记 return 清理函数
js
// ❌ 错误:事件监听在,组件不在了
useEffect(() => {
document.addEventListener('mousemove', handler)
// 没有 return 清理!
}, [])
// ✅ 正确:加 return 清理
useEffect(() => {
document.addEventListener('mousemove', handler)
return () => document.removeEventListener('mousemove', handler)
}, [])
坑3:依赖数组写漏了
js
// ❌ 错误:useEffect 里用了外部变量,但是 [] 是空的
useEffect(() => {
console.log(filter) // 用了 filter,但 filter 变化时不会重新执行
}, [])
// ✅ 正确:外部依赖要写进数组
useEffect(() => {
console.log(filter)
}, [filter]) // filter 变化时重新执行
坑4:用 useState 存不需要渲染的值
jsx
// ❌ 错误:Worker 实例不需要显示,不该用 useState
const [worker, setWorker] = useState(null)
// 每次 setWorker 都会触发一次没有意义的渲染
// ✅ 正确:不需要显示的值用 useRef
const workerRef = useRef(null)
// 改了不渲染,持久化又省心
总结
四个 Hook 速查表
| Hook | 封装了什么 | 用到的内置 Hook | 适用场景 |
|---|---|---|---|
useTheme() |
Context 取值 | useContext |
跨层级共享数据 |
useTodos() |
Todo 增删改查 | useState |
业务状态管理 |
useMouse() |
鼠标坐标追踪 | useState + useEffect |
浏览器事件监听 |
useWorker() |
Worker 实例管理 | useRef + useEffect |
长生命周期实例 |
自定义 Hook 决策树
markdown
这段逻辑在多个组件里用到了吗?
│
┌─┴─┐
▼ ▼
是 否
│ │
▼ ▼
能否 保持
用 在原
内置 组件
Hook 里
封装
成一个
新Hook
一句口诀
用已有的 Hook,造自己的 Hook------包状态、包逻辑、包生命周期,命名以
use开头,到处复用。
核心代码骨架
ts
// 一个自定义 Hook 的标准结构
import { useState, useEffect, useRef } from 'react'
export function useXXX(params) { // ① use 开头
const [state, setState] = useState() // ② 内置 Hook 组合
const ref = useRef()
useEffect(() => { // ③ 副作用 + 清理
// 挂载时执行
return () => { /* 卸载时清理 */ }
}, [params])
const action = () => { // ④ 操作方法
setState(prev => /* 不可变更新 */)
}
return { state, action } // ⑤ 暴露给外部
}
结尾
这次学习让我对 React Hooks 的理解上了一个台阶------从一个会用内置 Hook 的"API 调用者",变成了能自己造 Hook 的"组件架构师"。希望这篇文章能帮你少走点弯路。
📦 源码地址:
hjf-ai/fe/react/basic/(包含 context-demo、ref-demo 两个完整项目)
如果这篇文章帮你搞懂了自定义 Hook 的封装思路,不妨点个赞、收个藏,分享给同样在学 React 的小伙伴 🔥