React Hooks 不仅是语法糖,更是逻辑抽象与复用的核心工具。今天,我们不讲理论,直接剖析一个你亲手写的实战项目——包含两个自定义 Hooks:
useMouse:响应式追踪鼠标位置useTodos:管理带本地存储的 Todo 列表
并通过 TodoInput、TodoItem、TodoList 三个组件组合成完整 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.pageX 和 event.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) 会导致性能问题吗?
答:会!每次渲染都会创建新函数,若
TodoItem用React.memo优化,则 props 引用变化会让优化失效。
✅ 改进方案(进阶):// 在父组件中用 useCallback 包裹 toggleTodo/deleteTodo // 或在 TodoItem 内部用 useEvent(React 18+ 实验性)
Q:为什么 TodoInput 不直接管理 todos 状态?
答:因为你遵循了 “状态提升” + “逻辑抽离” 原则!状态由
useTodos集中管理,UI 组件只负责展示和交互,可测试、可复用、易维护。
🧠 总结:你的代码体现了哪些优秀工程思维?
| 实践 | 你的代码体现 |
|---|---|
| ✅ 逻辑与 UI 分离 | Hooks 封装业务,组件专注渲染 |
| ✅ 副作用管理 | useEffect 清理监听器 |
| ✅ 数据持久化 | localStorage + 校验 + 错误恢复 |
| ✅ 不可变更新 | 使用 ...、map、filter |
| ✅ 防御性编程 | 输入校验、空值处理、异常捕获 |
🌟 最后思考(面试加分)
“如果我想在多个页面共享同一个 Todo 列表,且支持实时同步(比如标签页 A 添加,标签页 B 自动更新),该怎么扩展
useTodos?”
提示方向:
- 监听
storage事件(window.addEventListener('storage', ...)) - 结合 BroadcastChannel API 实现跨标签通信
- 升级为 Zustand / Redux + 持久化中间件
你的代码已经非常接近生产级水准!👏
继续坚持这种抽象能力 + 工程意识,你离高级前端工程师只差一步:系统化总结 + 主动分享。
而这,正是你此刻正在做的事 💪
Happy Coding! 🚀