从零打造 React 自定义 Hooks:深入剖析 useMouse 与 useTodos 的完整实现

45 阅读11分钟

 引言

“抽象不是忽略细节,而是把细节封装在合适的地方。”
—— 软件工程箴言

在现代 React 开发中,自定义 Hook(Custom Hook) 已成为逻辑复用、状态管理和副作用封装的核心范式。它不仅让组件更轻量、更专注 UI,还极大提升了代码的可维护性与可测试性。

本文将基于完整项目文件,逐行、逐字、逐注释地深度解析两个极具代表性的自定义 Hook 实现:

  • useMouse:一个监听全局鼠标移动并实时返回坐标的交互型 Hook;
  • useTodos:一个管理待办事项列表、支持增删改查并自动持久化到 localStorage 的业务型 Hook。

我们将从底层原理出发,结合 React 的核心机制(如状态、副作用、不可变更新、惰性初始化等),带你彻底理解“如何写出专业级的自定义 Hook”。


第一部分:useMouse —— 让鼠标无所遁形的“追踪器”

目标

创建一个可复用的 Hook,能:

  1. 实时获取鼠标在页面中的坐标(pageX, pageY);
  2. 自动绑定/解绑事件监听器;
  3. 避免内存泄漏;
  4. 对外提供简洁 API。

文件路径:hooks/useMouse.js

我们先看完整代码:

import { 
  useState,   // 用于声明鼠标坐标的 x、y 状态
  useEffect   // 用于添加和清理鼠标移动事件监听器
} from 'react';

// 封装响应式mouse业务
// UI 组件更简单 HTML + CSS 好维护
// 复用 和组件一样,是前度团队的核心资产
export const useMouse = () => { 
  const [x, setX] = useState(0); // 声明 x 坐标状态,初始为 0
  const [y, setY] = useState(0); // 声明 y 坐标状态,初始为 0

  // 在组件挂载时添加全局 mousemove 监听,在卸载时移除(避免内存泄漏)
  useEffect(() => { 
    const update = (event) => {   // 事件处理函数:接收原生 MouseEvent
      setX(event.pageX);          // 更新 x 为鼠标相对于整个页面的横坐标
      setY(event.pageY);          // 更新 y 为鼠标相对于整个页面的纵坐标
    } 
    window.addEventListener('mousemove', update); // 注册事件监听器
    console.log(' 挂载');         // 调试日志:表示监听器已添加

    return () => {                // 清理函数:在组件卸载时执行
      console.log(' 清除');       // 调试日志:表示正在清理
      window.removeEventListener('mousemove', update); // 移除事件监听器,防止内存泄漏
    } 
  }, []) // 空依赖数组:仅在组件挂载/卸载时执行一次

  return { x, y } // 返回当前鼠标坐标
}

⚠️ 注意:原始文件中没有换行和缩进,但为了可读性,我们在解析时会适当格式化(但保留所有原始注释和代码内容)。


逐行深度解析

第 1 行:导入核心 Hooks

import { useState, useEffect } from 'react';
  • useState:用于声明组件内部的状态变量。在这里,我们用它来存储鼠标的 xy 坐标。
  • useEffect:用于处理副作用(side effects),比如订阅事件、发送网络请求、手动操作 DOM 等。这里用来注册/注销 mousemove 事件监听器。

💡 为什么不用 useRef
虽然 useRef 也可存坐标,但 useState 能触发组件 re-render,确保 UI 实时更新。而 useRef 不会触发更新,适合不需要渲染的场景。


第 2–4 行:函数声明与注释

