手写 React Hooks:从 useMouse 到 useTodos,彻底搞懂自定义 Hook

86 阅读8分钟

在 React 函数组件时代,Hooks 无疑是提升代码复用性和可读性的“神器”。除了使用内置的 useStateuseEffect,我们还能亲手封装属于自己的 Hook。本文将通过一个基于 Vite 构建的待办事项项目,逐行剖析 useMouseuseTodos 两个自定义 Hook 的实现细节,带你掌握自定义 Hook 的设计思想与最佳实践。

1. 为什么需要自定义 Hook?

React 官方提供了 useStateuseEffectuseContext 等基础 Hook,但它们通常解决的是单一维度的状态逻辑。当多个组件需要共享带副作用的状态逻辑(比如监听鼠标位置、读写 localStorage、订阅 WebSocket)时,把逻辑提取成一个自定义 Hook 就成了最优解。

自定义 Hook 的本质:一个以 use 开头的普通函数,内部可以调用其他 Hook,用于封装有状态、有副作用的逻辑,并返回组件需要的数据或方法。

下面我们通过项目中的两个经典案例来深入理解。

完整项目链接:gitee.com/hong-strong…


2. 项目概览(Vite + React)

项目结构如下(仅展示关键文件):

src/
├── hooks/
│   ├── useMouse.js          # 监听鼠标位置
│   └── useTodos.js          # 待办事项的增删改查 + localStorage 持久化
├── components/
│   ├── TodoInput.jsx        # 输入框组件
│   ├── TodoList.jsx         # 列表组件
│   └── TodoItem.jsx         # 单个待办项组件
├── App.jsx                  # 根组件,使用 useTodos
├── App.css                  # 组件样式(含奇特的 3D 翻转效果)
├── index.css                # 全局样式 + 亮色/暗色主题
├── main.jsx                 # 入口文件
└── readme.md                # 项目说明(仅作背景,本文不展开)

项目通过 useTodos 管理待办数据,并自动同步到 localStorage;通过 useMouse 实时获取鼠标坐标(虽然 App.jsx 中注释掉了,但仍是完美的教学示例)。


3. 手写 useMouse:监听全局鼠标事件

3.1 完整代码逐行解析

// 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) => {
      console.log('//////////////////////////');
      setX(event.pageX);
      setY(event.pageY);
    };
    window.addEventListener('mousemove', update);
    console.log('||||||');

    return () => {
      console.log('|||||| 清除');
      window.removeEventListener('mousemove', update);
    };
  }, []);  // 空依赖数组 → 只在组件挂载时注册,卸载时移除

  return { x, y };
};

逐行讲解

  • import { useState, useEffect } from 'react':自定义 Hook 必须依赖 React 提供的基础 Hook。
  • export const useMouse = () => { ... }:函数名以 use 开头,这是 React 的约定,方便 lint 工具检查规则(比如不能在条件语句中调用 Hook)。
  • const [x, setX] = useState(0):声明响应式变量 x,初始值为 0。鼠标的横坐标。
  • const [y, setY] = useState(0):纵坐标。
  • useEffect(() => { ... }, [])
    • 第一个参数是副作用函数,在组件渲染后执行。空依赖数组 [] 表示该副作用只会在组件挂载时执行一次,不会因为重新渲染而重复注册事件。
    • 内部定义了 update 函数,接收原生 mousemove 事件对象,调用 setXsetY 更新状态。
    • window.addEventListener('mousemove', update):全局监听鼠标移动。
    • 返回值是一个清理函数return () => { window.removeEventListener(...) }。这个清理函数会在组件卸载时自动执行,移除事件监听,防止内存泄漏。
  • return { x, y }:将坐标暴露给组件使用。

重要扩展:为什么必须清理?

如果忘记移除事件监听,当组件被卸载后,update 函数仍然会绑定在 window 上。一旦鼠标移动,就会调用已卸载组件的 setX,React 会发出警告(“Can't perform a React state update on an unmounted component”),并且造成内存泄漏——事件回调持有的闭包无法释放。

图表:useMouse 的生命周期流程

f75fa8de-3b05-48a2-9aa4-96c6932390e8.png

graph TD
    A[组件挂载] --> B[执行 useEffect 副作用]
    B --> C[注册 mousemove 监听]
    C --> D[用户移动鼠标]
    D --> E[触发 update 函数]
    E --> F[调用 setX/setY]
    F --> G[组件重新渲染,显示新坐标]
    
    H[组件卸载] --> I[执行 useEffect 返回的清理函数]
    I --> J[移除 mousemove 监听]
    J --> K[内存安全]

4. 手写 useTodos:管理待办 + localStorage 持久化

这个 Hook 更复杂,体现了状态逻辑存储副作用的完美封装。

4.1 完整代码逐行解析

// src/hooks/useTodos.js
import { useState, useEffect } from 'react';

const STORAGE_KEY = 'todos';   // 常量,好维护

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

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 };
};

