告别"胖组件",拥抱高内聚、可复用的工程化思维
你是否也经历过这样的场景:一个看似简单的 Todo 应用,其状态逻辑却像一团乱麻,分散在组件的各个生命周期和事件处理函数中?useState、useEffect 和复杂的 JSX 逻辑交织在一起,让组件变得臃肿且难以维护。
说白了,这不仅仅是代码组织的问题,更是架构思想的缺失。
在 React 的世界里, "逻辑与视图分离" 是永恒的追求。而要实现这一点,仅靠组件自身的拆分是远远不够的,我们还需要合理的项目架构 和恰当的逻辑抽象 。今天,我们将结合一个典型的 React + TypeScript 项目结构,从零开始构建一个 Todo 应用,重点剖析其架构设计,并一步步抽离核心业务逻辑,封装成高内聚、可复用的 useTodos Hook。
读完这篇文章,你将收获:
- 一个清晰、可落地的 React 项目架构方案
- 封装自定义 Hook 的完整思路与最佳实践
- 将"架构思想"转化为实际代码的能力
一、先看骨架:一个"长得好"的项目应该长什么样?
在动手写代码之前,我们先来审视一个健康的 React + TypeScript 项目应该具备怎样的目录结构。这就像建房子,地基和框架决定了上层建筑能盖多高。
以下是一个经过实战检验的目录结构:
python
src/
├── api/ # 🔌 所有与后端交互的接口请求
│ ├── client.ts # axios/fetch 实例封装
│ └── todo.ts # Todo 相关的 API 请求函数
├── assets/ # 🎨 静态资源(图片、字体、全局样式变量等)
│ ├── images/
│ └── styles/
├── components/ # 🧩 可复用的 UI 组件
│ ├── TodoFilter/ # 每个组件独立文件夹,包含其专属样式和测试
│ │ ├── TodoFilter.tsx
│ │ ├── TodoFilter.module.css
│ │ └── index.ts
│ ├── TodoInput/
│ ├── TodoList/
│ └── TodoItem/
├── hooks/ # 🪝 自定义 Hooks(逻辑复用的核心)
│ ├── useTodos.ts # Todo 业务逻辑
│ └── useLocalStorage.ts # 通用工具 Hook
├── types/ # 📝 全局类型定义(数据蓝图)
│ ├── todo.ts
│ └── common.ts
├── utils/ # 🔧 工具函数库(纯函数)
│ ├── format.ts
│ └── validator.ts
├── App.tsx # 应用根组件(组合各部分的胶水层)
├── App.css
├── main.tsx # 应用入口
└── index.css # 全局样式
为何要这样组织? 这背后的核心思想是 "关注点分离" :
api/:负责数据来源,不管是请求后端接口还是本地存储,都收敛于此。组件不需要关心数据从哪来,只需要调用 API 函数即可。components/:纯 UI 层,只关心"长什么样"和"用户操作如何触发事件",不包含任何业务逻辑和数据状态管理。这也符合原子设计理论,便于组合和测试。hooks/:业务逻辑的"大脑"。它是连接数据(API/状态)和视图(组件)的桥梁,也是我们实现逻辑复用的利器。types/:TypeScript 的威力所在。统一维护类型定义,能有效避免类型"飘散"在项目的各个角落,让协作和维护变得轻松。
"好的架构不是一蹴而就的,而是随着对业务理解的加深,不断演进出来的。" 但一个清晰的起点,决定了项目能走多远。
二、定义我们的"数据蓝图"(Types)
在动手写逻辑之前,我们先要明确数据的形状。TypeScript 是我们坚实的后盾,它能帮我们在编译阶段就避免大量低级错误。
在 /src/types/todo.ts 文件中,我们定义好 Todo 项和过滤条件的类型:
typescript
// src/types/todo.ts
export type FilterType = 'all' | 'active' | 'completed';
export interface Todo {
id: string;
text: string;
completed: boolean;
}
有了这份清晰的"数据蓝图",我们的后续开发将更加稳健和高效。
三、将"大脑"装进 Hook(核心逻辑抽离)
现在,我们开始实现核心的业务逻辑 ------ useTodos Hook。它就像一个独立的"大脑",掌管着所有 Todo 数据的变化和派生。
typescript
// src/hooks/useTodos.ts
import { useState } from 'react';
import type { Todo, FilterType } from '../types/todo';
export function useTodos() {
// 1. 定义状态:数据源和过滤条件
const [todos, setTodos] = useState<Todo[]>([]);
const [filter, setFilter] = useState<FilterType>('all');
// 2. 定义状态更新函数(业务逻辑)
const addTodo = (text: string) => {
if (!text.trim()) return; // 防御性编程,过滤空输入
const newTodo: Todo = {
id: Date.now().toString(),
text: text.trim(),
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));
};
const clearCompleted = () => {
setTodos((prev) => prev.filter((item) => !item.completed));
};
// 3. 计算派生状态(Derived State)
// 这是一个计算属性,根据当前 filter 和 todos 实时计算出要展示的列表
const filteredTodos = todos.filter((todo) => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true; // 'all'
});
// 4. 返回"大脑"的接口,供视图层使用
return {
todos, // 原始数据
filteredTodos, // 处理后的数据,直接用于渲染
filter,
setFilter, // 暴露设置过滤条件的方法
addTodo,
toggleTodo,
deleteTodo,
clearCompleted,
};
}
这里有三个关键的设计细节,值得你细细品味:
- 函数式更新 (
setTodos(prev => ...)) : 这是保证状态更新安全性的最佳实践。它确保我们的更新操作基于的是最新的状态值,有效避免了因异步更新或批处理导致的"过时闭包"问题。 - 派生状态的计算 : 我们将
filteredTodos的计算逻辑从组件中移到了 Hook 内部。这使得组件层只需要消费数据,而无需关心数据是如何被过滤的,逻辑更集中。 - 清晰的接口设计: Hook 返回的对象明确地暴露了视图层需要的数据和操作函数,形成了一个清晰的内聚单元。
四、在组件中"即插即用"
有了 useTodos 这个强大的 Hook,我们的视图组件(App.tsx)变得极其简洁、清爽。它只负责两件事:渲染 UI 和 响应用户操作。
javascript
// src/App.tsx
import { useTodos } from './hooks/useTodos';
import { TodoList } from './components/TodoList';
import { TodoFilter } from './components/TodoFilter';
import './App.css';
function App() {
// ✅ 一行代码,引入所有业务逻辑
const {
filteredTodos,
filter,
setFilter,
addTodo,
toggleTodo,
deleteTodo,
clearCompleted,
} = useTodos();
return (
<div className="todo-app">
<h1>我的待办</h1>
{/* 输入框组件,调用 addTodo */}
<TodoInput onAdd={addTodo} />
{/* 列表组件,仅负责渲染 filteredTodos 并触发操作 */}
<TodoList
todos={filteredTodos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
{/* 底部操作栏 */}
<TodoFooter
filter={filter}
onFilterChange={setFilter}
onClearCompleted={clearCompleted}
/>
</div>
);
}
export default App;
组件从"如何管理数据"的复杂逻辑中解耦 出来,变得更加纯净 和易于测试。你可以很轻松地为一个组件替换不同的数据源或逻辑实现,只要它们符合相同的 Hook 接口。
五、架构如何演进?聊聊 API 层的插入
回到我们一开始的架构图,如果现在要求 Todo 数据来自后端 API,而不是本地状态,我们的架构会如何应对?
答案是:只需修改 useTodos Hook 内部实现,App.tsx 和其他 UI 组件完全不用动。
javascript
// src/hooks/useTodos.ts
import { useState, useEffect } from 'react';
import { fetchTodos, createTodo, updateTodo, deleteTodo } from '../api/todo';
// ... 其余逻辑
export function useTodos() {
const [todos, setTodos] = useState<Todo[]>([]);
// ... filter 状态
// 组件挂载时拉取数据
useEffect(() => {
fetchTodos().then(setTodos);
}, []);
// addTodo 改为调用 API
const addTodo = async (text: string) => {
if (!text.trim()) return;
const newTodo = await createTodo({ text: text.trim(), completed: false });
setTodos((prev) => [...prev, newTodo]);
};
// toggleTodo、deleteTodo 也做类似改造...
// 但返回给组件的方法名和参数保持不变!
}
你会发现,架构的分层带来了极强的灵活性。UI 层、逻辑层、数据层各司其职,修改其中一层,其他层不会受到牵连。
总结:从 Todo 到任意业务逻辑
今天我们以一个标准的 React + TypeScript 项目结构为起点,完成了一次从架构设计 到自定义 Hook 封装的完整实战演练。
- 合理的项目架构 (
api/components/hooks/types)是团队协作和项目长期维护的基石。 - 自定义 Hook 则是我们实现逻辑复用和视图解耦的核心武器。
这个小而美的重构,不仅仅是代码行数的减少,更是一次设计思想的升级。它让你的代码结构更清晰,逻辑更内聚,为后续的迭代和协作打下了坚实的基础。
从今天开始,当你在组件中写下第一个 useState 时,不妨问自己一句:"这部分逻辑,是不是可以放到一个 Hook 里?" 当你在 src 下新建文件夹时,想一想:"这个文件,放在哪个目录下最能体现它的职责?"
你的代码,乃至你的未来,都会感谢你。