🌟 从零彻底掌握 React 自定义 Hook:深度解析 useMouse 与 useTodos 的完整开发思维与逐行代码详解

52 阅读10分钟

🌟 从零彻底掌握 React 自定义 Hook:深度解析 useMouse 与 useTodos 的完整开发思维与逐行代码详解

这不是一篇“速成教程”,而是一次完整的、贴近真实工程实践的代码构建之旅。
我们将从用户需求出发,一步步思考、设计、实现、优化,并深入解释每一行代码背后的原理、权衡与最佳实践。


🧭 引言:为什么你需要理解“写代码的思路”?

很多初学者学 React 时,只是机械地复制 useStateuseEffect 的用法,却不知道:

  • 为什么状态要这样初始化?
  • 为什么副作用必须清理?
  • 为什么不能直接修改数组?
  • 为什么要把逻辑抽成 Hook?

真正的编程能力,不在于记住语法,而在于“如何思考问题”。

本文将以两个经典场景——鼠标位置追踪待办事项管理为例,带你完整走一遍专业前端工程师的开发流程:

  1. 明确需求
  2. 拆解问题
  3. 设计状态结构
  4. 处理副作用与生命周期
  5. 封装可复用逻辑
  6. 防御性编程与错误处理
  7. 性能与用户体验优化

每一步都配有详细注释 + 原理解析 + 常见陷阱,确保你不仅会写,更知道“为什么这样写”。


🖱️ 第一部分:useMouse —— 安全监听鼠标位置(含内存泄漏防范)

🔍 第一步:明确需求

  • 实时获取鼠标的 X 和 Y 坐标;
  • 坐标变化时,UI 自动更新;
  • 组件卸载后,不能留下任何监听器(防止内存泄漏);
  • 代码可复用,多个组件能同时使用。

🧱 第二步:设计状态与副作用

要素决策
状态需要两个值:x 和 y → 用两个 useState
副作用来源全局事件 window.mousemove
副作用时机组件挂载时添加,卸载时移除
依赖关系无需依赖外部变量 → 依赖数组为 []

✍️ 第三步:编写代码(逐行深度解析)

// 文件路径: src/hooks/useMouse.js
import { useState, useEffect } from 'react';

/**
 * 自定义 Hook:安全地追踪鼠标在页面中的实时位置
 * 
 * 返回值:
 *   { x: number, y: number }
 * 
 * 特性:
 *   - 自动绑定/解绑 mousemove 事件
 *   - 防止内存泄漏
 *   - 支持多实例共存(每个组件独立)
 */
export const useMouse = () => {
  // ▼▼▼ 状态声明 ▼▼▼
  
  /**
   * 使用 useState 声明两个响应式状态
   * 初始值设为 0,表示页面左上角
   * 
   * 注意:这里不需要函数式初始化,
   * 因为初始值是常量,不涉及计算或 I/O
   */
  const [x, setX] = useState(0);
  const [y, setY] = useState(0);

  // ▼▼▼ 副作用管理 ▼▼▼
  
  /**
   * useEffect 用于处理“副作用”(side effect)
   * 这里指:添加全局事件监听器
   * 
   * 为什么放在这里?
   *   - 函数组件本身没有生命周期方法(如 componentDidMount)
   *   - useEffect 是 React 提供的“做事情”的入口
   */
  useEffect(() => {
    /**
     * 定义事件处理函数
     * 
     * 为什么不在 useEffect 外面定义?
     *   - 如果在外面定义,它无法访问当前的 setX/setY(闭包问题)
     *   - 如果在里面定义,每次 effect 重新运行都会创建新函数(但这里依赖是 [],只运行一次)
     */
    const handleMouseMove = (event) => {
      /**
       * event.pageX / pageY 是相对于整个 HTML 文档的坐标
       * 包含滚动偏移,适合大多数 UI 场景
       * 
       * 对比:
       *   clientX/clientY → 相对于视口(不含滚动)
       *   screenX/screenY → 相对于屏幕
       */
      setX(event.pageX);
      setY(event.pageY);
    };

    /**
     * 添加事件监听器到 window
     * 
     * 为什么是 window 而不是 document 或 body?
     *   - mousemove 在 window 上能捕获整个页面的移动
     *   - 更可靠,尤其在全屏或复杂布局中
     */
    window.addEventListener('mousemove', handleMouseMove);

    /**
     * 返回一个“清理函数”(cleanup function)
     * 
     * React 会在以下时机调用它:
     *   1. 组件卸载(unmount)时
     *   2. 下一次 useEffect 执行前(如果依赖变化)
     * 
     * 本例中依赖是 [],所以只在卸载时调用
     * 
     * 清理目的:
     *   - 移除事件监听器,避免内存泄漏
     *   - 防止对已卸载组件调用 setState(React 会警告)
     */
    return () => {
      window.removeEventListener('mousemove', handleMouseMove);
    };

    /**
     * 依赖数组:[]
     * 表示该 effect 只在组件首次挂载时运行一次
     * 不会因为 x/y 变化而重复绑定监听器
     * 
     * 如果忘记写 [],会导致:
     *   - 每次渲染都添加新监听器
     *   - 越绑越多,性能下降,内存暴涨
     */
  }, []);

  // ▼▼▼ 暴露 API ▼▼▼
  
  /**
   * 返回一个对象,包含当前坐标
   * 
   * 为什么返回对象而不是数组?
   *   - 对象支持命名解构,语义更清晰:
   *       const { x, y } = useMouse();
   *   - 比 const [a, b] = useMouse() 更易读
   */
  return { x, y };
};

