Hooks 让函数组件有了状态和副作用,而自定义 Hook 让"状态逻辑"能像组件一样被复用。
一、从一个需求开始:把鼠标坐标显示在页面上
需求很简单:监听鼠标移动,把坐标 x: 123 y: 456 实时显示在页面上。
最直白的写法,把逻辑全部塞进组件里:
jsx
import { useState, useEffect } from 'react'
const App = () => {
const [x, setX] = useState(null)
const [y, setY] = useState(null)
useEffect(() => {
document.addEventListener("mousemove", handleMouseMove)
return () => {
document.removeEventListener("mousemove", handleMouseMove)
}
function handleMouseMove(e) {
setX(e.clientX)
setY(e.clientY)
}
}, [])
return (
<div>{x && y ? `x:${x} y:${y}` : "鼠标未移动"}</div>
)
}
能跑。但问题很明显:
- 逻辑和 UI 混在一起------这段"订阅事件、更新坐标"的逻辑和渲染长得一模一样,谁也说不清它属于哪个组件。
- 没法复用------另一个组件也想要鼠标坐标?复制粘贴一整段,改个名字,以后两处要同步改 bug。
二、抽成自定义 Hook:useMouse
把这段逻辑从组件里"提"出来,放进一个 use 开头的函数:
js
// src/hooks/useMouse.js
import { useState, useEffect } from 'react'
const useMouse = () => {
const [x, setX] = useState(null)
const [y, setY] = useState(null)
useEffect(() => {
document.addEventListener("mousemove", handleMouseMove)
// 组件卸载后,不会主动回收监听器,需要手动清理
return () => {
document.removeEventListener("mousemove", handleMouseMove)
}
function handleMouseMove(e) {
setX(e.clientX)
setY(e.clientY)
}
}, [])
return { x, y }
}
export default useMouse
组件瞬间变薄:
jsx
import useMouse from './hooks/useMouse'
const App = () => {
const { x, y } = useMouse()
return <div>{x && y ? `x:${x} y:${y}` : "鼠标未移动"}</div>
}
任何组件想要鼠标坐标,一行 useMouse() 就拿走。这就是自定义 Hook 的核心价值:把"响应式状态 + 副作用"打包成一个可复用的函数。
三、三个必须讲清楚的点
1. use 开头是约定,不是语法
自定义 Hook 必须以 use 开头。它不改变函数的行为,而是让 ESLint 的 react-hooks 插件和阅读代码的人能一眼认出"这是一个 Hook",从而保证两条铁律被遵守:
- Hook 只能在函数组件 或其他 Hook 的顶层调用;
- 不能在 if / for / 普通函数里调用(否则状态顺序会错乱)。
所以命名 useMouse、useTheme,不是随便起的。
2. 为什么是"Hook",而不是普通函数?
这是最关键的一点:
普通函数里不能调用 useState / useEffect / useContext。只有 Hook 或组件能调用 Hook。
所以"把响应式和副作用封装进去"这件事,只能用自定义 Hook 做 。普通函数只能做纯逻辑封装(比如格式化时间 formatDate()),一旦想塞进去一个 useState,它就必须升级成 useXxx。
3. 清理副作用:谁添加,谁清理
看 useMouse 里的 useEffect 返回了一个函数:
js
return () => {
document.removeEventListener("mousemove", handleMouseMove)
}
useEffect 的返回值会在组件卸载时执行。为什么要清?
mousemove是挂在document上的全局监听器,组件卸载后它不会自动消失;- 不清的话,监听器会一直挂在
document上------内存泄漏; - 而且监听器里的
setX还在继续执行,去更新一个已经卸载组件的状态,React 会警告。
所以规则是:在 effect 里添加了什么,就在清理函数里移除什么。 订阅(addEventListener)、定时器(setInterval)、网络请求,都要有对应的清理。
四、更高阶:自定义 Hook 里还能消费 Context
useMouse 封装的是"浏览器事件 + state"。但自定义 Hook 的能力不止于此------它还能消费 Context ,把 useContext 一起包进去。
js
// src/hooks/useTheme.js
import { useContext } from 'react'
import { ThemeContext } from '../ThemeContext'
export function useTheme() {
return useContext(ThemeContext)
}
这样,Page、Child 等任何深层组件,都只需要一句:
js
const theme = useTheme() // 我要主题
而不是:
js
import { ThemeContext } from '../ThemeContext'
const theme = useContext(ThemeContext) // 我得先知道有个叫 ThemeContext 的东西
这么包一层的价值,在消费方那边才看得见:
① 消费方不关心数据从哪来。 useTheme() 是语义(我要主题),useContext(ThemeContext) 是实现(去读那个 context)。将来 Provider 里塞进 isDark、fontSize,消费方依然是 useTheme(),不用改。
② 接口稳定,内部随便换。 今天是 useContext(ThemeContext),明天你把它换成多个 context 合并、甚至换成别的状态方案,只要 useTheme 的返回值不变,所有消费者零改动。
③ 集中做派生逻辑。 消费方可以拿到更友好的 API:
js
export function useTheme() {
const theme = useContext(ThemeContext)
return { theme, isDark: theme === "dark" } // 消费方直接拿 isDark
}
④ 生态惯例。 Redux 的 useSelector、useDispatch,React Router 的 useNavigate,全是这个套路------把复杂的内部机制收口成一个语义化的 useXxx。
五、hooks 目录:逻辑层的架构意义
自定义 Hook 通常放在 src/hooks/ 目录下。它代表一种分层:
css
src/
├── components/ ← UI 层:只负责渲染,越薄越好
├── hooks/ ← 逻辑层:响应式状态、副作用、context 消费,都收敛到这里
└── ThemeContext.jsx
组件只管"长什么样",Hook 管"状态怎么变、数据从哪来"。这样组件变薄、逻辑可复用、测试也能单独针对 hook 写。
六、小结:什么时候该抽自定义 Hook
满足以下任一情况,就值得抽:
- 一段状态 / 副作用逻辑在多个组件里重复(鼠标坐标、滚动位置、定时器、防抖);
- 一段逻辑和 UI 无关,纯粹是"状态 + 副作用",适合独立成单元(发请求、订阅、读写本地存储);
- 多个组件要消费同一个 Context (主题、登录用户、语言)------抽成
useTheme、useUser。
命名就一个铁律:以 use 开头。
自定义 Hook 不是魔法,它只是"以 use 开头的、内部可以调用别的 Hook 的函数"。把响应式和副作用装进 useXxx,组件就只管漂亮地渲染。