React 自定义 Hook 实战:封装鼠标坐标与副作用清理

在 React 页面中显示鼠标坐标并不复杂:准备两个状态,监听 mousemove,再把坐标渲染出来即可。

最直接的写法可能是这样:

jsx 复制代码
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 复用的不是代码片段,而是状态逻辑

普通工具函数可以封装计算:

js 复制代码
function add(a, b) {
  return a + b;
}

但它不能直接在内部调用 useStateuseEffect 等 React Hook。

自定义 Hook 本质上仍然是 JavaScript 函数,只不过它遵循 React Hooks 的调用规则,并且可以继续组合其他 Hook:

js 复制代码
function useSomething() {
  const [state, setState] = useState();

  useEffect(() => {
    // 订阅外部系统
  }, []);

  return state;
}

按照约定,自定义 Hook 的名称必须以 use 开头。这个命名不是为了好看,而是在告诉 React、ESLint 和其他开发者:这个函数内部可能调用了 Hook,必须遵守 Hook 的调用规则。

最重要的两条规则是:

  1. 只在 React 函数组件或自定义 Hook 中调用 Hook。
  2. 在函数顶层调用,不要放进条件、循环或普通事件函数里。

二、把鼠标状态和事件监听抽到 useMouse

可以把原来写在 App 中的逻辑移动到 src/hooks/useMouse.js

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 对外只暴露组件真正需要的结果:

js 复制代码
{
  x,
  y
}

至于坐标如何产生、事件在哪里监听、组件卸载时如何清理,都被封装在 useMouse 内部。

业务组件不需要理解这些实现细节。

三、组件只负责展示

抽取之后,App 会变得非常简单:

jsx 复制代码
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 中可能会看到这样的判断:

jsx 复制代码
{x && y ? `x: ${x}, y: ${y}` : '鼠标未移动'}

这段代码存在一个不容易发现的边界问题。

鼠标移动到浏览器最左边时,x 可以等于 0;移动到最上边时,y 也可以等于 0。但 JavaScript 会把数字 0 判断为假值,于是页面错误地显示"鼠标未移动"。

这里真正需要判断的不是坐标是否为真值,而是坐标是否仍为初始值 null

js 复制代码
const hasMoved = x !== null && y !== null;

这个细节说明,条件渲染应当围绕业务语义编写,而不是随手依赖 JavaScript 的真假值转换。

五、useEffect 为什么必须返回清理函数?

document.addEventListener 把事件监听器注册到了 React 组件之外的浏览器对象上。

组件卸载并不会自动替我们执行:

js 复制代码
document.removeEventListener(...);

如果不清理,旧的监听函数可能继续保留,造成多余执行、重复订阅或内存占用。

因此,创建订阅的 Effect 应当同时描述如何撤销订阅:

js 复制代码
useEffect(() => {
  function handleMouseMove(event) {
    // 处理事件
  }

  document.addEventListener('mousemove', handleMouseMove);

  return () => {
    document.removeEventListener('mousemove', handleMouseMove);
  };
}, []);

这里必须使用同一个 handleMouseMove 函数引用。下面这种写法无法删除原来的监听函数:

js 复制代码
document.addEventListener('mousemove', () => {
  // ...
});

document.removeEventListener('mousemove', () => {
  // 这是另一个新函数
});

两个箭头函数的代码即使完全相同,也不是同一个函数对象。

开发环境为什么可能看到 Effect 执行两次?

如果入口使用了 StrictMode,React 在开发环境中可能执行一次"挂载、清理、再次挂载"的检查流程。

这不是 [] 依赖数组失效,也不是 React 创建了两个正式组件,而是在帮助我们发现没有正确清理的副作用。

只要 Effect 的订阅与清理保持对称:

text 复制代码
添加监听 → 删除监听 → 再次添加监听

最终就只会保留有效的那一次订阅。

因此,与其说 useEffect(..., []) "绝对只执行一次",更准确的说法是:它描述的是组件一次挂载周期中的订阅逻辑;在开发环境的严格检查下,React 可能额外执行设置和清理来验证代码是否安全。

六、调用同一个自定义 Hook,会共享同一份状态吗?

这是自定义 Hook 最常见的误解之一。

假设两个组件都调用 useMouse

jsx 复制代码
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 渲染,已经难以阅读。
  • 这段逻辑可以用一个明确名称描述,例如 useMouseuseOnlineStatususeTodos

自定义 Hook 也不等于把所有代码都塞进 hooks 目录。一个只执行普通字符串转换、数组排序或数学计算的函数,通常仍然应该是普通工具函数。

只有需要调用其他 React Hook,或者表达组件生命周期相关逻辑时,才需要自定义 Hook。

八、mousemove 高频触发,需要优化吗?

mousemove 触发频率很高,每次调用 setPosition 都可能带来一次组件更新。

在这个教学 demo 中,组件结构简单,直接更新状态足以说明 Hook 的工作方式,没有必要过早优化。

但如果鼠标坐标驱动的是复杂页面、图表或大量 DOM,可以继续考虑:

  • 使用 requestAnimationFrame 合并一帧内的多次更新。
  • 对事件进行节流。
  • 只在真正需要监听时挂载组件。
  • 避免让大量组件分别调用 useMouse

优化应建立在实际性能问题上,而不是看到高频事件就机械添加节流代码。

九、总结

自定义 Hook 的价值并不是少写几行代码,而是给一段有状态、有生命周期的逻辑建立清晰边界。

在这个鼠标坐标 demo 中:

  1. useState 保存最新坐标。
  2. useEffect 连接 React 与浏览器的 mousemove 事件。
  3. 清理函数在组件卸载时删除监听。
  4. useMouse 对外返回坐标,组件只负责渲染。

同时要记住:调用同一个自定义 Hook 只是复用逻辑,并不会让多个组件自动共享同一份状态。

一句话总结:普通函数复用计算,自定义 Hook 复用状态与副作用,Context 才负责让多个组件共享同一份数据。


相关推荐
SEO_juper几秒前
2026年用Python分析网站访问日志:看清Googlebot和AI爬虫怎么爬你的站(附完整代码)
开发语言·前端·seo·独立站·谷歌优化
摸鱼仙人~1 分钟前
React 原理进阶:彻底理解 re-render、React.memo、useMemo 与 useCallback
前端·vue.js
IMPYLH5 分钟前
HTML 的 <small> 元素
前端·网络·html
IT_陈寒6 分钟前
Vue的响应式让我加班到凌晨,问题竟出在这个不起眼的地方
前端·人工智能·后端
AlienZHOU9 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
Captaincc12 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师13 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen14 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒14 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
前端snow15 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端