React 自定义 Hooks 实战:用 useMouse 理解响应式封装

2 阅读5分钟

React 自定义 Hooks 实战:用 useMouse 理解响应式封装

前言

在 React 生态中,组件通信与逻辑复用是两个核心命题。组件通信涵盖了父子单向数据流、兄弟关系、跨层级传递的痛点;useContext 作为上下文机制解决了深层嵌套的传值问题;而自定义 Hooks 则从另一个维度切入 —— 它关注的不是"数据怎么传",而是"逻辑怎么复用"。

本文以项目中的 useMouse 为例,拆解自定义 Hook 的设计思路:如何将一个涉及 DOM 事件、副作用、响应式状态的场景,抽象成一个干净、可复用的 Hook。


组件通信的"问题地图"

在 React 中,组件通信是一个层级分明的体系:

  • 父子通信:单向数据流,靠 props 逐层下传,适合简单场景。
  • 兄弟通信:需要共同的父组件做中转,或者借助状态管理。
  • 爷孙 / 陌生关系:层层传递既啰嗦又低效,useContext 跨越层级直接消费数据。
  • 上下文:三步走 —— createContext → Provider 包裹 → useContext 消费。

这是"数据在哪里"的答案。但还有另一个问题:"能力在哪里"。比如,监听鼠标位置这件事,难道要把它写在组件里、然后靠 props 或 context 到处传递吗?

答案是:自定义 Hooks。


自定义 Hooks 的设计哲学

一个合格的自定义 Hook 有三条朴素的标准:

  1. 以 use 开头 —— 这是 React 的约定,让 linter 能校验 Hook 规则。
  2. 放在 hooks 目录下 —— 属于架构层,不是某个组件的附属品。
  3. 封装了响应式与副作用 —— 这是 Hook 的本质价值。

这三条构成了自定义 Hook 的"一面旗帜":它不是一个普通函数,而是一个有生命周期感知、能持有状态、且可跨组件复用的逻辑单元。

而 useMouse 恰好是一个教科书级的例子。


拆解 useMouse:一个完整的 Hook 闭环

import { useState, useEffect } from 'react';

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

逐层拆解:

1. 状态声明 —— 响应式的起点

const [x, setX] = useState(null);
const [y, setY] = useState(null);

初始值为 null,这是一个语义信号:尚未获取到鼠标位置。调用方可以根据 x 是否为 null 来决定展示"等待状态"还是"坐标数据",这正是"响应式的鼠标坐标"的含义——坐标值随鼠标移动自动更新,组件只需读取即可。

2. 副作用注册与回收 —— 生命周期的闭环

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

这里有三层设计细节:

  • 事件注册:在 useEffect 中绑定 document 级别的 mousemove,保证全局监听。
  • 清理函数:return 的函数在组件卸载时执行,移除事件监听。注释中提到"定时器、worker、事件需要手动回收"——这是 React 开发中极易被忽视的内存泄漏点。useEffect 的清理机制是防止这类泄漏的标准范式。
  • 空依赖数组 []:表示此 effect 只在组件挂载和卸载时执行一次,事件处理函数 handleMouseMove 通过闭包稳定引用 setX / setY(useState 返回的 setter 本身是稳定的)。

3. 暴露接口 —— 最小化契约

return { x, y }

Hook 只向外暴露调用方真正需要的两个字段,不暴露内部的 setX / setY(调用方本身也不应该手动设置鼠标坐标)。这是一种封装:内部的状态变更完全由副作用驱动,外部只读。


消费端:一个干净的 App 组件

import { useMouse } from './hooks/useMouse';

function App() {
  const { x, y } = useMouse()
  return (
    <div style={{ height: '100vh', display: 'flex',
      alignItems: 'center', justifyContent: 'center' }}>
      {x && y ? `x: ${x}, y: ${y}` : '鼠标未移动'}
    </div>
  )
}

这里的优雅体现在几个方面:

  1. 一行接入:const { x, y } = useMouse(),没有 props 传递,没有 context 包裹,任何组件需要鼠标位置时,直接调用即可。
  2. 状态驱动的 UI:利用 x && y 的短路求值做条件渲染——初始状态(null)显示"鼠标未移动",移动后实时展示坐标。这是 React 声明式 UI 思想的自然延伸。
  3. 关注点分离:App 组件不需要知道 DOM 事件如何绑定、如何解绑、坐标如何获取。它只关心"我有鼠标数据"和"我怎么渲染它"。

从 useMouse 到更广阔的 Hooks 图景

回顾整个设计,可以画出一条清晰的逻辑链:

层次内容对应文件
理论框架组件通信体系 & 自定义 Hooks 规范项目文档
架构封装将鼠标监听抽象为可复用 Hookhooks/useMouse.js
界面消费用 Hook 驱动视图渲染App.jsx

这正是 React 自定义 Hooks 的核心价值:把"怎么做"封装在 Hook 里,让组件只管"是什么"。

当你遇到类似场景 —— 窗口尺寸监听、网络状态检测、表单逻辑、定时器操作 —— 都可以套用 useMouse 的模式:用 useState 持有响应式状态,用 useEffect 管理副作用的注册与回收,暴露最小接口,放入 hooks 目录作为架构资产。


小结

useMouse 只有 20 行代码,但它完整示范了自定义 Hook 的最佳实践:状态、副作用、清理、封装、复用。三个关键词——"响应式"、"抽象"、"封装"——贯穿始终。

写好自定义 Hook 的秘诀不在于复杂度,而在于把一件具体的事情封装干净。当一个 Hook 的调用方只需要一行解构就能获得它需要的全部能力时,你就做对了。