自定义 Hooks:把状态和副作用,从组件里“拆”出来

50 阅读4分钟

在 React 项目里,组件一旦开始承担过多逻辑,通常会出现几个明显信号:

  • JSX 越来越难读
  • 同一类逻辑在多个组件中反复出现
  • 生命周期相关代码(事件监听、定时器)分散在各处
  • 稍不注意就留下内存泄漏隐患

Hooks 的出现,本质上不是“少写 class”,而是引入了一种函数级别的逻辑抽象方式

自定义 Hooks,正是这种抽象能力真正发挥价值的地方。


一、Hooks 的本质不是 API,而是函数式拆分

从形式上看,Hooks 只是一些以 use 开头的函数:

  • useState
  • useEffect
  • useRef
  • useContext

但从设计角度看,它们做的是同一件事:

把状态和副作用,从组件结构中解耦出来

这也是为什么自定义 Hooks 必须遵循两个规则:

  1. 必须以 use 开头
  2. 内部可以自由组合 React 内置 Hooks

只要满足这两点,Hooks 就不再依赖具体组件结构,而是可以被任意复用。


二、一个典型问题:mousemove 的响应式实现

先看一个常见需求:
实时获取鼠标在页面中的位置。

如果直接写在组件里,通常会是这样:

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

  window.addEventListener('mousemove', handler);

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

这段代码有几个特点:

  • 和 UI 无关
  • 强依赖生命周期
  • 必须手动清理副作用

如果在多个组件中需要鼠标位置,这段逻辑就会被复制多次。

这正是自定义 Hooks 的切入点。


三、useMouse:把副作用变成可复用能力

将 mousemove 逻辑抽离为一个 Hook:

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    const update = (event) => {
      setX(event.pageX);
      setY(event.pageY);
    };

    window.addEventListener('mousemove', update);

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

  return { x, y };
};

这个 Hook 做了三件事:

  1. 内部维护状态
  2. 管理事件监听与清理
  3. 对外只暴露结果

组件不再关心事件绑定细节,只关心数据。


在组件中使用

function MouseMove() {
  const { x, y } = useMouse();

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

组件结构变得非常纯粹,只剩下 UI 表达。


四、内存泄漏:Hooks 必须直面的现实问题

一个常见误区是:
组件卸载后,业务函数会自动销毁。

事实上:

  • React 只负责卸载组件
  • 事件监听、定时器、订阅都需要手动清理

如果在 useEffect 中不返回清理函数,就会出现:

  • 事件重复绑定
  • 状态更新到已卸载组件
  • 内存持续增长

useEffect(() => {}, []) 并不等于“安全”,
只有 return cleanup,副作用才是完整的。

这也是为什么自定义 Hooks 非常适合封装这类逻辑:
一旦写对,所有使用方都自动正确。


五、业务型 Hooks:useTodos 的价值不在于 Todo

再看一个更偏业务的 Hook:Todo 管理。

import { useState, useEffect } from 'react';

const STORAGE_KEY = 'todos';

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

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

这里的关键并不在 Todo 本身,而在于:

  • 状态初始化逻辑被封装
  • 副作用(localStorage 同步)集中管理
  • 组件无需关心数据来源

组件只负责组合

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

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

这时,组件更像“配置层”,而不是“逻辑容器”。


六、自定义 Hooks 带来的真实收益

当项目规模变大后,自定义 Hooks 带来的好处会非常明显:

  1. 逻辑复用不再依赖组件结构
  2. 副作用集中,内存问题更可控
  3. UI 与业务彻底解耦
  4. Hooks 本身可以被测试、被重构

从团队层面看,Hooks 更像一种“逻辑模块”,而不是技巧。


七、什么时候值得抽 Hooks

不是所有逻辑都需要 Hook,但以下场景非常适合:

  • 依赖生命周期的逻辑
  • 需要清理副作用的逻辑
  • 多组件共享的状态行为
  • 与 UI 无关的业务流程

一个简单判断标准是:

如果这段代码删除 JSX 后依然成立,它大概率属于 Hook。