构建基于功能的 React 模块:使用 TanStack Query 实现文章的 CRUD 操作
按照功能而非文件类型来组织 React 应用,然后使用 TanStack Query 和 axios 实现完整的文章 CRUD 功能,进而明确了解这种架构模式在何种情况下无法继续扩展。
大多数 React 项目都是从以技术角色命名的文件夹开始的:components、hooks、services、types。这样的结构看起来很整洁,但一旦需要修改某个功能,就不得不打开四个目录才能完成操作。基于功能的架构通过按领域而非文件类型来组织代码,从而解决了这个问题。本文将使用 TypeScript、TanStack Query 和 axios,在 DummyJSON 开放 API 的基础上构建一个具备创建、读取、更新和删除功能的文章模块,并客观分析这种架构未能解决的问题。
架构模式解答的两个问题
一旦意识到 React 架构模式是在回答两个不同的问题,它们之间的对比就会变得容易:
- 代码应存储在哪里?基于功能的布局以及功能切片设计(FSD)可以回答这个问题。它们按功能或领域来组织代码。
这些关注点彼此独立,并不冲突。你可以决定文件应放置在哪里,同时也可以独立决定它们之间如何相互导入,正因如此,在实际的代码库中常常会将这些模式结合起来使用。基于功能的组织方式是对第一个问题最简单的解答,也是一个合理的起点。如需更全面的布局介绍,请参阅这篇关于React文件夹结构模式的对比文章。
按文件类型分组为何行不通
许多项目最初采用的都是基于类型的布局:
src/
├── components/ # all components
│ ├── PostList.tsx
│ └── PostForm.tsx
├── hooks/ # all hooks
│ ├── usePosts.ts
│ ├── useCreatePost.ts
│ └── useUpdatePost.ts
├── services/ # all API calls
│ └── postsApi.ts
└── types/ # all types
└── post.ts
虽然这个应用规模不大,但结构还算清晰。现在想象一下你需要修改文章编辑的功能。相关的代码分散在 components/PostForm.tsx、hooks/useUpdatePost.ts、services/postsApi.ts 以及 types/post.ts 中。每次修改都意味着要逐一查看这些文件,而现有的结构根本无法表明哪些文件对应哪种功能。随着功能不断增加,每个文件夹都会变成一个冗长且混杂的列表,要了解某次修改的影响范围也变得越来越困难。
按功能分组
基于功能的组织规则很简单:将某个功能所需的所有内容,包括其组件、钩子、API调用以及类型,都放在同一个文件夹中。在这个例子中,功能涉及文章管理,因此列表展示、详情查看、创建、更新和删除等相关逻辑都放在 features/posts/ 目录下:
src/
├── features/
│ ├── posts/
│ │ ├── api.ts # all posts CRUD
│ │ ├── usePosts.ts # list (Read)
│ │ ├── useCreatePost.ts # Create
│ │ ├── useUpdatePost.ts # Update
│ │ ├── useDeletePost.ts # Delete
│ │ ├── PostList.tsx # list + delete UI
│ │ ├── PostForm.tsx # shared create/edit form
│ │ └── types.ts # post types
│ └── comments/ # other domains grow with the same shape (e.g., comments)
├── shared/
│ ├── api/client.ts # axios instance (baseURL)
│ └── ui/ # shared components used across features
└── app/
├── providers.tsx # QueryClientProvider, etc.
└── main.tsx
目前的编辑流程需要打开一个文件夹。每当出现新的功能模块,比如评论功能,就会为其创建结构相同的独立文件夹。真正用于实现各项功能的代码,例如HTTP客户端或通用UI组件,会被放入shared/目录中;而应用程序的配置部分,比如服务提供者及入口点,则存放在app/目录中。
将文章功能与真实API连接起来
为使结构更清晰,示例中使用的是DummyJSON这一免费的REST API,它提供了文章相关资源(详细文档见DummyJSON的posts页面)。服务器状态则通过TanStack Query来管理。
在测试时有一个重要注意事项:DummyJSON仅模拟添加、更新和删除操作。它会返回看似真实的响应,包括新创建帖子的ID,但实际上不会在服务器上存储任何数据。因此,每次数据变更后该示例都会清除相关的查询缓存,从而触发重新获取数据。使用DummyJSON时,重新获取的数据仍会是未变化的服务器数据;而使用真实后端时,更改内容会得以保留并如预期般显示。
共享的HTTP客户端
所有功能都使用同一个axios实例。该实例用于设置基础URL,同时也是实现拦截器等跨领域功能的理想载体,比如统一的错误处理或添加认证头信息。此处的拦截器只是一个可扩展的占位符。
// shared/api/client.ts
import axios from 'axios';
export const client = axios.create({
baseURL: 'https://dummyjson.com',
});
// cross-cutting concerns like common error handling go here as interceptors
client.interceptors.response.use(
(res) => res,
(error) => Promise.reject(error),
);
帖子领域的类型定义
该功能首先定义了相关类型。Post与DummyJSON返回的数据结构一致,PostsResponse用于封装分页后的列表,CreatePostInput描述了创建操作所需的信息,而UpdatePostInput则使用Partial<Pick<...>>结构,这样更新操作就可以修改标题、内容或两者同时修改。
// features/posts/types.ts
export interface Post {
id: number;
title: string;
body: string;
tags: string[];
reactions: { likes: number; dislikes: number };
views: number;
userId: number;
}
export interface PostsResponse {
posts: Post[];
total: number;
skip: number;
limit: number;
}
export interface CreatePostInput {
title: string;
body: string;
userId: number;
}
export type UpdatePostInput = Partial<Pick<Post, 'title' | 'body'>>;
统一的API对象处理所有CRUD操作
接下来,一个postsApi对象将五种操作集中起来:分页查询(通过limit和skip参数实现)、按ID获取、创建、更新和删除。每个方法都会解包res.data,因此调用者收到的是类型化的领域对象而非axios的响应数据。需要注意的是,创建操作的端点为/posts/add,这是DummyJSON特有的。
// features/posts/api.ts — all of CRUD
import { client } from '@/shared/api/client';
import type {
Post,
PostsResponse,
CreatePostInput,
UpdatePostInput,
} from './types';
export const postsApi = {
// Read (list)
list: (limit = 10, skip = 0) =>
client
.get<PostsResponse>('/posts', { params: { limit, skip } })
.then((res) => res.data),
// Read (detail)
get: (id: number) =>
client.get<Post>(`/posts/${id}`).then((res) => res.data),
// Create
create: (body: CreatePostInput) =>
client.post<Post>('/posts/add', body).then((res) => res.data),
// Update
update: (id: number, body: UpdatePostInput) =>
client.put<Post>(`/posts/${id}`, body).then((res) => res.data),
// Delete
remove: (id: number) =>
client.delete<Post>(`/posts/${id}`).then((res) => res.data),
};
包裹每项操作的钩子函数
每个操作都通过一个钩子来实现。读取操作使用 useQuery;列表钩子会将结果缓存于 ['posts'] 键下。
// features/posts/usePosts.ts — Read
import { useQuery } from '@tanstack/react-query';
import { postsApi } from './api';
export function usePosts() {
return useQuery({
queryKey: ['posts'],
queryFn: () => postsApi.list(),
});
}
写入操作使用 useMutation。创建成功后,该钩子会使 ['posts'] 失效,从而导致已挂载的列表重新获取数据。
// features/posts/useCreatePost.ts — Create
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { postsApi } from './api';
export function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: postsApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
}
删除操作遵循相同的模式,以文章 ID 作为变异变量。
// features/posts/useDeletePost.ts — Delete
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { postsApi } from './api';
export function useDeletePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => postsApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
}
更新钩子的实现方式也相同:调用 postsApi.update,并使受影响文章的列表键和详情键同时失效。随着键的数量增加,功能模块内部的一个小型查询键生成器(例如一个用于生成列表键和详情键的对象)可以确保失效操作的一致性且不会出现拼写错误。
构成这些钩子的组件
组件结构保持简洁,仅通过钩子函数进行组合。列表会为每项显示标题及删除按钮,在查询处理期间则会展示加载提示。
// features/posts/PostList.tsx — list (Read) + delete (Delete)
import { usePosts } from './usePosts';
import { useDeletePost } from './useDeletePost';
export function PostList() {
const { data, isLoading } = usePosts();
const deletePost = useDeletePost();
if (isLoading) return <p>Loading…</p>;
return (
<ul>
{data?.posts.map((post) => (
<li key={post.id}>
<span>{post.title}</span>
<button onClick={() => deletePost.mutate(post.id)}>Delete</button>
</li>
))}
</ul>
);
}
表单会保存标题和内容的本地状态,并提交创建操作,同时在请求处理过程中禁用按钮。按当前实现只能创建帖子;若要将其用于编辑功能,需添加可选的id属性,预先填充字段,并在存在id时调用更新钩子函数。
// features/posts/PostForm.tsx — shared create (Create) / edit (Update) form
import { useState } from 'react';
import { useCreatePost } from './useCreatePost';
export function PostForm() {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const createPost = useCreatePost();
function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
createPost.mutate({ title, body, userId: 1 });
}
return (
<form onSubmit={handleSubmit}>
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title" />
<textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Body" />
<button disabled={createPost.isPending}>
{createPost.isPending ? 'Saving…' : 'Create'}
</button>
</form>
);
}
此处重要的并非各个代码片段本身,而是它们的位置。与帖子相关的类型定义、API调用、钩子函数以及用户界面都位于features/posts/目录内,该文件夹之外的代码无需了解帖子的内部实现方式。
基于功能模块的结构能带来什么优势
- 集中存放。某个功能的所有代码都放在同一个文件夹中,因此处理相关内容时只需打开该文件夹即可。
- 易于移除。删除某个功能通常只需删除其对应的文件夹,再移除一两个路由即可。
- 并行开发。不同团队可以负责不同的功能,从而减少合并冲突和审核时的混乱。
- 意图清晰。顶层文件夹的名称能够直观体现应用程序的功能,这种设计理念常被称为“大声宣告架构”。
一个能进一步强化这些优势的好习惯是为每个功能创建一个index.ts文件,仅导出其他模块可能用到的内容。这样其他模块就可以从features/posts导入,而无需直接访问其内部文件。
该模式的扩展局限
基于功能的布局是良好的开端,但随着应用的发展会出现三个问题。
功能之间的依赖关系难以控制
实际应用中会添加评论、个人资料等功能。如果features/posts导入自features/comments,而comments又反过来导入自posts,就会出现循环依赖,而现有结构无法防止这种情况。文件夹布局仅能说明代码的存放位置,却无法规定哪些功能可以相互依赖。虽然可以通过诸如no-restricted-imports这样的代码检查规则来部分解决这个问题,但这种结构本身并未提供任何相关规则。
共享文件夹变成杂物堆
当开发人员不确定某个文件应放在何处时,它往往会被放入 shared/ 目录。这种情况反复出现后,shared/ 就会充斥着各种互不相关、归属模糊的代码,而每个功能又都必须依赖这些代码,从而在单个目录中重现了原本基于类型的杂乱状况。
缺乏层次概念
有些功能是基础性的,而另一些则是多个基础功能的组合,比如控制面板页面。扁平的 features/ 目录无法体现这种层级关系,因此也就没有明确的结构指引来说明导入方向应如何设定。
简而言之,基于功能的架构虽然能通过按领域分组来体现正确的设计思路,但却无法解决功能之间应如何关联的问题。诸如特征切片设计这样的方法则通过引入标准化的层次结构以及“导入只能指向下层”的规则来解决这一问题。
关键要点
- 将两个问题分开处理:代码存放的位置,以及依赖关系的指向方向。
- 按文件类型分组会导致单个功能分散在多个文件夹中,从而掩盖其边界。
- 将组件、钩子、API调用和类型放在同一个功能文件夹中,有助于更便捷地导航、删除内容并明确团队职责。
- 请将
shared/目录仅用于真正需要跨功能使用的代码,并通过一个简小的公共入口点来访问每个功能。 - 需考虑该模式的局限性:如果没有明确的规则,最终会出现跨功能导入、共享文件夹过于臃肿以及层次结构缺失等问题。