# React 自定义 Hook 从入门到实战——用 useTodos 和 useTheme 彻底搞懂封装思维 > 刚开始学 React Hooks 的时候,

刚开始学 React Hooks 的时候,我以为 useStateuseEffectuseContext 就是全部了。直到项目越来越复杂,同样的逻辑在好几个组件里复制粘贴,我才发现------原来 React 还留了一手:"你可以自己造 Hook"。这篇文章用三个实战案例,把自定义 Hook 的封装思路一次讲透。

前言

写 React 写了几个月,有一天我打开项目一看,傻眼了:App.tsx 里塞了几百行代码,useStateuseEffect 混在一起,增删改查的逻辑散落在各个角落。想复用一段逻辑?复制粘贴吧。想给另一个组件也加上主题切换?再写一遍 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 的函数" ------它可以把 useStateuseEffectuseContext 这些基础 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'

为什么 TodointerfaceFiltertype

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?

因为它封装了三个能力:

  1. useRef --- 持久化 Worker 实例,跨渲染不丢失
  2. useEffect --- 控制创建/销毁时机,加清理
  3. 暴露 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 的小伙伴 🔥

相关推荐
城管不管16 小时前
第六次面试2026.8.5北京一面已OC
面试·职场和发展
kyriewen17 小时前
我排查了一个React内存泄漏——罪魁祸首是这3个被忽略的清理函数
前端·javascript·面试
windliang18 小时前
Claude Code 源码分析(七):Skill 如何进入 Agent
前端·人工智能·面试
缓冲中请稍后18 小时前
React Router 完全指南:从 HashRouter 到 BrowserRouter
前端·面试
MindUp19 小时前
面试录音视频的AI复盘方案:从语音转录到RAG问答的技术实践
人工智能·面试·音视频
李文旺20 小时前
意图识别精准度升级方案
面试
江畔柳前堤1 天前
YOLO 目标检测全流程深度剖析
人工智能·yolo·目标检测·计算机视觉·unity·面试·vllm
Lumos1861 天前
《嵌入式通讯协议栈实战》 3 通讯帧设计
面试
做前端的娜娜子1 天前
移动端上拉加载与下拉刷新实现方案
前端·面试·掘金·金石计划