4.1.1 初始化状态:惰性加载

  • const STORAGE_KEY = 'todos':定义存储键名,避免魔法字符串。
  • loadFromStorage:从 localStorage 读取数据,如果不存在则返回空数组。注意 JSON.parse 可能抛出异常(比如存储被破坏),生产环境应加 try-catch,本文简化。
  • useState(loadFromStorage):这里传递的是函数本身(而不是 loadFromStorage()),这是 useState惰性初始化特性。React 只会在组件首次渲染时执行该函数,读取一次 localStorage;后续重渲染不会再调用,节省性能。

4.1.2 同步副作用:useEffect 依赖 todos

  • useEffect(() => { saveToStorage(todos); }, [todos])
    • 每当 todos 发生变化(增、删、改),都会自动调用 saveToStorage,将最新数组序列化后存入 localStorage
    • 因为依赖数组是 [todos],所以首次渲染后也会执行一次(保存初始值)。
    • 这里不需要清理函数,因为没有任何全局订阅或定时器。

4.1.3 增删改查逻辑

  • addTodo:使用不可变数据模式,生成新数组([...todos, newTodo])。id 使用 Date.now() 简单生成,实际项目可用 uuid
  • toggleTodo:遍历 todos,匹配 id 后返回新对象 { ...todo, completed: !todo.completed },不匹配的保持不变。最终生成新数组触发更新。
  • deleteTodo:用 filter 过滤掉待删除项。
  • 返回值:提供 todos 状态和三个操作函数,组件直接解构使用。

4.2 图表:useTodos 数据流

346fac44-8a00-481a-a034-8b0e9cf315bf.png

flowchart LR
    A[组件调用 useTodos] --> B[初始化:从 localStorage 读取]
    B --> C[todos 状态]
    
    D[用户触发 add/toggle/delete] --> E[调用对应的 setTodos]
    E --> F[生成新的 todos 数组]
    F --> G[触发 useEffect]
    G --> H[保存到 localStorage]
    F --> I[组件重新渲染]

5. 组件中使用自定义 Hook

5.1 根组件 App.jsx(核心部分)

import { useTodos } from './hooks/useTodos.js';
import TodoList from './components/TodoList.jsx';
import TodoInput from './components/TodoInput.jsx';

export default function App() {
  const { 
    todos,
    addTodo,
    deleteTodo,
    toggleTodo
  } = useTodos();

  return (
    <>
      <TodoInput onAddTodo={addTodo}/>
      {
        todos.length > 0 ? 
          <TodoList 
            onDelete={deleteTodo}
            onToggle={toggleTodo}
            todos={todos}
          /> : 
          <div>暂无待办事项</div>
      }
    </>
  );
}
  • 解构 Hook 返回值useTodos 返回的对象被解构,直接获得状态和操作函数。
  • 条件渲染:当没有待办时显示提示信息,否则渲染 TodoList
  • TodoInput:接收 onAddTodo 回调,内部维护输入框的局部状态(见下文)。
  • TodoListTodoItem:通过 props 传递数据和操作函数,实现“状态提升”的另一种形式——逻辑全在 Hook 里,组件只负责展示。

5.2 TodoInput.jsx 解析(局部状态与表单提交)

import { useState } from 'react';

export default function TodoInput({ onAddTodo }) {
  const [text, setText] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!text.trim()) return;
    onAddTodo(text.trim());
    setText("");
  };

  return (
    <form className="todo-input" onSubmit={handleSubmit}>
      <input 
        type="text" 
        value={text}
        onChange={e => setText(e.target.value)}
      />
    </form>
  );
}
  • 局部状态text 只属于输入框组件,不需要提升到 useTodos 中。
  • 表单提交:阻止默认刷新,判断非空后调用父组件传入的 onAddTodo,然后清空输入框。

5.3 TodoItem.jsxTodoList.jsx(列表渲染)

// TodoItem.jsx
export default function TodoItem({ todo, onToggle, onDelete }) {
  return (
    <li className="todo-item">
      <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>
  );
}
  • 受控复选框checked 绑定 todo.completedonChange 触发 onToggle 并传递 id
  • 删除按钮:直接调用 onDelete
