理解 React 自定义 Hook:不只是“封装”,更是思维方式的转变

阅读建议:适合已经会用 useStateuseEffect 的 React 初中级开发者。本文通过一个完整的 Todo 应用案例,带你一步步理解 自定义 Hook 是什么、为什么需要它、以及如何写出可复用、易维护的状态逻辑


一、我们遇到的问题:组件越来越重

在写 React 函数组件时,你是否经历过这样的场景?

  • 一个组件既要处理表单输入,又要监听键盘事件,还要同步数据到本地存储;
  • 另一个页面也需要同样的数据加载逻辑,但你只能复制粘贴代码;
  • 组件卸载后,某些事件监听还在运行,导致内存占用上升甚至行为异常;

这些问题的本质是:UI 和业务逻辑耦合太深

React 提供了函数组件 + Hook 的模型,让我们可以用更简洁的方式管理状态。而其中最强大的能力之一,就是------自定义 Hook


二、什么是自定义 Hook?先看一个简单例子

假设我们要实现在页面上实时显示鼠标位置的功能。

最原始的做法

jsx 复制代码
function App() {
  const [x, setX] = useState(0);
  const [y, setY] = useState(0);

  useEffect(() => {
    const handleMove = (e) => {
      setX(e.pageX);
      setY(e.pageY);
    };
    window.addEventListener('mousemove', handleMove);
    
    // 卸载时清除,防止内存泄漏
    return () => {
      window.removeEventListener('mousemove', handleMove);
    };
  }, []);

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

这段代码没有问题,但它有一个隐患:如果其他组件也需要这个功能,就得再写一遍 useEffect 和状态声明。一旦将来要加防抖或坐标转换,所有地方都得改。

这显然不是我们想要的开发方式。

抽象出 useMouse

我们可以把这部分逻辑单独提取出来:

js 复制代码
// hooks/useMouse.js
import { useState, useEffect } from 'react';

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

  useEffect(() => {
    const updatePosition = (e) => {
      setX(e.pageX);
      setY(e.pageY);
    };

    window.addEventListener('mousemove', updatePosition);

    return () => {
      window.removeEventListener('mousemove', updatePosition);
    };
  }, []);

  return { x, y };
}

然后在任意组件中直接使用:

jsx 复制代码
function MouseDisplay() {
  const { x, y } = useMouse();
  return <div>当前鼠标坐标:{x}, {y}</div>;
}

看起来只是换了个写法,但背后的意义完全不同。


三、关键点剖析:为什么这样设计?

1. 命名规范:以 use 开头

这是 React 的约定。只有以 use 开头的函数,React 才会认为它可能调用了其他 Hook(如 useState),从而启用相应的执行规则。虽然只是一个命名习惯,但它统一了团队协作的认知成本。

2. 状态是独立的

每次调用 useMouse(),都会创建一组新的 xy 状态。也就是说:

jsx 复制代码
function ComponentA() {
  const { x } = useMouse(); // 属于 A 的状态
}

function ComponentB() {
  const { x } = useMouse(); // 属于 B 的状态,互不影响
}

这一点非常重要:自定义 Hook 不是全局状态管理工具,它是逻辑的复用单元,而不是状态共享机制

3. 必须清理副作用

很多人忽略的一点是:window.addEventListener 是全局操作,不会因为组件卸载而自动消失。

如果不写 return () => removeEventListener(...),那么即使组件被销毁,事件监听依然存在。当下次触发 mousemove 时,旧的回调函数仍会被执行,可能导致:

  • 设置已卸载组件的状态(React 警告)
  • 内存无法释放(长期运行下性能下降)

所以,只要是涉及全局资源的操作(定时器、事件监听、WebSocket 连接等),就必须在 useEffect 中返回清理函数。


四、进阶实战:封装一套带持久化的待办事项逻辑

接下来我们做一个更有实用价值的例子:将 Todo 的增删改查 + 本地存储封装成一个自定义 Hook。

目标很明确:让任何组件都能轻松接入完整的 Todo 功能,而不需要关心数据从哪来、怎么存。

第一步:定义需求

我们需要实现:

  • 初始化时从 localStorage 加载数据;
  • 添加、修改、删除时自动保存;
  • 对外暴露清晰的方法:addTodo, toggleTodo, deleteTodo

第二步:编写 useTodos.js

js 复制代码
// hooks/useTodos.js
import { useState, useEffect } from 'react';

const STORAGE_KEY = 'todos'; // 抽离常量,便于后期维护或迁移

// 读取本地数据
function loadFromStorage() {
  const raw = localStorage.getItem(STORAGE_KEY);
  try {
    return raw ? JSON.parse(raw) : [];
  } catch (e) {
    console.warn('Failed to parse todos from localStorage', e);
    return [];
  }
}

// 保存到本地
function saveToStorage(todos) {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}

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

  // 数据变化时自动持久化
  useEffect(() => {
    saveToStorage(todos);
  }, [todos]); // 注意依赖项是 todos

  const addTodo = (text) => {
    if (!text || !text.trim()) return;

    const newTodo = {
      id: Date.now(), // 简单唯一 ID
      text: text.trim(),
      completed: false,
    };
    setTodos([...todos, newTodo]);
  };

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

关键细节说明

✅ 使用函数初始化 state

js 复制代码
useState(loadFromStorage)

而不是:

js 复制代码
useState(loadFromStorage())

区别在于:前者只会在首次渲染时执行 loadFromStorage 函数;后者每次组件渲染都会调用该函数(尽管结果不用),造成不必要的性能浪费。

✅ 持久化放在 useEffect

我们将 saveToStorage(todos) 放在 useEffect 内,并监听 todos 变化。这样做的好处是:

  • 数据变更逻辑集中;
  • 不会在每次 add/toggle/delete 中重复写保存逻辑;
  • 符合"状态驱动副作用"的设计理念。

✅ 保持不可变性(Immutability)

所有操作都没有直接修改原数组,而是返回新数组:

  • map 返回新数组用于更新状态;
  • filter 同样如此;
  • 对象展开 {...todo} 创建副本;

这是确保 React 正确触发重渲染的关键。


五、结合组件使用:UI 与逻辑彻底分离

有了 useTodos,我们的主组件变得非常干净:

jsx 复制代码
// App.jsx
import { useTodos } from './hooks/useTodos';
import TodoInput from './components/TodoInput';
import TodoList from './components/TodoList';

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

  return (
    <>
      <TodoInput addTodo={addTodo} />
      {todos.length > 0 ? (
        <TodoList 
          todos={todos}
          onToggle={toggleTodo}
          onDelete={deleteTodo}
        />
      ) : (
        <div>暂无待办事项</div>
      )}
    </>
  );
}

你会发现,App 组件不再关心:

  • 数据从哪里来?
  • 是否需要保存?
  • 如何生成 ID?

它只负责协调各个子组件之间的交互,成为一个"胶水层"。

这种分层结构极大提升了项目的可维护性。


六、组件拆解:各司其职

TodoInput:专注输入

jsx 复制代码
// components/TodoInput.jsx
import { useState } from 'react';

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

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

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="请输入待办事项"
      />
    </form>
  );
}

职责单一:接收用户输入并提交。

TodoItem:单项展示与交互

jsx 复制代码
// components/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)}>删除</button>
    </li>
  );
}

注意这里没有直接操作 todos 数组,而是通过回调通知父级处理。这也是典型的"数据下行,事件上行"模式。

TodoList:列表容器

jsx 复制代码
// components/TodoList.jsx
import TodoItem from './TodoItem';

export default function TodoList({ todos, onToggle, onDelete }) {
  return (
    <ul className="todo-list">
      {todos.map((todo) => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={onToggle}
          onDelete={onDelete}
        />
      ))}
    </ul>
  );
}

它只做一件事:遍历渲染每一项。


七、深入本质:自定义 Hook 到底是什么?

很多初学者会误以为自定义 Hook 是某种魔法语法,其实它的本质非常朴素:

自定义 Hook 就是一个普通的 JavaScript 函数,只要它内部调用了 React 的内置 Hook(如 useState、useEffect),并且名字以 use 开头。

它不能在条件语句中调用,也不能在普通函数中随意使用 ------ 因为 React 依赖调用顺序来追踪状态,这就是所谓的 "Hook 规则"。

但正因为这些限制,才保证了它的可靠性和可预测性。


八、常见误区与最佳实践

❌ 错误做法

  1. 在循环或判断中调用 Hook

    js 复制代码
    if (condition) {
      useState(); // ❌ 不允许!
    }
  2. 忘记清理副作用

    js 复制代码
    useEffect(() => {
      const timer = setInterval(() => {}, 1000);
      // 没有 return 清理函数 → 定时器永远存在
    }, []);
  3. 过度抽象

    并非所有逻辑都要抽成 Hook。比如简单的 const [open, setOpen] 就没必要封装成 useModal

✅ 推荐做法

实践 说明
提前组织好输入输出 明确参数和返回值结构
使用稳定引用 避免在每次渲染中创建新函数传给子组件
增加错误处理 JSON.parse 失败时降级为空数组
写注释文档 特别是复杂逻辑,方便他人接手

九、总结:自定义 Hook 的真正价值

学完这一整套流程,我们可以总结出自定义 Hook 的核心价值:

  1. 逻辑复用

    把通用的能力(如鼠标监听、本地存储、请求封装)提炼出来,一处维护,多处使用。

  2. 关注点分离

    UI 负责展示,Hook 负责逻辑,各归其位,降低认知负担。

  3. 提升可测试性

    你可以单独测试 useTodos 的行为,而不必挂载整个组件树。

  4. 沉淀团队资产

    当项目积累了一批高质量的自定义 Hook(如 useRequest, useLocalStorage, useUndo),它们就会成为团队的核心生产力工具。


结语

React 自定义 Hook 并不是一个高级技巧,而是一种思维方式的进化。

它告诉我们:组件不只是视图,也可以是可组合的状态逻辑单元

当你开始思考"这段逻辑能不能抽成一个 useXxx"的时候,你就已经迈出了成为优秀 React 开发者的重要一步。

希望这篇文章能帮你真正理解自定义 Hook 的意义,而不仅仅是学会怎么写。

如果你觉得有收获,欢迎点赞、收藏、分享。也欢迎在评论区交流你在项目中封装过的实用 Hook!


示例代码完整结构如下:

css 复制代码
src/
├── App.jsx
├── components/
│   ├── TodoInput.jsx
│   ├── TodoItem.jsx
│   └── TodoList.jsx
├── hooks/
│   ├── useMouse.js
│   └── useTodos.js
└── index.js

你可以基于此结构继续扩展,比如加入过滤、搜索、分类等功能,进一步体会逻辑与 UI 分离带来的便利。

共勉。

相关推荐
皮坨解解1 小时前
关于领域模型的总结
前端
UIUV1 小时前
React+Zustand实战学习笔记:从基础状态管理到项目实战
前端·react.js·typescript
岭子笑笑1 小时前
Vant4图片懒加载源码解析(二)
前端
千寻girling2 小时前
面试官 : “ 说一下 ES6 模块与 CommonJS 模块的差异 ? ”
前端·javascript·面试
贝格前端工场2 小时前
困在像素里:我的可视化大屏项目与前端价值觉醒
前端·three.js
哈哈你是真的厉害2 小时前
React Native 鸿蒙跨平台开发:Steps 步骤条 鸿蒙实战
react native·react.js·harmonyos
float_六七2 小时前
用 `<section>` 而不是 `<div>的原因
前端
ChinaLzw2 小时前
解决uniapp web-view 跳转到mui开发的h5项目 返回被拦截报错的问题
前端·javascript·uni-app
用户12039112947262 小时前
从零起步,用TypeScript写一个Todo App:踩坑与收获分享
前端·react.js·typescript