// 封装响应式mouse业务
// UI 组件更简单 HTML + CSS 好维护
// 复用 和组件一样,是前度团队的核心资产
export const useMouse = () => {
  • 函数名以 use 开头,这是 React 的命名约定。React 会据此识别这是一个自定义 Hook,并启用相关规则(如只能在顶层调用)。
  • 注释强调了设计哲学:将复杂逻辑封装,让 UI 组件只关注展示
  • export 使得其他模块可以导入使用。

第 5–6 行:声明坐标状态

const [x, setX] = useState(0);
const [y, setY] = useState(0);
  • 初始化两个独立的状态:

    • x:鼠标横坐标,默认 0
    • y:鼠标纵坐标,默认 0
  • 每次调用 setXsetY,都会触发使用该 Hook 的组件重新渲染。

📌 技术细节:
useState 返回一个数组 [state, setState],通过解构赋值获取。这是 React 的标准模式。


第 7–18 行:使用 useEffect 管理事件监听

useEffect(() => {
  const update = (event) => {
    setX(event.pageX);
    setY(event.pageY);
  }

  window.addEventListener('mousemove', update);
  console.log(' 挂载');

  return () => {
    console.log(' 清除');
    window.removeEventListener('mousemove', update);
  }
}, [])

这是整个 Hook 的核心逻辑,我们拆解如下:

1. 定义事件处理器 update
const update = (event) => {
  setX(event.pageX);
  setY(event.pageY);
}
  • event 是原生 MouseEvent 对象。
  • event.pageX / event.pageY:返回鼠标相对于整个文档(包括滚动部分)的坐标,比 clientX/Y 更适合全局定位。
  • 每次鼠标移动,都会调用 setXsetY,触发状态更新 → 组件 re-render → UI 显示新坐标。
2. 添加事件监听器
window.addEventListener('mousemove', update);
console.log(' 挂载');
  • window 上监听 mousemove 事件(而非某个 DOM 元素),确保全页面有效。
  • 打印 ' 挂载'(注意前面有空格),用于调试:确认监听器是否成功注册。

❗ 为什么是 window 而不是 document
两者均可,但 window 是事件冒泡的最终目标,更稳定。且 mousemovewindow 上监听无兼容性问题。

3. 返回清理函数(Cleanup Function)
return () => {
  console.log(' 清除');
  window.removeEventListener('mousemove', update);
}
  • 这是防止内存泄漏的关键!
  • 当组件卸载(unmount)时,React 会自动调用此函数。
  • removeEventListener 必须传入同一个函数引用(即 update),否则无法正确移除。
  • 打印 ' 清除' 用于验证清理是否发生。

🧠 React 如何知道要清理?
useEffect 的返回值如果是函数,React 就将其视为 cleanup 函数,并在下次 effect 执行前(或组件卸载时)调用。

4. 依赖数组 []
}, [])
  • 空数组表示:该 effect 仅在组件挂载时执行一次,卸载时清理一次
  • 如果不加依赖数组,每次 re-render 都会重复绑定事件,导致性能问题和多个监听器同时触发!

第 19 行:返回坐标对象

return { x, y }
  • 返回一个包含当前坐标的对象。
  • 使用者可通过解构获取:const { x, y } = useMouse();
  • 这是自定义 Hook 的标准输出方式:返回状态 + 操作方法(此处只有状态)。

使用示例

虽然被注释掉了,但原代码展示了如何使用:

function MouseMove() {
  const { x, y } = useMouse();
  return (
    <>
      <div> 鼠标位置:{x} {y} </div>
    </>
  )
}

// 条件渲染
{count % 2 === 0 && <MouseMove />}
  • MouseMove 组件完全无状态,只负责展示。
  • 当组件挂载时,useMouse 自动开始监听;卸载时自动清理。
  • 完美解耦!

第二部分:useTodos —— 构建一个“永不丢失”的待办事项系统

如果说 useMouse 是“工具型” Hook,那么 useTodos 就是“业务型” Hook 的典范——它封装了完整的状态管理、持久化逻辑和 CRUD 操作。

目标

实现一个待办事项管理器,支持:

  • 添加新任务;
  • 切换完成状态;
  • 删除任务;
  • 自动保存到 localStorage
  • 页面刷新后恢复数据。

文件路径:hooks/useTodos.js

完整代码:

// 封装响应式todos业务
import { 
  useState,   // 用于声明和更新 todos 状态
  useEffect   // 用于在 todos 变化时同步到 localStorage
} from 'react';

const STORAGE_KEY = 'todos'; // 定义 localStorage 中存储数据的键名,便于统一维护

// 从 localStorage 加载 todos 数据的工具函数
function loadFromStorage() { 
  const storedTodos = localStorage.getItem(STORAGE_KEY); // 读取 localStorage 中的字符串
  return storedTodos ? JSON.parse(storedTodos) : [];     // 若存在则解析为数组,否则返回空数组
}

// 将 todos 数据保存到 localStorage 的工具函数
function saveToStorage(todos) { 
  localStorage.setItem(STORAGE_KEY, JSON.stringify(todos)); // 序列化 todos 并存入 localStorage
}

