组件拆了,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 就是这个切配员------没有它,厨师就得在灶台和冰箱之间跑来跑去。
先看架构全景图:
数据流清晰无比: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在组件渲染闭包里是一个常量。如果在同一事件里连续调用addTodo和deleteTodo,setTodos([...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 的终极目标:
架构对比:拆组件 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 组件时,就做这一件事
- 在写任何 JSX 之前,先把所有业务逻辑写进
use[Feature].ts - 组件只通过 props 接收数据、通过回调通知事件------不直接操作全局状态
- 一个组件如果出现了除输入框之外的
useState,问自己:"这个状态只有这个组件用吗?" 不是 → 提到 Hook 里
开放问题:你有没有遇到过"组件拆得很干净,但改一个功能还是得改三四个文件"的情况?你觉得是哪里出了问题------是 Hook 拆分不够细,还是组件拆分的维度不对?评论区聊聊你的踩坑经历。