组件拆了,逻辑没拆——自定义 Hook 才是 React 业务逻辑的正当归属

组件拆了,App.tsx 还是 300 行?把业务逻辑装进自定义 Hook


开篇:你把组件拆了,然后呢?

看看这个 React 项目的目录结构,非常标准:

bash 复制代码
src/
├── types/todo.ts          # 类型定义
├── hooks/useTodos.ts      # 自定义 Hook
├── components/
│   ├── TodoInput.tsx      # 输入组件
│   ├── TodoItem.tsx       # 待办条目
│   ├── TodoList.tsx       # 列表组件
│   └── TodoFilter.tsx     # 筛选组件
├── App.tsx
└── main.tsx

类型、Hook、组件,三层分离------看起来很干净。

但大多数 React 初学者的项目长这样:

css 复制代码
src/
├── App.tsx    ← 300 行,状态、CRUD、筛选全塞在这里
├── TodoInput.tsx
└── TodoItem.tsx

组件是拆了------但只拆了 JSX。真正的业务逻辑(useState、增删改查、筛选过滤)还全堆在 App.tsx 里。 你以为你在做组件化,其实你在给 JSX 搬家。

这篇文章用一个 Todo 项目演示:怎么把"逻辑"从组件里拎出来,放进自定义 Hook,让组件只做一件事------渲染 UI。


核心概念:自定义 Hook = React 项目的"逻辑层"

自定义 Hook 本质上是一个"装了 React 超能力的纯逻辑函数"------它能用 useState、useEffect、useContext 等所有 Hooks,但不产出任何 JSX。它是组件和状态之间的一层隔离。

用一个比喻:

perl 复制代码
厨师(组件)     → 负责摆盘、上菜(渲染 UI)
切配员(Hook)   → 负责洗菜、切菜、备料(准备数据 + 操作逻辑)
冰箱(state)    → 存食材(存数据)

厨师不需要自己翻冰箱、洗菜、切肉。他只管告诉切配员"给我一份宫保鸡丁的料",然后专心炒菜摆盘。自定义 Hook 就是这个切配员------没有它,厨师就得在灶台和冰箱之间跑来跑去。

先看架构全景图:

graph TD A["App.tsx ─ 组装层"] --> B["useTodos Hook"] A --> C["TodoInput 组件"] A --> D["TodoList 组件"] A --> E["TodoFilter 组件"] B --> F["types/todo.ts"] C -->|"onAdd"| B D -->|"onToggle / onDelete"| B E -->|"onFilterChange"| B B -->|"todos, filter"| A A -->|"props 下发"| C A -->|"props 下发"| D A -->|"props 下发"| E

数据流清晰无比:Hook 是唯一的"数据源 + 操作中心",App 只负责分发,组件只负责渲染。


第一步:TypeScript 先给数据"定规矩"

写任何逻辑之前,先定义数据结构。这是 TypeScript + React 的第一课------类型先行:

typescript 复制代码
// types/todo.ts
// 🔑 interface 定义对象形状------每个 Todo 长什么样
export interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

// 🔑 type 定义联合类型------Filter 只能是这三个值之一
// ⚠️ 易错:用 string 代替联合类型。string 能编译通过,
// 但打字 'compleated' 也不会报错,运行时筛选不生效,查半天
export type Filter = 'all' | 'completed' | 'uncompleted';

这两行类型是整个项目的"宪法"。后面的所有代码都基于它们写,TypeScript 在你偏离约定时立刻报错。

interface 和 type 什么时候用哪个?

interface type
适合 描述对象形状 联合类型、交叉类型、基本类型别名
本项目 Todo 是对象 → interface Filter 是三选一 → type
可互换吗? 大部分场景可以,但联合类型只能用 type ---

简单记忆:对象形状用 interface,字符串联合用 type


第二步:把全套业务逻辑装进一个 Hook

useTodos 是这个项目的"大脑"。它拥有所有状态、所有操作方法。组件只需要从它拿数据和操作方法,别的什么都不管。

typescript 复制代码
// hooks/useTodos.ts ── 项目的"逻辑层"
import { useState } from 'react';
import type { Todo, Filter } from '../types/todo';

export function useTodos() {
  const [todos, setTodos] = useState<Todo[]>([]);     // 🔑 泛型约束:只能是 Todo 数组
  const [filter, setFilter] = useState<Filter>('all'); // 🔑 必须符合 Filter 联合类型

  // 新增待办
  const addTodo = (text: string) => {
    if (!text.trim()) return; // ⚠️ 空字符串或纯空格拦截

    const newTodo: Todo = {
      id: Date.now().toString(), // 🔑 时间戳做唯一 ID,简易版(生产环境推荐 nanoid)
      text,
      completed: false,
    };

    setTodos(prev => [...prev, newTodo]); // ✅ 函数式更新,避免闭包过期
  };

  // 切换完成状态
  const toggleTodo = (id: string) => {
    setTodos(prev =>
      prev.map(item =>
        item.id === id
          ? { ...item, completed: !item.completed } // 🔑 不可变更新:展开 + 覆盖
          : item
      )
    );
  };

  // 删除待办
  const deleteTodo = (id: string) => {
    setTodos(prev => prev.filter(item => item.id !== id)); // filter 返回新数组
  };

  // 清除已完成
  const clearCompleted = () => {
    setTodos(prev => prev.filter(item => !item.completed));
  };

  return {
    todos,
    filter,
    addTodo,
    toggleTodo,
    deleteTodo,
    clearCompleted,
  };
}

每个操作都用了函数式更新(prev => ...),而不是直接传值。 原因是:

⚠️ todos 在组件渲染闭包里是一个常量。如果在同一事件里连续调用 addTododeleteTodosetTodos([...todos, newTodo]) 里的 todos 可能还是上一帧的旧值。函数式更新保证 prev 永远是 React 内部的最新快照。


第三步:组件只剩骨架------薄到只剩 return

有了 useTodos,组件变成了纯粹的"UI 壳"。先看 TodoInput:

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

interface TodoInputProps {
  onAdd: (text: string) => void; // 🔑 组件只定义"能发出什么事件",不定义"事件后发生什么"
}

export function TodoInput({ onAdd }: TodoInputProps) {
  const [text, setText] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    onAdd(text);  // 交给 Hook 处理
    setText('');  // 清空自己的本地状态
  };

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

注意这个组件做了什么、没做什么:

做了(UI 职责) 没做(逻辑职责,交给 Hook)
管理输入框的本地 text 不关心待办怎么存的
提交时调用 onAdd(text) 不关心 ID 怎么生成
提交后清空输入框 不关心更新是追加还是替换

组件的 props 只定义"它能发出什么事件"(onAdd: (text: string) => void),不定义"事件发生后会发生什么"。 这就是受控组件的极致------给什么 props 渲染什么,出了事喊一声(回调),剩下的不管。

TodoItem 同理,一行自己的 state 都没有:

tsx 复制代码
// components/TodoItem.tsx
interface TodoItemProps {
  todo: Todo;
  onToggle: (id: string) => void;
  onDelete: (id: string) => void;
}

export function TodoItem({ todo, onToggle, onDelete }: TodoItemProps) {
  return (
    <li className={todo.completed ? 'completed' : ''}>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
      />
      <span>{todo.text}</span>
      <button onClick={() => onDelete(todo.id)}>删除</button>
    </li>
  );
}

组件里没有 useState、没有 useEffect、没有任何业务判断。它就是一个"纯渲染函数"。


第四步:App.tsx 变成"组装工"

所有逻辑在 Hook 里,所有渲染在组件里。App 干什么?把 Hook 和组件接起来:

tsx 复制代码
// App.tsx ── 唯一职责:把 Hook 的数据和操作分发给组件
import { useTodos } from './hooks/useTodos';
import { TodoInput } from './components/TodoInput';
import { TodoList } from './components/TodoList';
import { TodoFilter } from './components/TodoFilter';

function App() {
  const { todos, filter, addTodo, toggleTodo, deleteTodo, clearCompleted } = useTodos();

  return (
    <div className="app">
      <h1>待办事项</h1>
      <TodoInput onAdd={addTodo} />
      <TodoList
        todos={todos}
        filter={filter}
        onToggle={toggleTodo}
        onDelete={deleteTodo}
      />
      <TodoFilter
        current={filter}
        onChange={setFilter}
        onClearCompleted={clearCompleted}
      />
    </div>
  );
}

App.tsx 从 300 行的"全能控制器"变成了 20 行的"接线板"。 这就是自定义 Hook 的终极目标:

graph LR A[&#34;useTodos Hook&#34;] -->|&#34;数据 + 方法&#34;| B[&#34;App.tsx 组装层&#34;] B -->|&#34;props&#34;| C[&#34;TodoInput&#34;] B -->|&#34;props&#34;| D[&#34;TodoList&#34;] B -->|&#34;props&#34;| E[&#34;TodoFilter&#34;]

架构对比:拆组件 vs 拆逻辑

用一张表总结两种组织方式的天壤之别:

维度 只拆组件(❌ 常见错误) 组件 + Hook 双层拆分(✅ 正确)
App.tsx 行数 200-300 行 20-30 行
状态在哪 散落在 App 和各个组件里 集中在 useTodos
新增一个操作 找到所有相关组件分别改 只在 useTodos 加一个函数
单元测试 必须 mount 整个组件 只测 useTodos 一个函数即可
换 UI 框架 逻辑和 JSX 缠绕,重写 Hook 原样保留,只换组件
复用 Copy-paste 代码块 直接 import { useTodos }

结论:拆组件解决的是"看起来不乱",拆 Hook 解决的是"改起来不累"。


常见的三个"该不该放 Hook"疑问

Q1:筛选逻辑应该放 Hook 里还是组件里?

放 Hook 里。 筛选是"数据的推导",属于业务逻辑。组件只管"拿到最终数据然后渲染"。

typescript 复制代码
// ✅ 在 Hook 里做筛选,对外只暴露 filteredTodos
export function useTodos() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [filter, setFilter] = useState<Filter>('all');

  // 🔑 派生状态:不在 state 里存第二份,而是在 return 前算出来
  const filteredTodos = todos.filter(item => {
    if (filter === 'all') return true;
    if (filter === 'completed') return item.completed;
    return !item.completed; // 'uncompleted'
  });

  return { todos: filteredTodos, filter, /* ... */ };
}

