在 React 页面中显示鼠标坐标并不复杂:准备两个状态,监听 mousemove,再把坐标渲染出来即可。
最直接的写法可能是这样:
import { useEffect, useState } from 'react';
function App() {
const [x, setX] = useState(null);
const [y, setY] = useState(null);
useEffect(() => {
function handleMouseMove(event) {
setX(event.clientX);
setY(event.clientY);
}
document.addEventListener('mousemove', handleMouseMove);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
};
}, []);
return (
<div>
x: {x},y: {y}
</div>
);
}
这段代码能工作,但组件同时承担了三种职责:
- 管理鼠标坐标状态。
- 订阅和清理浏览器事件。
- 决定页面如何展示坐标。
如果另一个组件也要使用鼠标位置,就只能复制这段 useState + useEffect。
真正值得复用的并不是页面上的 <div>,而是“把浏览器鼠标事件转换成 React 状态”这段带有生命周期的逻辑。
这正是自定义 Hook 要解决的问题。
一、自定义 Hook 复用的不是代码片段,而是状态逻辑
普通工具函数可以封装计算:
function add(a, b) {
return a + b;
}
但它不能直接在内部调用 useState、useEffect 等 React Hook。
自定义 Hook 本质上仍然是 JavaScript 函数,只不过它遵循 React Hooks 的调用规则,并且可以继续组合其他 Hook:
function useSomething() {
const [state, setState] = useState();
useEffect(() => {
// 订阅外部系统
}, []);
return state;
}
按照约定,自定义 Hook 的名称必须以 use 开头。这个命名不是为了好看,而是在告诉 React、ESLint 和其他开发者:这个函数内部可能调用了 Hook,必须遵守 Hook 的调用规则。
最重要的两条规则是:
- 只在 React 函数组件或自定义 Hook 中调用 Hook。
- 在函数顶层调用,不要放进条件、循环或普通事件函数里。
二、把鼠标状态和事件监听抽到 useMouse
可以把原来写在 App 中的逻辑移动到 src/hooks/useMouse.js:
import { useEffect, useState } from 'react';
export function useMouse() {
const [position, setPosition] = useState({
x: null,
y: null,
});
useEffect(() => {
function handleMouseMove(event) {
setPosition({
x: event.clientX,
y: event.clientY,
});
}
document.addEventListener('mousemove', handleMouseMove);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
};
}, []);
return position;
}
这个 Hook 对外只暴露组件真正需要的结果:
{
x,
y
}
至于坐标如何产生、事件在哪里监听、组件卸载时如何清理,都被封装在 useMouse 内部。
业务组件不需要理解这些实现细节。
三、组件只负责展示
抽取之后,App 会变得非常简单:
import { useMouse } from './hooks/useMouse.js';
function App() {
const { x, y } = useMouse();
const hasMoved = x !== null && y !== null;
return (
<div
style={{
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{hasMoved ? `x: ${x},y: ${y}` : '鼠标尚未移动'}
</div>
);
}
export default App;
现在职责边界清晰了:
useMouse负责把浏览器事件转换为 React 状态。App负责决定状态如何显示。
将来无论是坐标面板、拖拽组件还是跟随鼠标移动的提示框,都可以调用同一个 useMouse。
四、为什么不能写成 x && y?
原始 demo 中可能会看到这样的判断:
{x && y ? `x: ${x}, y: ${y}` : '鼠标未移动'}
这段代码存在一个不容易发现的边界问题。
鼠标移动到浏览器最左边时,x 可以等于 0;移动到最上边时,y 也可以等于 0。但 JavaScript 会把数字 0 判断为假值,于是页面错误地显示“鼠标未移动”。
这里真正需要判断的不是坐标是否为真值,而是坐标是否仍为初始值 null:
const hasMoved = x !== null && y !== null;
这个细节说明,条件渲染应当围绕业务语义编写,而不是随手依赖 JavaScript 的真假值转换。
五、useEffect 为什么必须返回清理函数?
document.addEventListener 把事件监听器注册到了 React 组件之外的浏览器对象上。
组件卸载并不会自动替我们执行:
document.removeEventListener(...);
如果不清理,旧的监听函数可能继续保留,造成多余执行、重复订阅或内存占用。
因此,创建订阅的 Effect 应当同时描述如何撤销订阅:
useEffect(() => {
function handleMouseMove(event) {
// 处理事件
}
document.addEventListener('mousemove', handleMouseMove);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
};
}, []);
这里必须使用同一个 handleMouseMove 函数引用。下面这种写法无法删除原来的监听函数:
document.addEventListener('mousemove', () => {
// ...
});
document.removeEventListener('mousemove', () => {
// 这是另一个新函数
});
两个箭头函数的代码即使完全相同,也不是同一个函数对象。
开发环境为什么可能看到 Effect 执行两次?
如果入口使用了 StrictMode,React 在开发环境中可能执行一次“挂载、清理、再次挂载”的检查流程。
这不是 [] 依赖数组失效,也不是 React 创建了两个正式组件,而是在帮助我们发现没有正确清理的副作用。
只要 Effect 的订阅与清理保持对称:
添加监听 → 删除监听 → 再次添加监听
最终就只会保留有效的那一次订阅。
因此,与其说 useEffect(..., []) “绝对只执行一次”,更准确的说法是:它描述的是组件一次挂载周期中的订阅逻辑;在开发环境的严格检查下,React 可能额外执行设置和清理来验证代码是否安全。
六、调用同一个自定义 Hook,会共享同一份状态吗?
这是自定义 Hook 最常见的误解之一。
假设两个组件都调用 useMouse:
function Header() {
const position = useMouse();
}
function Panel() {
const position = useMouse();
}
它们复用了相同的逻辑,但不会自动共享同一个 position 状态。
每次调用自定义 Hook,都会创建一套属于当前组件的 Hook 状态和 Effect。上面的例子会注册两次 mousemove 监听,并分别维护两份坐标状态。
所以要区分两个概念:
- 自定义 Hook:复用有状态逻辑。
- Context:在多个组件之间共享同一份数据。
如果很多组件需要使用完全相同的鼠标坐标,而且页面对性能比较敏感,可以在上层只调用一次 useMouse,再通过 Props 或 Context 向下提供结果,而不是让每个组件重复注册全局监听。
七、什么时候值得抽取自定义 Hook?
不是代码一变长,就必须抽 Hook。更实用的判断标准是:组件中是否出现了一段可以独立命名的状态逻辑。
例如:
- 状态和 Effect 总是成组出现。
- 多个组件需要重复相同的订阅逻辑。
- 组件混合了数据获取、浏览器 API 和 UI 渲染,已经难以阅读。
- 这段逻辑可以用一个明确名称描述,例如
useMouse、useOnlineStatus、useTodos。
自定义 Hook 也不等于把所有代码都塞进 hooks 目录。一个只执行普通字符串转换、数组排序或数学计算的函数,通常仍然应该是普通工具函数。
只有需要调用其他 React Hook,或者表达组件生命周期相关逻辑时,才需要自定义 Hook。
八、mousemove 高频触发,需要优化吗?
mousemove 触发频率很高,每次调用 setPosition 都可能带来一次组件更新。
在这个教学 demo 中,组件结构简单,直接更新状态足以说明 Hook 的工作方式,没有必要过早优化。
但如果鼠标坐标驱动的是复杂页面、图表或大量 DOM,可以继续考虑:
- 使用
requestAnimationFrame合并一帧内的多次更新。 - 对事件进行节流。
- 只在真正需要监听时挂载组件。
- 避免让大量组件分别调用
useMouse。
优化应建立在实际性能问题上,而不是看到高频事件就机械添加节流代码。
九、总结
自定义 Hook 的价值并不是少写几行代码,而是给一段有状态、有生命周期的逻辑建立清晰边界。
在这个鼠标坐标 demo 中:
useState保存最新坐标。useEffect连接 React 与浏览器的mousemove事件。- 清理函数在组件卸载时删除监听。
useMouse对外返回坐标,组件只负责渲染。
同时要记住:调用同一个自定义 Hook 只是复用逻辑,并不会让多个组件自动共享同一份状态。
一句话总结:普通函数复用计算,自定义 Hook 复用状态与副作用,Context 才负责让多个组件共享同一份数据。