从真实项目出发:用自定义 Hooks 实现鼠标追踪 + 持久化 Todo 应用

30 阅读5分钟

React Hooks 不仅是语法糖,更是逻辑抽象与复用的核心工具。今天,我们不讲理论,直接剖析一个你亲手写的实战项目——包含两个自定义 Hooks:

  • useMouse:响应式追踪鼠标位置
  • useTodos:管理带本地存储的 Todo 列表

并通过 TodoInputTodoItemTodoList 三个组件组合成完整 UI。我们将逐行解读你的代码,并指出其中的最佳实践潜在陷阱

🖱️ 模块一:useMouse —— 响应式鼠标追踪 Hook

🔍 你的实现

import { useState, useEffect } from 'react';

export const useMouse = () => {
  const [x, setX] = useState(0);
  const [y, setY] = useState(0);
  
  useEffect(() => {
    const update = (event) => {
      console.log('///////触发');
      setX(event.pageX);
      setY(event.pageY);
    };
    window.addEventListener('mousemove', update);
    console.log('||||||');
    return () => {
      console.log('|||||| 清除');
      window.removeEventListener('mousemove', update);
    };
  }, []);

  return { x, y };
};

这段代码简洁而完整:挂载时监听 mousemove,卸载时清理,返回坐标供组件使用。

✅ 使用方式(在 App 中)

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

❓答疑解惑:useMouse 的易错点与面试题

Q1:为什么 useEffect 依赖数组是空的 []

:表示该副作用只在组件挂载时执行一次,卸载时执行清理。这是监听全局事件(如 window)的标准做法。如果漏写 [],每次渲染都会重复绑定监听器,导致性能问题甚至内存泄漏。

Q2:event.pageXevent.clientX 有什么区别?

  • clientX/Y:相对于视口(viewport)的坐标(不随滚动变化)。
  • pageX/Y:相对于整个页面(包括滚动部分)的坐标。 你的选择是合理的——如果你希望坐标反映“页面中的绝对位置”,pageX/Y 更合适。

Q3:控制台日志 |||||||||||| 清除 有什么作用?

:这是调试利器!能清晰看到:

  • 组件挂载 → 监听器添加
  • 组件卸载 → 监听器移除
    面试官若问“如何验证事件监听被正确清理?”,你可以直接说:“加日志观察清理函数是否执行”。

⚠️ 面试题:如果多个组件同时使用 useMouse,会有什么问题?

:每个组件都会注册独立的 mousemove 监听器,虽然功能正常,但性能冗余。优化方案可考虑:

  • 全局单例模式(通过 Context + 一个顶层 Hook 管理)
  • 使用 useRef 共享状态(进阶)

✅ 模块二:useTodos —— 带持久化的 Todo 状态管理 Hook

🔍 你的实现(含本地存储)

import { useState, useEffect } from 'react';

const STORAGE_KEY = 'todos';

function loadFromStorage() {
  const storedTodos = localStorage.getItem(STORAGE_KEY);
  try {
    const parsed = storedTodos ? JSON.parse(storedTodos) : [];
    if (Array.isArray(parsed) && parsed.every(item => 
      typeof item === 'object' && 
      item !== null && 
      typeof item.id === 'number' && 
      typeof item.text === 'string' && 
      typeof item.completed === 'boolean'
    )) {
      return parsed;
    }
    localStorage.removeItem(STORAGE_KEY); // 清除非法数据
    return [];
  } catch (error) {
    localStorage.removeItem(STORAGE_KEY); // 清除损坏数据
    return [];
  }
}

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

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

  useEffect(() => {
    saveToStorage(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 };
};

这段代码堪称教科书级实践:数据校验、错误恢复、持久化、不可变更新,一应俱全!


❓答疑解惑:useTodos 的关键细节

Q1:useState(loadFromStorage) 为什么传函数而不是调用结果?

:其实你这里传的是函数调用结果loadFromStorage()),但更优写法是传函数本身

const [todos, setTodos] = useState(loadFromStorage); // 注意:不加 ()

这样 React 会在首次渲染时惰性执行,避免后续重渲染时重复读取 localStorage。不过你的写法在功能上没问题,只是略逊性能。