⚠️ 常见错误与陷阱分析

错误写法后果正确做法
忘记 return () => removeEventListener内存泄漏 + React 警告必须清理
依赖数组写成 [x, y]每次坐标变就重新绑定监听器依赖应为 []
在组件外定义 handleMouseMove无法访问最新 setX/setY在 useEffect 内定义
使用 clientX 而非 pageX滚动后坐标不准根据需求选 pageX/Y

📋 第二部分:useTodos —— 带持久化的待办事项管理(含错误处理与不可变更新)

🔍 第一步:明确需求

  • 支持添加、删除、切换任务完成状态;
  • 任务数据刷新后不丢失
  • 所有操作通过函数暴露,UI 组件只负责渲染;
  • 代码健壮,能处理 localStorage 异常;
  • 符合 React 不可变更新原则。

🧱 第二步:状态与数据流设计

数据类型存储方式初始化来源
todos{ id: number, text: string, completed: boolean }[]React 状态 (useState)localStorage 或空数组
持久化字符串localStorage每次 todos 变化时自动保存

💡 关键原则

  • 状态单一来源:所有数据以 todos 状态为准;
  • 单向数据流:UI → 调用操作函数 → 更新状态 → 自动保存 → UI 重绘。

✍️ 第三步:编写代码(超详细逐行解析)

// 文件路径: src/hooks/useTodos.js
import { useState, useEffect } from 'react';

/**
 * 定义 localStorage 的 key
 * 使用常量避免“魔法字符串”(magic string)
 * 便于维护和避免拼写错误
 */
const STORAGE_KEY = 'react_todos_v1';

/**
 * 从 localStorage 加载待办事项
 * 
 * 封装为独立函数的原因:
 *   - 逻辑复用(初始化 + 错误处理)
 *   - 单一职责:只负责“读”
 *   - 易于测试
 */
function loadFromStorage() {
  try {
    /**
     * localStorage.getItem 返回字符串或 null
     * 如果从未保存过,返回 null
     */
    const saved = localStorage.getItem(STORAGE_KEY);
    
    if (saved === null) {
      return []; // 首次使用,返回空数组
    }

    /**
     * JSON.parse 将字符串转为 JS 对象
     * 注意:可能抛出 SyntaxError(如数据被篡改)
     */
    const parsed = JSON.parse(saved);

    /**
     * 额外校验:确保是数组
     * 防止旧版本数据格式不兼容
     */
    if (!Array.isArray(parsed)) {
      console.warn('Invalid todos data in localStorage, resetting to empty array');
      return [];
    }

    return parsed;
  } catch (error) {
    /**
     * 捕获所有异常:
     *   - JSON 解析失败
     *   - localStorage 被禁用(如无痕模式)
     *   - 存储空间不足
     * 
     * 不让错误中断程序,而是降级处理
     */
    console.error('Failed to load todos from localStorage:', error);
    return []; // 安全兜底
  }
}

/**
 * 保存待办事项到 localStorage
 * 
 * 同样封装为函数,便于复用和错误处理
 */