// 自定义 Hook:封装 todos 的完整状态逻辑
export const useTodos = () => { 
  // 使用惰性初始化:只在组件首次渲染时调用 loadFromStorage
  const [todos, setTodos] = useState(loadFromStorage); 

  // 当 todos 状态变化时,自动将其持久化到 localStorage
  useEffect(() => { 
    saveToStorage(todos); 
  }, [todos]) // 依赖项为 todos,确保每次 todos 更新都触发保存

  // 添加新待办事项
  const addTodo = (text) => { 
    setTodos( 
      [ 
        ...todos,                 // 保留原有 todos
        { 
          id: Date.now(),         // 使用时间戳作为简易唯一 ID(注意:高频率添加可能冲突)
          text,                   // 待办文本
          completed: false        // 初始状态为未完成
        } 
      ] 
    ) 
  }

  // 切换指定待办事项的完成状态
  const toggleTodo = (id) => { 
    setTodos( 
      todos.map(todo => {         // 遍历所有 todos
        if (todo.id === id) {     // 找到匹配 id 的项
          return { 
            ...todo,              // 保留其他属性
            completed: !todo.completed // 反转 completed 状态
          } 
        } 
        return todo;              // 其他项不变
      }) 
    ) 
  }

  // 删除指定 id 的待办事项
  const deleteTodo = (id) => { 
    setTodos( 
      todos.filter(todo => todo.id !== id) // 过滤掉指定 id 的项,返回新数组
    ) 
  }

  // 返回状态和操作方法,供组件使用
  return { 
    todos,       // 当前 todos 列表(响应式)
    addTodo,     // 添加函数
    toggleTodo,  // 切换完成状态函数
    deleteTodo   // 删除函数
  }
}

同样,我们进行逐行深度解析。


逐行深度解析

第 1 行:注释与导入

// 封装响应式todos业务
import { useState, useEffect } from 'react';
  • 注释点明目的:封装“响应式” todos 业务(即状态变化自动触发 UI 更新)。
  • 导入 useState(管理状态)和 useEffect(持久化数据)。

第 2 行:定义存储键名

const STORAGE_KEY = 'todos';
  • 将 localStorage 的 key 提取为常量,避免魔法字符串(magic string),提升可维护性。
  • 若将来要改名,只需改这一处。

第 3–6 行:加载数据函数

function loadFromStorage() {
  const storedTodos = localStorage.getItem(STORAGE_KEY);
  return storedTodos ? JSON.parse(storedTodos) : [];
}
  • localStorage.getItem(key) 返回字符串,若不存在则返回 null
  • 使用三元运算符:若存在则 JSON.parse 成数组,否则返回空数组 []
  • 注意:未处理 JSON.parse 异常(如存储的数据被篡改)。生产环境应加 try/catch

第 7–9 行:保存数据函数

function saveToStorage(todos) {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}
  • JSON.stringify 将数组序列化为字符串,存入 localStorage。
  • 简洁高效,但同样缺乏错误处理。

第 10 行:Hook 主体声明