⚠️ 不要filteredTodos 存成另一个 useState。它完全由 todos + filter 算出,存两份数据只会带来同步 bug。这就是"派生状态"------能算出来的就不要存。

Q2:输入框的本地 state 该放 Hook 还是组件?

放组件。 TodoInput 里的 text 是"输入框自己的事",别的组件不关心。Hook 只需要知道"用户提交了什么",不需要知道"用户打了什么字"。

判断标准一句话只有多个组件需要共享的状态,才放进 Hook。

Q3:什么时候该把 useState 升级成 useReducer?

当前 useTodos 用 4 个函数各自管理状态,已经有点"散"的味道了。如果再加功能------批量删除、撤销、拖拽排序------每个新功能都要加一个新的 setState 操作函数。

升级成 useReducer 后:

typescript 复制代码
type TodoAction =
  | { type: 'ADD'; text: string }
  | { type: 'TOGGLE'; id: string }
  | { type: 'DELETE'; id: string }
  | { type: 'CLEAR_COMPLETED' };

function todoReducer(state: Todo[], action: TodoAction): Todo[] {
  switch (action.type) {
    case 'ADD':
      return [...state, { id: Date.now().toString(), text: action.text, completed: false }];
    case 'TOGGLE':
      return state.map(item =>
        item.id === action.id ? { ...item, completed: !item.completed } : item
      );
    case 'DELETE':
      return state.filter(item => item.id !== action.id);
    case 'CLEAR_COMPLETED':
      return state.filter(item => !item.completed);
    default:
      return state;
  }
}

判断升级时机 :当 Hook 里出现了 3 个以上互相独立的 setState 调用,且操作逻辑多到"看一遍记不全有哪些操作"时,就该升级 useReducer。


结尾:下次写组件之前,先写 Hook

开篇问:为什么组件拆了,App.tsx 还是 300 行?

因为你拆的是 UI,不是逻辑。真正的重构是:把 useState 和业务函数从组件里搬出来,放进自定义 Hook。组件变成纯粹的"渲染函数",App 变成"接线板"。

用一句话记住:Hook 负责"数据怎么变",组件负责"长得怎么样",App 负责"谁能看到谁"。三层各管各的,改任何一层不牵连另外两层。

下次写 React 组件时,就做这一件事

  1. 在写任何 JSX 之前,先把所有业务逻辑写进 use[Feature].ts
  2. 组件只通过 props 接收数据、通过回调通知事件------不直接操作全局状态
  3. 一个组件如果出现了除输入框之外的 useState,问自己:"这个状态只有这个组件用吗?" 不是 → 提到 Hook 里

开放问题:你有没有遇到过"组件拆得很干净,但改一个功能还是得改三四个文件"的情况?你觉得是哪里出了问题------是 Hook 拆分不够细,还是组件拆分的维度不对?评论区聊聊你的踩坑经历。


相关推荐
小月土星1 小时前
自定义业务 Hook:从零解剖一个 React + TypeScript Todo 应用:用金字塔思维看透前端架构
react.js·架构·前端框架
Goodbye1 小时前
React 核心概念实战:从组件化思维到本地存储持久化
react.js
触底反弹1 小时前
🔥 别再只用 useRef 拿 DOM 了!这 5 个实战场景让你彻底理解它(附源码解析 + 面试题)
前端·javascript·react.js
BreezeJiang1 小时前
Web Worker 不负责渲染:React 中的线程分工与消息闭环
javascript·react.js
烬羽1 小时前
useContext 用是用了,但你真的用对了吗?——把 Context 封装进自定义 Hook
前端·react.js·全栈
何时梦醒1 小时前
🎯 从零彻底搞懂 React Context API —— 一篇带你穿越"组件树"的状态共享方案
前端·javascript·react.js
BreezeJiang1 小时前
不要把 Context 当万能状态库:主题共享和鼠标 Hook 应该这样拆
javascript·react.js
AI 小老六1 小时前
Brainstorming 与 grill-me 在 AI 产品设计和工程决策中的分工边界
人工智能·ai·架构·创业创新
想要成为糕糕手1 小时前
🧵 浏览器里的第二大脑:Web Worker
javascript·react.js·浏览器