从混乱到清晰:项目架构与自定义 Hook 的双重实践

📅 2026/8/13 10:34:31
从混乱到清晰:项目架构与自定义 Hook 的双重实践
告别“胖组件”拥抱高内聚、可复用的工程化思维你是否也经历过这样的场景一个看似简单的 Todo 应用其状态逻辑却像一团乱麻分散在组件的各个生命周期和事件处理函数中useState、useEffect和复杂的 JSX 逻辑交织在一起让组件变得臃肿且难以维护。说白了这不仅仅是代码组织的问题更是架构思想的缺失。在 React 的世界里“逻辑与视图分离”是永恒的追求。而要实现这一点仅靠组件自身的拆分是远远不够的我们还需要合理的项目架构和恰当的逻辑抽象。今天我们将结合一个典型的 React TypeScript 项目结构从零开始构建一个 Todo 应用重点剖析其架构设计并一步步抽离核心业务逻辑封装成高内聚、可复用的useTodosHook。读完这篇文章你将收获一个清晰、可落地的 React 项目架构方案封装自定义 Hook 的完整思路与最佳实践将“架构思想”转化为实际代码的能力一、先看骨架一个“长得好”的项目应该长什么样在动手写代码之前我们先来审视一个健康的 React TypeScript 项目应该具备怎样的目录结构。这就像建房子地基和框架决定了上层建筑能盖多高。以下是一个经过实战检验的目录结构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 项和过滤条件的类型// src/types/todo.ts export type FilterType all | active | completed; export interface Todo { id: string; text: string; completed: boolean; }有了这份清晰的“数据蓝图”我们的后续开发将更加稳健和高效。三、将“大脑”装进 Hook核心逻辑抽离现在我们开始实现核心的业务逻辑 ——useTodosHook。它就像一个独立的“大脑”掌管着所有 Todo 数据的变化和派生。// src/hooks/useTodos.ts import { useState } from react; import type { Todo, FilterType } from ../types/todo; export function useTodos() { // 1. 定义状态数据源和过滤条件 const [todos, setTodos] useStateTodo[]([]); const [filter, setFilter] useStateFilterType(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和响应用户操作。// 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 classNametodo-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而不是本地状态我们的架构会如何应对答案是只需修改useTodosHook 内部实现App.tsx 和其他 UI 组件完全不用动。// src/hooks/useTodos.ts import { useState, useEffect } from react; import { fetchTodos, createTodo, updateTodo, deleteTodo } from ../api/todo; // ... 其余逻辑 export function useTodos() { const [todos, setTodos] useStateTodo[]([]); // ... 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下新建文件夹时想一想“这个文件放在哪个目录下最能体现它的职责”你的代码乃至你的未来都会感谢你。