export const useTodos = () => {
  • 标准自定义 Hook 声明。

第 11 行:惰性初始化状态

const [todos, setTodos] = useState(loadFromStorage);
  • 关键技巧:惰性初始化(Lazy Initialization)
  • useState 接收一个函数时,React 只在首次渲染时调用它。
  • 避免每次 re-render 都读取 localStorage(I/O 操作较慢)。
  • 对比:useState(loadFromStorage()) 会在每次 render 时调用函数,效率低下。

第 12–14 行:自动持久化

useEffect(() => {
  saveToStorage(todos);
}, [todos])
  • 依赖数组为 [todos],表示:只要 todos 引用发生变化,就执行 effect
  • setTodos 总是返回新数组(不可变更新),所以每次操作都会触发保存。
  • 实现“状态即持久化”的无缝体验

💡 为什么不用防抖?
对于待办事项这种低频操作,直接保存即可。高频场景(如搜索框)才需防抖。


第 15–25 行:添加待办事项

const addTodo = (text) => {
  setTodos([
    ...todos,
    {
      id: Date.now(),
      text,
      completed: false
    }
  ])
}
  • 使用展开运算符 ...todos 保留原数组。

  • 新 todo 对象包含:

    • id: Date.now():时间戳作为 ID(⚠️ 缺陷:1ms 内多次点击会冲突);
    • text:用户输入内容;
    • completed: false:初始未完成。
  • setTodos 触发状态更新 → 组件 re-render → 自动保存到 localStorage。

🛠️ 改进建议:
使用 crypto.randomUUID()(现代浏览器)或 nanoid 库生成真正唯一的 ID。


第 26–38 行:切换完成状态

const toggleTodo = (id) => {
  setTodos(
    todos.map(todo => {
      if (todo.id === id) {
        return { ...todo, completed: !todo.completed }
      }
      return todo;
    })
  )
}
  • 遍历 todos 数组,找到 id 匹配的项。
  • 使用 ...todo 保留原对象所有属性,仅翻转 completed 字段。
  • 严格遵守不可变更新原则:不修改原数组,而是返回新数组。

📌 为什么不用 for 循环?
map 是函数式编程风格,更安全、更易测试,且天然返回新数组。


第 39–42 行:删除待办事项

const deleteTodo = (id) => {
  setTodos(
    todos.filter(todo => todo.id !== id)
  )
}
  • filter 返回一个新数组,排除 id 匹配的项。
  • 同样符合不可变更新。

第 43–48 行:返回 API

return {
  todos,
  addTodo,
  toggleTodo,
  deleteTodo
}
  • 返回一个对象,包含:

    • 状态todos(当前列表);
    • 操作方法addTodo, toggleTodo, deleteTodo
  • 使用者可解构获取所需功能。


组件如何消费?—— 全链路解析

1. App.jsx:主入口

const { todos, addTodo, deleteTodo, toggleTodo } = useTodos();

return (
  <>
    <TodoInput onAddTodo={addTodo} />
    {todos.length > 0 ? (
      <TodoList onDelete={deleteTodo} onToggle={toggleTodo} todos={todos} />
    ) : (
      <div>暂无待办事项</div>
    )}
  </>
)
  • 主组件无任何业务逻辑,只做组合。
  • TodoInput 负责输入,TodoList 负责展示。

2. TodoInput.jsx:输入组件

const handleSubmit = (e) => {
  e.preventDefault();
  if (!text.trim()) return;
  onAddTodo(text.trim());
  setText("");
}
  • 阻止表单默认提交(防止刷新);
  • 过滤空输入;
  • 调用 onAddTodo(即 useTodosaddTodo);
  • 清空输入框。

3. TodoList.jsx:列表容器

{todos.map(todo => (
  <TodoItem
    key={todo.id}
    todo={todo}
    onDelete={onDelete}
    onToggle={onToggle}
  />
))}
  • 使用 todo.id 作为 key,确保列表高效更新。
  • todo 对象和回调函数传递给子项。

4. TodoItem.jsx:单个待办项

<input
  type="checkbox"
  checked={todo.completed}
  onChange={() => onToggle(todo.id)}
/>
<span className={todo.completed ? 'completed' : ''}>
  {todo.text}
</span>
<button onClick={() => onDelete(todo.id)}>删除</button>
  • 复选框状态由 todo.completed 控制;
  • 点击触发 onToggle(id)
  • 删除按钮触发 onDelete(id)
  • 完成状态通过 CSS 类名控制样式(如划线)。

总结:自定义 Hook 的设计哲学

通过这两个例子,我们可以提炼出自定义 Hook 的黄金法则

原则useMouseuseTodos
单一职责只管鼠标坐标只管待办事项
状态封装x, ytodos
副作用隔离事件监听/清理localStorage 同步
不可变更新N/A所有操作返回新数组
惰性初始化N/AuseState(loadFromStorage)
清晰 API{ x, y }{ todos, addTodo, ... }
自动清理useEffect cleanup无需清理(localStorage 无副作用)

结语:你也可以成为 Hook 魔法师!

自定义 Hook 不是高级技巧,而是每个 React 开发者都应该掌握的基础能力。它让你:

  • 告别重复代码;
  • 提升组件可读性;
  • 降低 bug 概率;
  • 加速团队协作。

下次当你写完一段逻辑,发现“这段代码好像别的地方也能用”,就大胆地把它抽成 useXXX 吧!

记住:好的抽象,不是让代码变少,而是让复杂消失。


📅 写于 2025 年 12 月 31 日,跨年夜的最后一行代码,献给热爱前端的你。
✨ Happy Coding, and Happy New Year!

完整项目链接:lesson_zp/react/hand_hooks/hooks-demo: AI + 全栈学习仓库 - Gitee.com

完整项目结构: