如何在 React 中向 API 发出异步请求

2025-06-04

如何在 React 中向 API 发出异步请求

这是 React 新手在开发新项目时经常遇到的问题。我会在这里向你展示如何操作,以及一个可以帮助你编写更好、更简洁的代码(附带测试!)的方法。

假设我们正在开发一个新的博客应用,它将根据 API 的响应渲染一个简单的文章列表。通常我们会这样写:

import { useEffect, useState } from 'react';

import axios from 'axios';

import { Post } from '../../types/post';
import Pagination from '../Pagination/Pagination';
import PostCard from '../PostCard/PostCard';

const DirBlogPosts: React.FC = () => {
  const [page, setPage] = useState<number>(1);
  const [posts, setPosts] = useState<Array<Post>>([]);
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [isError, setIsError] = useState<boolean>(false);

  useEffect(() => {
    (async () => {
      try {
        setIsLoading(true);
        const { data } = await axios.get<Array<Post>>('https://example.com/posts', {
          params: { page },
        });
        setPosts(data);
      } catch (error) {
        setIsError(true);
      } finally {
        setIsLoading(false);
      }
    })();
  }, [page]);

  if (isLoading) {
    return <p>Loading posts...</p>;
  }

  if (isError) {
    return <p>There was an error trying to load the posts.</p>;
  }

  return (
    <div>
      {posts.map((post) => (
        <PostCard post={post} />
      ))}
      <Pagination page={page} onChangePage={setPage} />
    </div>
  );
};

export default DirBlogPosts;
Enter fullscreen mode Exit fullscreen mode

这里我们有状态page。这些状态在组件首次渲染或 发生更改时posts更新isLoadingisErrorpage

你能看出这里的问题吗?

  1. 我们的组件内部有所有的获取逻辑;
  2. 我们需要手动控制许多状态;
  3. 创建自动化测试很困难。

但我们可以尝试采用不同的方法并创建更清晰的代码。

构建您的服务

首先,利用 Typescript 的特性,我们来定义一下什么是帖子:

// src/types/post.ts
export type Post = {
  id: number;
  title: string;
  imageUrl: string;
  content: string;
};
Enter fullscreen mode Exit fullscreen mode

该帖子基本上是一个具有id、、和的对象titleimageUrlcontent

现在我们可以创建“列表帖子服务”的定义:

// src/services/definitions/list-posts-service.ts
import { Post } from '../../types/post';

export interface ListPostsService {
  list(params: ListPostsService.Params): Promise<ListPostsService.Result>;
}

export namespace ListPostsService {
  export type Params = {
    page?: number;
  };

  export type Result = Array<Post>;
}
Enter fullscreen mode Exit fullscreen mode

这里我们定义“列表帖子服务”实现应该有一个名为的方法list,它将接收定义的参数并返回定义的结果。

为什么我们要为此创建一个界面?

答案很简单:我们的组件将接收并执行此服务。组件甚至不需要知道您将使用 Axios 还是 Fetch。假设您的组件是不可知的。稍后您可能需要将 Axios 更改为 Fetch,甚至使用 Redux。

那么,让我们构建我们的 Axios 服务实现:

// src/services/implementation/axios-list-posts-service.ts
import { AxiosInstance } from 'axios';

import { Post } from '../../types/post';
import { ListPostsService } from '../definitions/list-posts-service';

export default class AxiosListPostsService implements ListPostsService {
  constructor(private readonly axiosInstance: AxiosInstance) {}

  async list(params: ListPostsService.Params): Promise<ListPostsService.Result> {
    const { data } = await this.axiosInstance.get<Array<Post>>('/posts', {
      params: { page: params.page },
    });

    return data;
  }
}
Enter fullscreen mode Exit fullscreen mode

这是我们使用 Axios 的实现。我们需要在构造函数中使用 Axios 实例,并在方法中list向端点发出请求/posts

由于我们已经在开发这项服务,因此我们还要创建一个模拟版本以供测试使用:

import faker from 'faker';
import lodash from 'lodash';

import { ListPostsService } from './list-posts-service';

export const mockListPostsServicesResult = (): ListPostsService.Result => {
  return lodash.range(10).map((id) => ({
    id,
    title: faker.lorem.words(),
    content: faker.lorem.paragraphs(),
    imageUrl: faker.internet.url(),
  }));
};

export class ListPostsServiceSpy implements ListPostsService {
  params: ListPostsService.Params;

  result: ListPostsService.Result = mockListPostsServicesResult();

  async list(params: ListPostsService.Params): Promise<ListPostsService.Result> {
    this.params = params;

    return this.result;
  }
}
Enter fullscreen mode Exit fullscreen mode

我们只需要将参数和模拟结果存储在类中,以便稍后使用 Jest 进行测试。对于模拟数据,我喜欢使用 Faker.js 库。

构建干净的组件

为了管理我们可能需要的所有加载和错误状态,我喜欢使用 React Query 库。您可以阅读文档,了解如何将其添加到项目中的所有细节。基本上,您只需添加一个自定义提供程序来包装您的应用,因为 React Query 还管理请求的缓存。

