从入门到进阶:手写React自定义Hooks,让你的组件更简洁

0 阅读10分钟

从入门到进阶:手写React自定义Hooks,让你的组件更简洁

大家好,今天我们来聊聊React中非常实用的自定义Hooks。通过两个实际例子(鼠标位置追踪和Todo待办应用),带你从零开始封装自己的Hooks,彻底理解“逻辑复用”的魅力,并掌握如何避免常见的内存泄漏问题。

什么是Hooks?

Hooks是React 16.8引入的一种函数式编程思想,它让我们在函数组件中使用状态和生命周期等特性。React内置了useStateuseEffect等基础Hooks,而自定义Hooks则是将组件逻辑提取到可复用的函数中,以use开头,内部可以调用其他Hooks。

自定义Hooks的好处

  • 复用状态逻辑,避免重复代码
  • 让UI组件更纯粹,只关注渲染
  • 便于团队维护和共享核心逻辑

第一部分:不使用自定义Hooks实现鼠标追踪(并理解内存泄漏)

我们先从一个简单的需求开始:实时显示鼠标在页面上的位置。直接在App组件里写逻辑。

直接在组件内实现

新建App.jsx,代码如下:

import { useState, useEffect } from 'react';

function MouseMove() {
  const [x, setX] = useState(0);
  const [y, setY] = useState(0);

  useEffect(() => {
    // 鼠标移动时的更新函数
    const updateMouse = (e) => {
      setX(e.clientX);
      setY(e.clientY);
    };

    // 监听 mousemove 事件
    window.addEventListener('mousemove', updateMouse);
    console.log('添加事件监听');

    // 清理函数:组件卸载时移除监听,防止内存泄漏
    return () => {
      window.removeEventListener('mousemove', updateMouse);
      console.log('移除事件监听');
    };
  }, []); // 空依赖数组,只在挂载时执行一次

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

export default function App() {
  const [count, setCount] = useState(0);

  return (
    <>
    {count}
      <button onClick={() => setCount(count => count + 1)}>
        // 点击重新挂载函数
        增加
      </button>
      {count % 2 === 0 && <MouseMove />}
    </>
  );
}

代码解析

  • useState 用来存储鼠标坐标,初始值为 0
  • useEffect 接受两个参数:第一个是副作用函数,第二个是依赖数组。这里依赖数组为 [],表示副作用只在组件挂载时执行一次,不会在每次渲染后重复执行。
  • 在副作用函数中,我们定义了 updateMouse,并通过 addEventListener 注册了 mousemove 事件。
  • 重点useEffect 可以返回一个清理函数,React 会在组件卸载时调用它。我们在清理函数中移除了事件监听,这是避免内存泄漏的关键。
⚠ 如果不清理会发生什么?

想象一下:如果组件卸载时没有移除 mousemove 监听,那么 updateMouse 函数仍然存在于内存中,并且每次鼠标移动都会尝试调用 setXsetY。但此时组件已经被销毁,这些 setState 调用是无意义的,并且会导致内存泄漏——事件处理函数持有对组件作用域的引用,垃圾回收无法释放相关内存。长时间运行的应用可能会因此变得卡顿甚至崩溃。

验证方法:注释掉 return () => {...} 这一部分,然后反复点击按钮让 MouseMove 组件挂载/卸载,观察控制台。你会发现即使组件卸载了,鼠标移动时控制台依然打印“添加事件监听”(实际上并没有重新添加,但之前添加的监听还在),说明事件处理函数依然存活。这就是内存泄漏的表现。

没清理的效果图

我们看到就算函数已经卸载,事件依然会执行 屏幕录制 2026-02-28 202959.gif

清理后的效果图

可以看到,函数卸载后,事件不会执行了 屏幕录制 2026-02-28 203448.gif

第二部分:提取自定义Hook useMouse

我们把鼠标追踪的逻辑封装成一个独立的Hook,放在 hooks/useMouse.js 中。

创建 useMouse.js

import { useState, useEffect } from 'react';

/**
 * 自定义 Hook:追踪鼠标在页面上的位置
 * @returns {{ x: number, y: number }} 包含当前鼠标坐标的对象
 */
export const useMouse = () => {
  const [x, setX] = useState(0);
  const [y, setY] = useState(0);

  useEffect(() => {
    /** @param {MouseEvent} e 原生鼠标事件对象 */
    const update = (e) => {
      setX(e.clientX);
      setY(e.clientY);
    };

    window.addEventListener('mousemove', update);
    console.log('useMouse: 添加监听');

    // 清理函数:组件卸载时移除监听
    return () => {
      window.removeEventListener('mousemove', update);
      console.log('useMouse: 移除监听');
    };
  }, []); // 依赖数组为空,保证只在挂载时执行一次

  return { x, y };
};

API 详细解释

  • useMouse 是一个自定义 Hook,它内部使用了 React 的 useStateuseEffect
  • 返回值:一个包含 xy 的对象,类型均为 number,表示当前鼠标坐标。
  • 副作用:在组件挂载时添加 mousemove 监听,卸载时移除。这里的清理逻辑与前面相同,但被封装在 Hook 内部,任何使用 useMouse 的组件都会自动获得正确的生命周期管理,无需重复编写清理代码。

在组件中使用 useMouse

现在改造 App.jsx,引入 useMouse

import { useState } from 'react';
import { useMouse } from './hooks/useMouse';

function MouseMove() {
  const { x, y } = useMouse(); // 一行代码搞定!

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

export default function App() {
  const [count, setCount] = useState(0);
  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>
        点击重新挂载 MouseMove 组件
      </button>
      {count % 2 === 0 && <MouseMove />}
    </>
  );
}

现在 MouseMove 组件变得非常简洁,只负责渲染,逻辑全在 useMouse 中。如果其他地方也需要鼠标位置,直接调用 useMouse 即可,真正做到了“一次封装,多处复用”。

第三部分:使用自定义Hooks实现Todo应用(带本地存储)

接下来,我们实现一个更复杂的例子:带本地存储的Todo待办应用。我们将创建一个 useTodos Hook,封装所有todos的状态管理和持久化。

1. 编写 useTodos.js

这个Hook负责:

  • 管理todos数组(增、删、改完成状态)
  • 自动同步到localStorage,实现数据持久化
import { useState, useEffect } from 'react';

const STORAGE_KEY = 'todos'; // 本地存储的键名,统一管理便于维护

/**
 * 从 localStorage 加载待办数据
 * @returns {Array} 存储的待办数组,如果没有则返回空数组
 */
function loadFromStorage() {
  const storedTodos = localStorage.getItem(STORAGE_KEY);
  return storedTodos ? JSON.parse(storedTodos) : [];
}

/**
 * 将待办数据保存到 localStorage
 * @param {Array} todos - 待办数组
 */
function saveToStorage(todos) {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}

/**
 * 自定义 Hook:管理待办事项的所有逻辑(增删改、本地存储同步)
 * @returns {{
 *   todos: Array,
 *   addTodo: (text: string) => void,
 *   toggleTodo: (id: number|string) => void,
 *   deleteTodo: (id: number|string) => void
 * }}
 */
export const useTodos = () => {
  // 使用函数形式初始化,避免每次渲染都重新读取 localStorage
  const [todos, setTodos] = useState(loadFromStorage);

  // 每当 todos 变化,自动保存到 localStorage
  useEffect(() => {
    saveToStorage(todos);
  }, [todos]); // 依赖 todos,只有 todos 变化时才执行

  /**
   * 添加新待办
   * @param {string} text - 待办内容
   */
  const addTodo = (text) => {
    setTodos([
      ...todos,
      {
        id: Date.now(),      // 简单用时间戳作为临时唯一 ID(仅用于演示)
        text,
        completed: false
      }
    ]);
  };

  /**
   * 切换指定待办的完成状态
   * @param {number|string} id - 待办项的 ID
   */
  const toggleTodo = (id) => {
    setTodos(
      todos.map(todo =>
        todo.id === id
          ? { ...todo, completed: !todo.completed } // 切换状态,保持其他属性不变
          : todo
      )
    );
  };

  /**
   * 删除指定待办
   * @param {number|string} id - 待办项的 ID
   */
  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  // 返回所有状态和操作
  return {
    todos,
    addTodo,
    toggleTodo,
    deleteTodo
  };
};

API 详细解释

  • loadFromStorage:内部辅助函数,用于读取本地存储,返回待办数组或空数组。
  • saveToStorage:内部辅助函数,将待办数组序列化后存入本地存储。
  • useTodos 返回值:
    • todos:当前待办列表,每个待办对象包含 id(number/string)、text(string)、completed(boolean)。
    • addTodo(text):接收待办文本,创建一个新待办(id为当前时间戳,completed为false),并更新状态。
    • toggleTodo(id):接收待办id,遍历todos,找到对应项并反转其 completed 属性。
    • deleteTodo(id):接收待办id,过滤掉该项,更新状态。

关于内存泄漏的再次提醒:虽然本Hook中没有显式的事件监听或定时器,但 useEffect 依赖 [todos],会在每次 todos 变化后执行保存操作。这里没有清理函数的必要,因为保存操作是安全的。但如果我们在 useEffect 中启动了定时器或订阅了外部事件,就必须返回清理函数。

2. 编写UI组件(每个组件都带有详细API注释)

TodoInput.jsx - 输入框
import { useState } from 'react';

/**
 * 待办输入表单组件
 * @param {Object} props
 * @param {Function} props.onAddTodo - 添加待办的回调函数,接收待办文本作为参数
 */
export default function TodoInput({ onAddTodo }) {
  const [text, setText] = useState('');

  /**
   * 表单提交处理函数
   * @param {Event} e - 表单提交事件对象
   */
  const handleSubmit = (e) => {
    e.preventDefault(); // 阻止页面刷新
    const trimmedText = text.trim();
    if (!trimmedText) return; // 输入为空时不添加

    onAddTodo(trimmedText); // 调用父组件传递的添加函数
    setText(''); // 清空输入框
  };

  return (
    <form className="todo-input" onSubmit={handleSubmit}>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="输入待办事项..."
      />
    </form>
  );
}
TodoItem.jsx - 单个待办项
/**
 * 单个待办项组件
 * @param {Object} props
 * @param {Object} props.todo - 待办对象 { id, text, completed }
 * @param {Function} props.onDeleteTodo - 删除待办的回调,接收待办 id
 * @param {Function} props.onToggleTodo - 切换完成状态的回调,接收待办 id
 */
export default function TodoItem({ todo, onDeleteTodo, onToggleTodo }) {
  return (
    <li className="todo-item">
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggleTodo(todo.id)}
      />
      <span className={todo.completed ? 'completed' : ''}>
        {todo.text}
      </span>
      <button onClick={() => onDeleteTodo(todo.id)}>删除</button>
    </li>
  );
}
TodoList.jsx - 待办列表
import TodoItem from './TodoItem';

/**
 * 待办列表组件,渲染所有待办项
 * @param {Object} props
 * @param {Array} props.todos - 待办数组
 * @param {Function} props.onDeleteTodo - 删除待办的回调
 * @param {Function} props.onToggleTodo - 切换完成状态的回调
 */
export default function TodoList({ todos, onDeleteTodo, onToggleTodo }) {
  return (
    <ul className="todo-list">
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onDeleteTodo={onDeleteTodo}
          onToggleTodo={onToggleTodo}
        />
      ))}
    </ul>
  );
}

3. 在 App.jsx 中组装

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

export default function App() {
  // 直接使用自定义Hook,获取所有状态和方法
  const { todos, addTodo, deleteTodo, toggleTodo } = useTodos();

  return (
    <>
      <h1>Todo 待办清单</h1>
      <TodoInput onAddTodo={addTodo} />
      {todos.length > 0 ? (
        <TodoList
          todos={todos}
          onDeleteTodo={deleteTodo}
          onToggleTodo={toggleTodo}
        />
      ) : (
        <div>暂无待办事项,添加一条吧~</div>
      )}
    </>
  );
}

效果

  • 添加待办:输入文本,回车提交。
  • 勾选复选框切换完成状态,文字样式变化(可自行添加CSS,比如加删除线)。
  • 点击删除按钮移除该项。
  • 刷新页面,数据依然存在,因为已经同步到localStorage。
效果图

屏幕录制 2026-02-28 203724.gif

第四部分:深入理解内存泄漏与清理的必要性

在React函数组件中,useEffect 是处理副作用的主要场所。常见的副作用包括:

  • 订阅外部事件(如 mousemoveresize、WebSocket)
  • 设置定时器(setIntervalsetTimeout
  • 手动修改DOM
  • 数据请求(虽然请求本身不需要清理,但需要处理竞态)

所有这些副作用,如果在组件卸载后没有正确清理,都会导致内存泄漏。例如:

  • 事件监听:组件卸载后,事件处理函数仍然被全局对象(如 window)引用,导致组件内部的状态变量和函数无法被垃圾回收。
  • 定时器:即使组件卸载,定时器仍然会周期性触发回调,如果回调中使用了 setState,会报“在未挂载的组件上调用setState”的警告,并且造成内存泄漏。
  • 订阅:类似于事件监听,必须取消订阅。

如何避免?useEffect 中返回一个清理函数,React 会在组件卸载前和执行下一次副作用前调用它。这个清理函数应该:

  • 移除事件监听
  • 清除定时器
  • 取消订阅
  • 中止请求(如果支持)

示例:错误的写法(导致内存泄漏)

useEffect(() => {
  const timer = setInterval(() => {
    console.log('定时器执行');
  }, 1000);
  // 没有返回清理函数!
}, []);

组件卸载后,定时器依然运行,回调中的代码可能访问已经不存在的组件状态。

正确的写法

useEffect(() => {
  const timer = setInterval(() => {
    console.log('定时器执行');
  }, 1000);
  return () => clearInterval(timer); // 清理定时器
}, []);

在我们的 useMouse 例子中,我们正是通过返回清理函数来移除事件监听,确保了无论组件如何挂载/卸载,都不会留下残留的监听器。

总结

通过两个例子,我们见证了自定义Hooks的强大:

  1. useMouse:将副作用(事件监听)和状态封装起来,组件只需调用并渲染,同时自动处理了内存泄漏的清理逻辑。
  2. useTodos:不仅管理状态,还集成了本地存储持久化,让UI组件完全无感。

自定义Hooks让我们能够像搭积木一样组合逻辑,保持组件简洁,提升代码复用性。在实际项目中,你可以根据自己的业务封装更多通用Hooks,比如 useLocalStorageuseFetchuseWindowSize 等。

最后请记住:每当你在 useEffect 中引入持续性的副作用(事件、定时器、订阅),务必返回一个清理函数,这是React函数组件中防止内存泄漏的基本准则。

希望这篇文章能帮你打开自定义Hooks的大门,快去动手试试吧!