// TodoList.jsx
export default function TodoList({ todos, onDelete, onToggle }) {
  return (
    <ul className="todo-list">
      {todos.map(todo => (
        <TodoItem 
          key={todo.id}
          todo={todo}
          onDelete={onDelete}
          onToggle={onToggle}
        />
      ))}
    </ul>
  );
}
  • key 属性:React 列表渲染必须提供稳定且唯一的 key,这里使用 todo.id

6. 自定义 Hook 的核心规则与常见误区

编写自定义 Hook 时必须遵守 React 的两条铁律,否则会导致难以调试的错误。

6.1 只在顶层调用 Hook

规则:不要在循环、条件判断或嵌套函数中调用 useStateuseEffect 或任何自定义 Hook。

原因:React 依赖 Hook 的调用顺序来正确关联 state 和副作用。如果顺序发生变化,React 无法知道哪个 state 对应哪个 useState

错误示例

function MyComponent({ condition }) {
  if (condition) {
    const [data, setData] = useState(null); // ❌ 条件调用
  }
  // ...
}

正确做法:将条件逻辑放在 Hook 内部

function useCustomHook(condition) {
  const [data, setData] = useState(null);
  useEffect(() => {
    if (condition) {
      // 执行逻辑
    }
  }, [condition]);
}

6.2 只在 React 函数组件或自定义 Hook 中调用 Hook

规则:不要在普通的 JavaScript 函数中调用 Hook。

原因:Hook 依赖 React 的调度机制,只有组件或 Hook 内部才能保证正确的上下文。

6.3 依赖数组必须诚实

当使用 useEffectuseCallbackuseMemo 时,依赖数组必须包含所有在副作用中使用的外部变量(包括 state、props、函数等)。如果遗漏依赖,会导致闭包捕获旧值,产生“过期状态”问题。

错误示例

useEffect(() => {
  console.log(count); // 使用了 count
}, []); // ❌ 缺少 count 依赖

正确做法

useEffect(() => {
  console.log(count);
}, [count]); // ✅ 包含所有依赖

如果某些依赖频繁变化但你不希望重新执行副作用,可以考虑:

  • 使用 useRef 保存最新值。
  • 重构逻辑,将副作用拆分为多个 useEffect
  • 使用 useReducer 代替。

6.4 图表:Hook 调用规则示意

graph TD
    A[函数组件 / 自定义 Hook] -->|允许调用| B[useState]
    A -->|允许调用| C[useEffect]
    A -->|允许调用| D[自定义 Hook]
    
    E[普通函数 / 循环 / 条件] -->|禁止调用| B
    E -->|禁止调用| C
    E -->|禁止调用| D
    
    style E fill:#f9f,stroke:#333,stroke-width:2px
    style A fill:#9f9,stroke:#333,stroke-width:2px

7. 自定义 Hook 的设计原则

7.1 何时抽取 Hook?

  • 两个或以上组件使用相同的有状态逻辑(包含副作用)。
  • 逻辑本身复杂度高,需要独立测试或维护。
  • 逻辑中使用了多个内置 Hook(useStateuseEffectuseRef 等),抽取后能提高可读性。

7.2 Hook 的命名规范

  • 必须以 use 开头,后跟大写字母(如 useMouse)。
  • 返回值可以是任意类型:对象、数组、基本类型。

7.3 Hook 的依赖管理

  • useEffectuseCallbackuseMemo 的依赖数组必须完整声明内部使用的所有外部变量(包括 state 和 props)。
  • 如果依赖频繁变化,考虑重构逻辑或使用 useReducer

7.4 图表:内置 Hook 与自定义 Hook 的关系

graph TD
    BuiltIn[React 内置 Hook] --> useState
    BuiltIn --> useEffect
    BuiltIn --> useContext
    BuiltIn --> useReducer
    
    Custom[自定义 Hook] -->|可调用任意内置 Hook| useState
    Custom -->|也可调用其他自定义 Hook| useMouse
    Custom -->|组合逻辑| useTodos
    
    Component[函数组件] -->|使用| useState
    Component -->|使用| Custom


8. 总结

通过亲手实现 useMouseuseTodos,我们深入理解了:

  • 自定义 Hook 的本质:封装可复用的状态逻辑函数。
  • 生命周期管理useEffect 的依赖数组和清理函数是避免内存泄漏的关键。
  • localStorage 持久化:结合 useState 惰性初始化 + useEffect 自动同步。
  • 组件组合:父组件调用自定义 Hook,子组件通过 props 接收数据和回调,形成清晰的数据流。
  • 铁律:只在顶层调用 Hook,只在 React 函数中调用 Hook,依赖数组要诚实。

最后提醒:自定义 Hook 虽然强大,但要遵循 React 的规则。当你发现项目中重复出现相似的逻辑时,勇敢地抽成一个 Hook 吧!