import { useState } from 'react';

import { useQuery } from 'react-query';

import { ListPostsService } from '../../services/definitions/list-posts-service';
import Pagination from '../Pagination/Pagination';
import PostCard from '../PostCard/PostCard';

type CleanBlogPostsProps = {
  listPostsService: ListPostsService;
};

const CleanBlogPosts: React.FC<CleanBlogPostsProps> = ({ listPostsService }) => {
  const [page, setPage] = useState<number>(1);
  const {
    data: posts,
    isLoading,
    isError,
  } = useQuery(['posts', page], () => listPostsService.list({ page }), { initialData: [] });

  if (isLoading) {
    return <p data-testid="loading-posts">Loading posts...</p>;
  }

  if (isError) {
    return <p data-testid="loading-posts-error">There was an error trying to load the posts.</p>;
  }

  return (
    <div>
      {posts!.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
      <Pagination page={page} onChangePage={setPage} />
    </div>
  );
};

export default CleanBlogPosts;
Enter fullscreen mode Exit fullscreen mode

现在你看清楚了吗?因为useQuery我们拥有了所有需要的状态:数据、加载和错误状态。你不再需要使用 了useEffect。 中的第一个参数useQuery可以是字符串或数组。当我将 包含page在这个数组中时,这意味着查询将使用这个新值重新获取(每当页面发生变化时,例如 中的情况useEffect)。

我还添加了一些data-testid用于测试的内容。那么,开始构建吧!

构建组件测试

我们的组件需要listPostsService,所以让ListPostsServiceSpy我们使用之前创建的 。使用它我们不会发出真正的 HTTP 请求,因为它是一个“伪服务”。

import { render, screen } from '@testing-library/react';
import reactQuery, { UseQueryResult } from 'react-query';

import { ListPostsServiceSpy } from '../../services/definitions/mock-list-posts-service';
import CleanBlogPosts from './CleanBlogPosts';

type SutTypes = {
  listPostsServiceSpy: ListPostsServiceSpy;
};

const makeSut = (): SutTypes => {
  const listPostsServiceSpy = new ListPostsServiceSpy();

  return {
    listPostsServiceSpy,
  };
};

jest.mock('react-query', () => ({
  useQuery: () => {
    return {
      data: [],
      isLoading: false,
      isError: false,
    };
  },
}));

describe('CleanBlogPosts', () => {
  it('should show loading state', async () => {
    const { listPostsServiceSpy } = makeSut();

    jest.spyOn(reactQuery, 'useQuery').mockReturnValueOnce({
      data: listPostsServiceSpy.result,
      isLoading: true,
      isError: false,
    } as any);

    render(<CleanBlogPosts listPostsService={listPostsServiceSpy} />);

    expect(screen.getByTestId('loading-posts')).toBeInTheDocument();
  });

  it('should show error state', async () => {
    const { listPostsServiceSpy } = makeSut();

    jest.spyOn(reactQuery, 'useQuery').mockReturnValueOnce({
      data: listPostsServiceSpy.result,
      isLoading: false,
      isError: true,
    } as any);

    render(<CleanBlogPosts listPostsService={listPostsServiceSpy} />);

    expect(screen.getByTestId('loading-posts-error')).toBeInTheDocument();
  });

  it('should list the posts', async () => {
    const { listPostsServiceSpy } = makeSut();

    jest.spyOn(reactQuery, 'useQuery').mockReturnValueOnce({
      data: listPostsServiceSpy.result,
      isLoading: false,
      isError: false,
    } as UseQueryResult);

    render(<CleanBlogPosts listPostsService={listPostsServiceSpy} />);

    const posts = await screen.findAllByTestId('post-card');

    expect(posts).toHaveLength(listPostsServiceSpy.result.length);
  });
});
Enter fullscreen mode Exit fullscreen mode

我们添加了 3 个测试:

  • 加载状态:检查是否useQuery返回状态isLoading: true,我们将渲染加载组件。
  • 错误状态:检查是否useQuery返回状态isError: true,我们将渲染错误组件。
  • 成功:检查我们的useQuery状态是否返回data,如果返回,我们将渲染所需的内容(帖子列表卡片)。我还检查了我们渲染的帖子数量是否与服务返回的帖子数量相同。

结论

这并不是“最适合你的 API 的解决方案”。每种情况可能需要不同的解决方案。但我希望这能帮助你找到开发更好代码的替代方案。

另一种选择是创建一个名为的自定义钩子useListPosts(),它将返回与相同的状态useQuery,但您也可以将 React Query 与组件分离并使用您自己的实现来创建更多测试。

遗憾的是,自动化测试在前端代码中并不常见,课程中也很少讲授。现在就打开你的 VSCode 试试吧 :)

文章来源:https://dev.to/eliasjnior/how-to-make-asynchronous-requests-to-your-api-in-react-1a7m
PREV
7 分钟学会 JavaScript 闭包
NEXT
Holopin 隆重推出:开发者专属的数字徽章平台!我们为何打造 Holopin?功能介绍:下一步是什么?在 Hacktoberfest 上领取 Holopin