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

0 阅读7分钟

在 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;
}

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

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

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

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 渲染,已经难以阅读。
  • 这段逻辑可以用一个明确名称描述,例如 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 才负责让多个组件共享同一份数据。