React 进阶:拒绝面条代码,用自定义 Hooks 让你的组件“减肥”

44 阅读4分钟

在 React 的开发旅程中,我们经常会遇到一个痛点:组件越来越臃肿。一个简单的组件里,既要写 HTML 结构,又要处理复杂的状态逻辑,还要监听浏览器事件或管理本地存储。久而久之,代码变成了难以维护的“面条代码”。

今天,我们通过两个经典的实战案例——鼠标位置追踪待办事项清单(Todo List) ,来聊聊如何利用 自定义 Hooks 将业务逻辑从 UI 中“抽离”出来,让代码变得清爽、可复用且易于维护。

什么是自定义 Hook?

简单来说,Hook 是一种函数式编程思想。在 React 中,凡是以 use 开头的函数都可以被称为 Hook。

如果说 useStateuseEffect 是 React 给我们的“乐高积木”,那么自定义 Hook 就是我们用这些积木搭建好的“精密马达”。你只需要把马达装进组件里,组件就能动起来,而不需要关心马达内部是如何绕线圈的。

核心优势在于:

  1. 逻辑复用:一次编写,到处运行。
  2. 关注点分离:UI 负责“长什么样”,Hooks 负责“怎么运作”。

案例一:鼠标位置追踪 —— 封装浏览器行为

我们先看一个通过监听 mousemove 事件来显示鼠标坐标的例子。

❌ 传统写法(痛点)

通常我们会在组件里写一个 useEffect,里面添加 window.addEventListener,还要记得在组件卸载时 removeEventListener 以防止内存泄漏。如果你有三个组件都需要这个功能,你就得把这段代码复制粘贴三次。

✅ 自定义 Hook 写法

我们可以将这部分逻辑封装成一个名为 useMouse 的 Hook。它的职责非常单一:响应式地返回鼠标的 (x, y) 坐标

import { useState, useEffect } from 'react';

// 封装响应式的 mouse 业务
export const useMouse = () => {
    const [x, setX] = useState(0);
    const [y, setY] = useState(0);

    useEffect(() => {
        const update = (e) => {
            setX(e.pageX);
            setY(e.pageY);
        }
        // 挂载监听
        window.addEventListener('mousemove', update);

        // 清理函数:非常重要!
        // 组件卸载时自动清除事件监听,防止内存泄漏
        return () => {
            window.removeEventListener('mousemove', update);
        }
    }, [])

    // 只返回 UI 需要的数据
    return { x, y }
}

在组件中使用

有了这个 Hook,UI 组件的代码变得异常简单。它完全不需要知道什么是 addEventListener,它只需要通过 useMouse() 获取数据并渲染即可。

function MouseTracker() {
  const { x, y } = useMouse(); // 呼之即来
  
  return (
    <div>
      鼠标当前位置: {x}, {y}
    </div>
  )
}

案例二:Todo List —— 封装复杂的业务逻辑

接下来我们将难度升级。一个完善的待办事项应用通常包含:

  1. CRUD 操作:增加、删除、切换完成状态。
  2. 数据持久化:刷新页面数据不丢失(同步 LocalStorage)。

如果把这些全写在组件里,代码量会瞬间爆炸。让我们来看看如何用 useTodos 来接管这一切。

1. 抽离数据逻辑 (useTodos)

我们把所有的状态管理和数据持久化逻辑都藏在 Hook 内部:

import { useState, useEffect } from 'react';

const STORAGE_KEY = 'todos';

export const useTodos = () => {
    // 初始化时从 LocalStorage 读取数据
    const [todos, setTodos] = useState(() => {
        const stored = localStorage.getItem(STORAGE_KEY);
        return stored ? JSON.parse(stored) : [];
    });

    // 监听 todos 变化,自动同步到 LocalStorage
    useEffect(() => {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(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
    }
}

2. 极简的 UI 组装

现在,我们的主组件只需要做一件事:组装

  • 输入框组件:负责接收用户输入。
  • 列表组件:负责渲染数据。
  • 逻辑层:由 useTodos 提供。
export default function TodoApp() {
  // 一行代码引入所有业务逻辑
  const { todos, addTodo, toggleTodo, deleteTodo } = useTodos();

  return (
    <>
      {/* 传递添加方法给输入组件 */}
      <TodoInput onAddTodo={addTodo} />
      
      {/* 根据数据状态决定渲染内容 */}
      {todos.length > 0 ? (
          <TodoList 
            todos={todos} 
            onDelete={deleteTodo} 
            onToggle={toggleTodo}
          />
      ) : (
          <div>暂无待办事项</div>
      )}
    </>
  )
}

UI 组件(如 TodoListTodoItem)变成了纯粹的展示组件。它们不持有状态,只通过 props 接收数据和回调函数。

// TodoItem 示例:只负责渲染和触发回调
export default function TodoItem({todo, onDelete, onToggle}) {
    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>
    )
}

总结

通过上面两个案例,我们可以看到自定义 Hooks 是前端团队的核心资产。

  1. 代码更清晰:UI 代码只管渲染,Hook 代码只管逻辑,阅读起来一目了然。
  2. 易于维护:如果哪天想把 LocalStorage 换成后端 API 存储,你只需要修改 useTodos 内部的代码,所有使用该 Hook 的组件完全不需要改动。
  3. 防止内存泄漏:像 useMouse 这样涉及事件监听的逻辑,封装在 Hook 内部可以确保清理逻辑(return 函数)被正确执行,不会因为组件卸载而遗漏。

下次当你发现自己在复制粘贴 useEffect,或者组件代码超过几百行时,不妨停下来想一想: “这一段逻辑,是不是可以封装成一个 Hook?”