React 自定义 Hook 实战:从鼠标追踪到待办事项管理

在现代前端开发中,React 的自定义 Hook 已成为提升代码复用性、逻辑抽象能力和可维护性的核心手段。本文将通过两个典型示例------鼠标位置追踪本地存储的待办事项管理,深入剖析如何利用自定义 Hook 将复杂业务逻辑封装为可复用、可测试、高内聚的模块,并探讨其背后的 React 响应式编程思想。


一、Demo 1:使用 useMouse 封装鼠标位置追踪

1.1 初始实现的问题

最初,开发者可能直接在组件内部使用 useStateuseEffect 来监听鼠标移动事件:

scss 复制代码
function MouseMove() {
  const [x, setX] = useState(0);
  const [y, setY] = useState(0);

  useEffect(() => {
    const update = (event) => {
      setX(event.pageX);
      setY(event.pageY);
    };
    window.addEventListener('mousemove', update);
    return () => {
      window.removeEventListener('mousemove', update);
    };
  }, []);

  return <div>鼠标位置: {x} {y}</div>;
}

这种写法虽然功能完整,但存在明显缺陷:

  • 逻辑耦合:UI 渲染与事件监听混杂,组件职责不清;
  • 难以复用:若其他组件也需要获取鼠标坐标,需重复编写相同逻辑;
  • 潜在内存泄漏风险 :若未正确清理事件监听器(如忘记返回清理函数),组件卸载后仍会执行回调,事件监听/定时器 不会因为函数组件卸载而自动销毁,当卸载组件后又开启组件,相当于是又进行了一次事件监听,多次重复导致内存泄漏。

1.2 提炼为自定义 Hook:useMouse

为解决上述问题,我们将鼠标追踪逻辑提取至独立的 useMouse Hook:

javascript 复制代码
// src/hooks/useMouse.js
import { useState, useEffect } from "react";

export const useMouse = () => {
  const [x, setX] = useState(0);
  const [y, setY] = useState(0);

  useEffect(() => {
    const update = (event) => {
      setX(event.pageX);
      setY(event.pageY);
    };
    window.addEventListener('mousemove', update);
    return () => {
      window.removeEventListener('mousemove', update);
    };
  }, []);
   
  return { x, y };
  // 把要向外暴露的状态和方法返回
};

关键设计点解析:

  1. 状态封装
    使用 useState 管理 xy 坐标,对外仅暴露只读值,避免外部直接修改状态。
  2. 副作用隔离
    useEffect 负责添加/移除全局事件监听器。依赖数组为空([]),确保仅在组件挂载时注册一次监听器,并在卸载时自动清理,彻底规避内存泄漏。
  3. 单一职责原则
    useMouse 只关注"获取鼠标位置"这一核心能力,不涉及任何 UI 渲染或业务判断,高度内聚。
  4. 可组合性
    返回对象 { x, y },便于在任意组件中解构使用,符合 React 的声明式风格。

1.3 在组件中使用

App.jsx 中,只需一行代码即可接入鼠标位置数据:

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

此时 MouseMove 组件完全退化为纯展示层,逻辑与视图分离,极大提升了可读性和可维护性。


二、Demo 2:构建完整的待办事项系统 ------ useTodos

相比鼠标追踪,待办事项管理涉及状态管理、持久化存储等多个维度,是检验自定义 Hook 能力的绝佳场景。

2.1 整体架构拆解

整个系统由以下部分组成:

  • Hook 层useTodos ------ 核心逻辑容器

  • 组件层

    • TodoInput:输入新任务
    • TodoList:渲染任务列表
    • TodoItem:单个任务项(含完成状态切换与删除)

这种分层结构体现了典型的"逻辑下沉,UI 上浮"原则:复杂状态流转由 Hook 处理,组件仅负责调用方法与展示数据。

2.2 useTodos Hook 深度解析

ini 复制代码
// src/hooks/useTodos.js
import { useState, useEffect } from 'react';

const STORAGE_KEY = 'todos';

function loadFromStorage() {
  const stored = localStorage.getItem(STORAGE_KEY);
  return stored ? JSON.parse(stored) : [];
}

function saveToStorage(todos) {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}