function saveToStorage(todos) {
  try {
    /**
     * JSON.stringify 将数组转为字符串
     * 注意:函数、undefined、Symbol 会被忽略
     * 我们的 todo 对象只有基本类型,安全
     */
    const serialized = JSON.stringify(todos);
    localStorage.setItem(STORAGE_KEY, serialized);
  } catch (error) {
    /**
     * 可能原因:
     *   - 超出存储配额(QuotaExceededError)
     *   - 用户禁用 localStorage
     * 
     * 此处仅记录日志,不影响主流程
     * (高级做法:可弹出提示“本地存储已满”)
     */
    console.error('Failed to save todos to localStorage:', error);
  }
}

/**
 * 自定义 Hook:管理待办事项列表
 * 
 * 返回值:
 *   {
 *     todos: Array,
 *     addTodo: (text: string) => void,
 *     toggleTodo: (id: number) => void,
 *     deleteTodo: (id: number) => void
 *   }
 */
export const useTodos = () => {
  /**
   * 初始化状态:使用函数式初始化
   * 
   * 为什么用函数?
   *   - useState(initialValue) 中,如果 initialValue 是函数,
   *     React 会在首次渲染时调用它,并缓存结果
   *   - 避免每次渲染都执行 loadFromStorage(性能优化)
   *   - 首屏直接显示数据,无闪烁
   */
  const [todos, setTodos] = useState(loadFromStorage);

  /**
   * 自动持久化:监听 todos 变化,同步到 localStorage
   * 
   * 依赖数组 [todos]:
   *   - 当 todos 引用变化时,触发保存
   *   - 因为我们总是创建新数组(不可变更新),引用必然变化
   */
  useEffect(() => {
    saveToStorage(todos);
  }, [todos]);

  /**
   * 添加新任务
   * 
   * 参数 text: 用户输入的文本
   */
  const addTodo = (text) => {
    /**
     * 防御性编程:
     *   - 检查 text 是否存在(防止传入 undefined)
     *   - trim() 去除首尾空格
     *   - 空字符串不添加
     */
    if (!text || !text.trim()) {
      return;
    }

    /**
     * 创建新任务对象
     * 
     * ID 生成策略:
     *   - Date.now() 简单有效,但极端情况下可能重复(如快速连点)
     *   - 生产环境建议用 nanoid、uuid 等库
     */
    const newTodo = {
      id: Date.now(),
      text: text.trim(),
      completed: false,
      // 可扩展字段:
      // createdAt: new Date().toISOString(),
      // updatedAt: new Date().toISOString()
    };

    /**
     * 不可变更新:创建新数组
     * 
     * 为什么不用 setTodos([...todos, newTodo])?
     *   - 如果 addTodo 被缓存(如 useCallback),todos 可能是旧闭包
     *   - 使用函数式更新(prev => [...])确保拿到最新状态
     */
    setTodos(prev => [...prev, newTodo]);
  };

  /**
   * 切换任务完成状态
   */
  const toggleTodo = (id) => {
    /**
     * 使用函数式更新 + map
     * 
     * map 遍历每个任务:
     *   - 如果 id 匹配,返回新对象(翻转 completed)
     *   - 否则原样返回
     * 
     * {...todo} 确保不修改原对象(不可变)
     */
    setTodos(prev =>
      prev.map(todo =>
        todo.id === id
          ? { ...todo, completed: !todo.completed }
          : todo
      )
    );
  };

  /**
   * 删除任务
   */
  const deleteTodo = (id) => {
    /**
     * filter 创建新数组,排除匹配 id 的任务
     * 同样使用函数式更新保证状态最新
     */
    setTodos(prev =>
      prev.filter(todo => todo.id !== id)
    );
  };

  /**
   * 返回 API
   * 
   * 注意顺序:状态在前,操作函数在后(常见约定)
   */
  return {
    todos,
    addTodo,
    toggleTodo,
    deleteTodo
  };
};

🔍 关键概念深度解析

1. 为什么必须“不可变更新”?

React 使用 Object.is() 比较状态是否变化。
如果你直接修改原数组:

// ❌ 错误!
todos[0].completed = true;
setTodos(todos); // 引用没变,React 认为“没更新”,不重绘!

✅ 正确做法:创建新数组/对象,让引用变化:

// ✅ 正确
setTodos(prev => prev.map(...)); // 新数组,引用不同
2. 函数式更新(setTodos(prev => ...))的优势

假设你有如下代码:

const add1 = () => setCount(count + 1);
const add2 = () => setCount(count + 1);