Q2:为什么 addTodo 没用函数式更新(setTodos(prev => [...]))?

:这是潜在 bug!假设用户快速连续添加两个 Todo,由于 todos 是闭包变量,第二次调用可能基于过期状态,导致丢失一项。
✅ 正确写法:

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

Q3:Date.now() 作为 ID 安全吗?

:在低频操作下可用,但不是生产级方案。高频点击(如自动化测试)可能导致 ID 冲突。建议改用:

import { nanoid } from 'nanoid';
id: nanoid()

crypto.randomUUID()(现代浏览器支持)。

⚠️ 面试题:你的 loadFromStorage 为什么要做类型校验?

:因为 localStorage 数据可能被用户篡改、其他脚本污染,或旧版本格式不兼容。防御性编程能防止应用因脏数据崩溃,提升健壮性——这是高级前端工程师的标志!

🧩 模块三:UI 组件 —— 职责分离的典范

📥 TodoInput:输入与提交

import { useState } from 'react';
export default function TodoInput({ addTodo }) {
  const [text, setText] = useState('');
  const handleSubmit = (e) => {
    e.preventDefault();
    if (!text.trim()) return;
    addTodo(text.trim());
    setText('');
  };
  return (
    <form className="todo-input" onSubmit={handleSubmit}>
      <input  
        type="text"
        value={text}
        onChange={e => setText(e.target.value)}
      />
    </form>
  );
}

亮点

  • 阻止表单默认刷新
  • 过滤空输入
  • 提交后清空输入框

📋 TodoList + TodoItem:列表渲染

// TodoList.jsx
import TodoItem from './TodoItem.jsx';
export default function TodoList({ todos, toggleTodo, deleteTodo }) {
  return (
    <ul className="todo_list">
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          toggleTodo={toggleTodo}
          deleteTodo={deleteTodo}
        />
      ))}
    </ul>
  );
}

// TodoItem.jsx
export default function TodoItem({ todo, toggleTodo, deleteTodo }) {
  return (
    <li className="todo-item">
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => toggleTodo(todo.id)}
      />
      <span className={todo.completed ? 'completed' : ''}>
        {todo.text}
      </span>
      <button onClick={() => deleteTodo(todo.id)}>Delete</button>
    </li>
  );
}

亮点

  • key={todo.id} 正确使用唯一 ID
  • 状态驱动样式(completed 类)
  • 无内联复杂逻辑,组件极简

❓答疑解惑:UI 组件常见误区

Q:TodoItem 中的箭头函数 () => toggleTodo(todo.id) 会导致性能问题吗?

:会!每次渲染都会创建新函数,若 TodoItemReact.memo 优化,则 props 引用变化会让优化失效。
✅ 改进方案(进阶):

// 在父组件中用 useCallback 包裹 toggleTodo/deleteTodo
// 或在 TodoItem 内部用 useEvent(React 18+ 实验性)

Q:为什么 TodoInput 不直接管理 todos 状态?

:因为你遵循了 “状态提升” + “逻辑抽离” 原则!状态由 useTodos 集中管理,UI 组件只负责展示和交互,可测试、可复用、易维护

🧠 总结:你的代码体现了哪些优秀工程思维?

实践你的代码体现
逻辑与 UI 分离Hooks 封装业务,组件专注渲染
副作用管理useEffect 清理监听器
数据持久化localStorage + 校验 + 错误恢复
不可变更新使用 ...mapfilter
防御性编程输入校验、空值处理、异常捕获

🌟 最后思考(面试加分)

“如果我想在多个页面共享同一个 Todo 列表,且支持实时同步(比如标签页 A 添加,标签页 B 自动更新),该怎么扩展 useTodos?”

提示方向

  • 监听 storage 事件(window.addEventListener('storage', ...)
  • 结合 BroadcastChannel API 实现跨标签通信
  • 升级为 Zustand / Redux + 持久化中间件

你的代码已经非常接近生产级水准!👏
继续坚持这种抽象能力 + 工程意识,你离高级前端工程师只差一步:系统化总结 + 主动分享

而这,正是你此刻正在做的事 💪

Happy Coding! 🚀