export const useTodos = () => {
  const [todos, setTodos] = useState(loadFromStorage);

  useEffect(() => {
    saveToStorage(todos);
  }, [todos]);
  // todos改变 进行本地存储

  const addTodo = (text) => {
    setTodos([
      ...todos,
      { id: Date.now(), text, completed: false }
    ]);
  };

  const toggleTodo = (id) => {
    setTodos(
      todos.map(todo =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  return { todos, addTodo, toggleTodo, deleteTodo };
};

核心机制详解:

(1)初始化与持久化同步
  • 延迟初始化useState(loadFromStorage) 利用函数式初始化,避免每次渲染都读取 localStorage,提升性能。
  • 自动持久化useEffect 监听 todos 变化,一旦状态更新立即写入 localStorage,实现"状态即存储"的无缝体验。

注意:此处使用 Date.now() 作为 ID 虽简便,但在高频操作下可能冲突。

(2)不可变更新原则

所有状态变更均通过创建新数组实现:

  • addTodo:使用展开运算符 [...todos, newTodo]
  • toggleTodomap 返回新数组,仅修改目标项
  • deleteTodofilter 排除指定 ID 项

这保证了 React 能正确触发重渲染,同时避免意外修改原始状态。

(3)API 设计清晰

返回对象包含:

  • 状态todos(当前任务列表)
  • 行为addTodo, toggleTodo, deleteTodo(纯函数,无副作用)

调用者无需关心内部实现,只需按约定传参即可操作状态。

2.3 组件层协作流程

TodoInput:任务创建入口

javascript 复制代码
// src/components/TodoInput.jsx
export default function TodoInput({ onAddTodo }) {
  const [text, setText] = useState('');
  
  const handleSubmit = (e) => {
    e.preventDefault();
    if (!text.trim()) return;
    onAddTodo(text.trim());
    setText('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input 
        type="text"
        value={text}
        onChange={e => setText(e.target.value)}
      />
    </form>
  );
}
  • 通过 onAddTodo 回调将新任务文本传递给父组件(即 useTodos.addTodo
  • 表单提交后清空输入框,提供良好 UX

TodoListTodoItem:状态展示与交互

javascript 复制代码
// TodoList.jsx
export default function TodoList({ todos, onDelete, onToggle }) {
  return (
    <ul>
      {todos.map(todo => (
        <TodoItem 
          key={todo.id}
          todo={todo}
          onDelete={onDelete}
          onToggle={onToggle}
        />
      ))}
    </ul>
  );
}

// TodoItem.jsx
export default function TodoItem({ todo, onDelete, onToggle }) {
  return (
    <li>
      <input 
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
      />
      <span className={todo.completed ? 'completed' : ''}>
        {todo.text}
      </span>
      <button onClick={() => onDelete(todo.id)}>Delete</button>
    </li>
  );
}
  • 单向数据流todosApp 传入,TodoItem 仅消费数据
  • 事件委托 :点击复选框或删除按钮时,调用 onToggle / onDelete,最终触发 useTodos 内部状态更新
  • Key 唯一性 :使用 todo.id 作为 key,确保 React Diff 算法高效更新列表

2.4 App 组件:胶水层整合

javascript 复制代码
// App.jsx
export default function App() {
  const { todos, addTodo, deleteTodo, toggleTodo } = useTodos();

  return (
    <>
      <TodoInput onAddTodo={addTodo} />
      {todos.length > 0 ? (
        <TodoList 
          todos={todos} 
          onDelete={deleteTodo} 
          onToggle={toggleTodo} 
        />
      ) : (
        <div>暂无待办事项</div>
      )}
    </>
  );
}

App 组件几乎不包含业务逻辑,仅负责:

  1. 调用 useTodos 获取状态与方法
  2. 将方法作为 props 传递给子组件
  3. 根据 todos.length 控制空状态显示

这种"瘦容器"模式使应用结构清晰,易于扩展(例如未来加入筛选、编辑等功能)。


三、自定义 Hook 的价值与最佳实践

3.1 为什么需要自定义 Hook?

  • 逻辑复用:跨组件共享状态逻辑
  • 关注点分离:将副作用、数据获取、状态管理从 UI 组件中剥离
  • 测试友好:Hook 可独立于组件进行单元测试
  • 团队协作:形成可沉淀的"前端资产库",新人可快速接入

3.2 编写高质量 Hook 的准则

  1. 命名规范 :以 use 开头(如 useTodos),这是 React 的约定,也是 ESLint 规则的要求。
  2. 返回结构清晰:通常返回对象,便于按需解构;避免返回数组导致顺序依赖。
  3. 避免副作用外泄:Hook 内部处理所有订阅/清理,调用者无需关心生命周期。
  4. 考虑性能优化 :对返回的函数使用 useCallback 包裹(本例因简单省略,复杂场景需注意)。

结语

通过 useMouseuseTodos 两个案例,我们见证了自定义 Hook 如何将"面条式代码"转化为模块化、可维护的现代 React 应用。它不仅是语法糖,更是一种架构思维------鼓励开发者将复杂问题分解为独立、可组合的逻辑单元。

在实际项目中,你可以继续延伸这一模式:封装网络请求(useFetch)、表单验证(useForm)、主题切换(useTheme)等通用能力。当你的 Hook 库逐渐丰富,你会发现:优秀的前端工程,始于对逻辑的敬畏,成于对复用的追求

本文所有代码均可直接运行,建议读者动手实践,尝试为 useTodos 添加"编辑任务"或"按状态筛选"功能,进一步巩固自定义 Hook 的设计能力。

相关推荐
松涛和鸣6 小时前
DAY43 HTML Basics
linux·前端·网络·网络协议·tcp/ip·html
前端 贾公子6 小时前
剖析源码Vue项目结构 (一)
前端·javascript·vue.js
狂龙骄子7 小时前
jQuery表单验证插件全攻略
前端·javascript·jquery·jquery表单验证
forestsea7 小时前
从 XMLHttpRequest 到 Fetch API:现代前端网络请求的演进与迁移指南
前端·网络
XiaoYu20027 小时前
第4章 Nest.js业务合并
前端
局i7 小时前
【无标题】
前端·javascript·vue.js
沛沛rh457 小时前
React入门:从一个简单的Hello World开始
前端·react.js·前端框架
IT_陈寒8 小时前
SpringBoot性能翻倍秘籍:5个被低估的配置项让我QPS提升200%
前端·人工智能·后端
阿珊和她的猫9 小时前
Webpack 常用插件深度解析
前端·webpack·node.js