首页 / 文章

构建基于功能的 React 模块:使用 TanStack Query 实现文章的 CRUD 操作

按照功能而非文件类型来组织 React 应用,然后使用 TanStack Query 和 axios 实现完整的文章 CRUD 功能,进而明确了解这种架构模式在何种情况下无法继续扩展。

发布者:REACTAPP.TOP阅读约 10 分钟2263 词

来源: React Architecture — The Feature-Based Pattern

大多数 React 项目都是从以技术角色命名的文件夹开始的:components、hooks、services、types。这样的结构看起来很整洁,但一旦需要修改某个功能,就不得不打开四个目录才能完成操作。基于功能的架构通过按领域而非文件类型来组织代码,从而解决了这个问题。本文将使用 TypeScript、TanStack Query 和 axios,在 DummyJSON 开放 API 的基础上构建一个具备创建、读取、更新和删除功能的文章模块,并客观分析这种架构未能解决的问题。

架构模式解答的两个问题

一旦意识到 React 架构模式是在回答两个不同的问题,它们之间的对比就会变得容易:

  • 代码应存储在哪里?基于功能的布局以及功能切片设计(FSD)可以回答这个问题。它们按功能或领域来组织代码。
  • 依赖关系指向何处?整洁架构给出了答案。纯粹的业务逻辑位于中心,而外部层,如React组件和API客户端,则依赖它,而非相反。
  • 这些关注点彼此独立,并不冲突。你可以决定文件应放置在哪里,同时也可以独立决定它们之间如何相互导入,正因如此,在实际的代码库中常常会将这些模式结合起来使用。基于功能的组织方式是对第一个问题最简单的解答,也是一个合理的起点。如需更全面的布局介绍,请参阅这篇关于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/目录仅用于真正需要跨功能使用的代码,并通过一个简小的公共入口点来访问每个功能。
    • 需考虑该模式的局限性:如果没有明确的规则,最终会出现跨功能导入、共享文件夹过于臃肿以及层次结构缺失等问题。