React 自定义 Hook:把响应式与副作用装进 useXxx

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>
  )
}

能跑。但问题很明显:

  1. 逻辑和 UI 混在一起------这段"订阅事件、更新坐标"的逻辑和渲染长得一模一样,谁也说不清它属于哪个组件。
  2. 没法复用------另一个组件也想要鼠标坐标?复制粘贴一整段,改个名字,以后两处要同步改 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 / 普通函数里调用(否则状态顺序会错乱)。

所以命名 useMouseuseTheme,不是随便起的。

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)
}

这样,PageChild 等任何深层组件,都只需要一句:

js 复制代码
const theme = useTheme()   // 我要主题

而不是:

js 复制代码
import { ThemeContext } from '../ThemeContext'
const theme = useContext(ThemeContext)   // 我得先知道有个叫 ThemeContext 的东西

这么包一层的价值,在消费方那边才看得见:

① 消费方不关心数据从哪来。 useTheme() 是语义(我要主题),useContext(ThemeContext) 是实现(去读那个 context)。将来 Provider 里塞进 isDarkfontSize,消费方依然是 useTheme(),不用改。

② 接口稳定,内部随便换。 今天是 useContext(ThemeContext),明天你把它换成多个 context 合并、甚至换成别的状态方案,只要 useTheme 的返回值不变,所有消费者零改动

③ 集中做派生逻辑。 消费方可以拿到更友好的 API:

js 复制代码
export function useTheme() {
  const theme = useContext(ThemeContext)
  return { theme, isDark: theme === "dark" }   // 消费方直接拿 isDark
}

④ 生态惯例。 Redux 的 useSelectoruseDispatch,React Router 的 useNavigate,全是这个套路------把复杂的内部机制收口成一个语义化的 useXxx

五、hooks 目录:逻辑层的架构意义

自定义 Hook 通常放在 src/hooks/ 目录下。它代表一种分层:

css 复制代码
src/
├── components/     ← UI 层:只负责渲染,越薄越好
├── hooks/          ← 逻辑层:响应式状态、副作用、context 消费,都收敛到这里
└── ThemeContext.jsx

组件只管"长什么样",Hook 管"状态怎么变、数据从哪来"。这样组件变薄、逻辑可复用、测试也能单独针对 hook 写

六、小结:什么时候该抽自定义 Hook

满足以下任一情况,就值得抽:

  1. 一段状态 / 副作用逻辑在多个组件里重复(鼠标坐标、滚动位置、定时器、防抖);
  2. 一段逻辑和 UI 无关,纯粹是"状态 + 副作用",适合独立成单元(发请求、订阅、读写本地存储);
  3. 多个组件要消费同一个 Context (主题、登录用户、语言)------抽成 useThemeuseUser

命名就一个铁律:以 use 开头

自定义 Hook 不是魔法,它只是"以 use 开头的、内部可以调用别的 Hook 的函数"。把响应式和副作用装进 useXxx,组件就只管漂亮地渲染。

相关推荐
触底反弹1 小时前
🏗️ 写完 Todos 之后,大型 React 项目的 7 个架构真相
前端·react.js·前端框架
kisshyshy1 小时前
从多页面到SPA:React Router 路由进阶完全指南
前端·javascript·react.js
Zzj_tju4 小时前
Tool-Using LLM 论文精读路线:从 ReAct 到可验证工具调用
前端·react.js·前端框架
To_OC11 小时前
别再瞎写 React Router!7 个高频踩坑点一次性讲透
前端·javascript·react.js
kyriewen14 小时前
我踩了三次同一个坑才明白:React里"改了数据却不重新渲染"的真正原因
前端·javascript·react.js
A242073493015 小时前
React中请求拦截与响应拦截的统一处理方案
前端·javascript·react.js
烬羽17 小时前
《受控 vs 非受控:你以为用对了 useState,直到你写了那个表单》
javascript·react.js·前端框架
嘟嘟071717 小时前
React 受控组件与非受控组件:表单数据到底归谁管?
前端·javascript·react.js
嘟嘟071717 小时前
React 表单管理进阶:从单字段到带校验的完整登录表单
前端·javascript·react.js