React学习:从TodoList看懂自定义组件的抽离之美

45 阅读5分钟

前言

最近在重温 React,写到 TodoList 这种经典案例时,忽然对 Hooks 有了新的体会。

以前写组件,恨不得把 state、useEffect、业务逻辑全塞进 App.jsx 里,代码写得像一碗拌不开的面条。直到我开始尝试自定义 Hooks,才发现:原来 React 组件也可以写得只剩“皮囊”(UI),而把“灵魂”(逻辑)优雅地抽离出去。

这篇文章分享一下我的学习心得,从一个简单的 useMouse 到完整的 useTodos,聊聊怎么把业务逻辑“拎”出来。


一、 为什么我们需要自定义 Hooks?

在 Hooks 出现之前(或者说在 Vue/React 的早期开发模式中),我们复用UI很容易(写个组件就行),但复用带有状态的逻辑却很麻烦。

你可能遇到过这种情况:

  1. 组件越来越胖,useEffect 里塞满了各种监听器和定时器。
  2. 两个毫不相关的组件,需要用到同一套“倒计时”或者“请求数据”的逻辑,结果只能复制粘贴。

自定义 Hooks 的本质,就是一种函数式编程思想:
它允许我们将 状态(State)生命周期(Lifecycle) 封装进一个函数里。这个函数以 use 开头,呼之即来,挥之即去,互不干扰。


二、 热身:从 useMouse 看生命周期管理

我们先看一个简单的需求:实时显示鼠标的坐标

如果不封装,我们通常会在组件里写 useEffect 监听 mousemove。但如果有 10 个组件都要用这个坐标呢?

1. 封装 useMouse

我们将逻辑剥离到 hooks/useMouse.js 中:

// hooks/useMouse.js
import { useState, useEffect } from "react";

// 这是一个纯粹的逻辑复用,不涉及任何 UI
export const useMouse = function () {
  const [x, setX] = useState(0);
  const [y, setY] = useState(0);

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

    // 1. 挂载时监听
    window.addEventListener("mousemove", update);
    
    // 2. 关键点:组件卸载时的清理!
    // 如果不清理,组件销毁了监听器还在,会导致严重的内存泄漏
    return () => {
      console.log("|||||| 清除监听器");
      window.removeEventListener("mousemove", update);
    };
  }, []); // 空数组表示只在挂载和卸载时执行

  // 向外暴露只读的状态
  return { x, y };
};

2. 思考点:关于内存泄漏

在 React 中,SPA(单页应用)不会自动清理事件监听器。如果你的组件频繁切换(比如 Tab 切换),而你没有在 useEffect 的返回值里解绑事件,浏览器的内存就会被这些无用的监听器占满,导致页面卡顿。

Hook 的哲学:  谁申请,谁治理。


三、 进阶:重构 TodoList —— 逻辑与视图的分离

鼠标位置只是单纯的“读”数据,而 TodoList 涉及到“增删改查”和“数据持久化”。
让我们看看如何把一个复杂的业务逻辑从 App.jsx 中彻底剥离。

1. 抽离业务内核:useTodos

我们在 hooks/useTodos.js 中通过 useState 和 useEffect 组合出一个功能完备的“待办事项管理器”。

这里有两个亮点值得注意:

  1. 惰性初始化:useState(loadFromStorage) 传入的是函数引用,而不是函数调用。这样只会在组件初次渲染时读取 LocalStorage,避免每次 render 都去读 IO,提升性能。
  2. 数据持久化闭环:利用 useEffect 监听 todos 的变化,一旦数据变了,自动同步到本地存储。
// hooks/useTodos.js
import { useState, useEffect } from "react";

const STORAGE_KEY = "todos"; 

export const useTodos = () => {
  // 1. 状态定义(包含初始化逻辑)
  const [todos, setTodos] = useState(loadFromStorage);

  // 辅助函数:从本地读取
  function loadFromStorage() {
    const storedTodos = localStorage.getItem(STORAGE_KEY);
    return storedTodos ? JSON.parse(storedTodos) : [];
  }

  // 2. 副作用:数据变化自动保存
  useEffect(() => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
  }, [todos]); // 依赖项是 todos,数据变了就存

  // 3. 业务动作(Action)
  
  // 新增
  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));
  };

  // 4. 返回接口(Interface)
  return {
    todos,
    addTodo,
    toggleTodo,
    deleteTodo,
  };
};

2. 只有 UI 的 Dumb Components(笨组件)

我们的 UI 组件(TodoInput, TodoList)现在变得非常纯粹,它们不包含任何业务逻辑,只负责:

  1. 接收数据 (props) 渲染页面。
  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)}
        placeholder="请输入待办事项..."
      />
    </form>
  );
}

TodoList.jsx (列表展示):

import TodoItem from "./TodoItem";

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

最后来看看 App.jsx。你会发现它非常干净,它不再是一个处理逻辑的“大杂烩”,而变成了一个指挥官

它只做两件事:

  1. 调用 useTodos 获取数据和方法。
  2. 把这些数据和方法分发给子组件。
// App.jsx
import { useTodos } from "./hooks/useTodos.js";
import TodoList from "./components/TodoList.jsx";
import TodoInput from "./components/TodoInput.jsx";
import "./App.css"; // 假设有简单的样式

export default function App() {
  // 核心逻辑一行代码搞定!
  const { todos, addTodo, deleteTodo, toggleTodo } = useTodos();

  return (
    <div className="app-container">
      <h1>My Todo List</h1>
      
      <TodoInput onAddTodo={addTodo} />
      
      {todos.length > 0 ? (
        <TodoList
          todos={todos}
          onDeleteTodo={deleteTodo}
          onToggleTodo={toggleTodo}
        />
      ) : (
        <div className="empty-tip">暂无待办事项,喝杯咖啡吧 ☕️</div>
      )}
    </div>
  );
}

四、 总结与深度思考

通过这次重构,我们实际上是在实践前端工程化中非常重要的关注点分离(Separation of Concerns)

  1. 逻辑复用性:如果明天老板让你做一个“回收站”功能,也需要管理一堆列表数据,你的 useTodos 里的增删改查逻辑稍作修改就可以直接复用,甚至可以把 useTodos 改名为更通用的 useList。
  2. 可测试性:测试 UI 很麻烦,但测试逻辑很简单。我们可以单独针对 useTodos.js 写单元测试,而不需要去点击页面上的按钮。
  3. 团队资产:像 useMouse、useLocalStorage、useRequest 这种 Hook,写好一次,整个团队都能用。这就是前端团队的核心资产。