如果快速点击两次,可能两次都基于同一个 count 值(比如 0),结果只加了 1。

而用函数式更新:

const add1 = () => setCount(prev => prev + 1);
const add2 = () => setCount(prev => prev + 1);

React 会按顺序传递最新值,确保结果正确(+2)。

3. localStorage 的局限性
  • 容量限制:通常 5~10MB,超限会报错;
  • 同步阻塞:大量数据读写会卡主线程;
  • 隐私模式:某些浏览器会禁用;
  • 跨设备不同步:只在当前设备生效。

✅ 适用于小型应用(如待办事项、主题设置)。
❌ 不适用于大型数据或需要同步的场景(应使用后端 API)。


🧩 第四部分:在组件中使用 —— UI 与逻辑彻底分离

App.jsx 示例

// src/App.jsx
import { useState } from 'react';
import { useTodos } from './hooks/useTodos';
import { useMouse } from './hooks/useMouse';

export default function App() {
  const [inputValue, setInputValue] = useState('');
  
  // 获取待办事项逻辑
  const { todos, addTodo, toggleTodo, deleteTodo } = useTodos();
  
  // 获取鼠标位置
  const { x, y } = useMouse();

  const handleSubmit = (e) => {
    e.preventDefault();
    if (inputValue.trim()) {
      addTodo(inputValue);
      setInputValue('');
    }
  };

  return (
    <div style={{ padding: '20px', fontFamily: 'Arial' }}>
      {/* 输入区域 */}
      <form onSubmit={handleSubmit} style={{ marginBottom: '20px' }}>
        <input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          placeholder="输入新任务..."
          style={{ padding: '8px', marginRight: '8px' }}
        />
        <button type="submit" style={{ padding: '8px 16px' }}>
          添加
        </button>
      </form>

      {/* 任务列表 */}
      {todos.length > 0 ? (
        <ul style={{ listStyle: 'none', padding: 0 }}>
          {todos.map(todo => (
            <li
              key={todo.id}
              style={{
                display: 'flex',
                alignItems: 'center',
                padding: '8px',
                borderBottom: '1px solid #eee'
              }}
            >
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => toggleTodo(todo.id)}
                style={{ marginRight: '12px' }}
              />
              <span
                style={{
                  flex: 1,
                  textDecoration: todo.completed ? 'line-through' : 'none',
                  color: todo.completed ? '#888' : '#000'
                }}
              >
                {todo.text}
              </span>
              <button
                onClick={() => deleteTodo(todo.id)}
                style={{
                  background: '#ff4d4d',
                  color: 'white',
                  border: 'none',
                  padding: '4px 8px',
                  borderRadius: '4px',
                  cursor: 'pointer'
                }}
              >
                删除
              </button>
            </li>
          ))}
        </ul>
      ) : (
        <p style={{ color: '#888' }}>暂无待办事项</p>
      )}

      {/* 鼠标位置显示 */}
      <div
        style={{
          position: 'fixed',
          top: '10px',
          right: '10px',
          background: '#f0f0f0',
          padding: '8px',
          borderRadius: '4px',
          fontSize: '14px'
        }}
      >
        鼠标位置: ({x}, {y})
      </div>
    </div>
  );
}

🧠 总结:专业开发者的完整思维链

阶段关键动作工具/原则
1. 需求分析列出功能点、数据流、边界情况用户故事、用例图
2. 状态设计确定状态结构、初始值、更新方式单一数据源、不可变
3. 副作用处理识别 I/O、监听、定时器useEffect + 清理
4. 封装抽象提取通用逻辑为自定义 Hook复用、关注点分离
5. 健壮性处理异常、输入校验、兜底防御性编程
6. 性能优化避免重复计算、减少重渲染函数式更新、依赖数组
7. 可维护性命名清晰、注释完整、模块化Clean Code 原则

💡 最后建议:如何真正掌握?

  1. 亲手敲一遍代码,不要复制粘贴;
  2. 故意制造错误:删掉清理函数、改错依赖数组,看控制台报什么;
  3. 扩展功能:给 useTodos 加“编辑任务”、“清空已完成”;
  4. 阅读源码:看看 react-useahooks 等库是怎么写的;
  5. 教给别人:试着向朋友解释 useMouse 为什么需要清理。

编程不是记忆,而是理解 + 实践。

你现在拥有的,不只是两段代码,而是一套可迁移的工程思维
用它去构建更复杂的 Hook 吧!🚀