在 React 应用中分离 API 层 - 实现可维护代码的 6 个步骤

2025-06-07

在 React 应用中分离 API 层 - 实现可维护代码的 6 个步骤

当你在 React 应用中处理 API 数据时,可以将所有代码都放在组件中。这样做效果很好,至少对于逻辑有限的小型应用来说是这样。

但是,一旦代码库增长并且用例数量增加,您就会遇到问题,因为:

  • UI 代码与数据层紧密耦合。
  • 您有很多重复的代码但忘记更新。
  • 该组件充满了 API 代码,变成了一团难以阅读的混乱的意大利面条。

你可能知道有更好的选择。而且你很可能不会把所有东西都扔到组件里。但你也不太明白经验丰富的开发人员所说的“清晰架构”或“单独的 API 层”是什么意思。

这听起来很复杂……而且令人望而生畏。但实际上,这并不难。只需几个逻辑步骤,就能从杂乱无章的代码过渡到独立的 API 层。

这就是我们在本页要做的事情。在前两篇关于使用 REST API获取修改数据的文章中,我们构建了一个组件。它完成了它的工作,但不可否认的是,它相当混乱。我们将使用这个组件,并逐步重构它,使其使用更分层的架构。

获取源代码

目录

  1. 最终结果
  2. 代码的初始状态
    1. 原始(混乱的)代码
    2. 问题
  3. 重构为单独的 API 层
    1. 步骤 1:提取查询钩子
    2. 第 2 步:重用通用逻辑
    3. 步骤3:使用全局Axios实例
    4. 步骤 4:在全局 Axios 实例中设置通用标头
    5. 步骤 5:使用全局配置的查询客户端
    6. 步骤6:提取API函数
  4. 最终的分离状态
  5. 额外奖励:通过包装库进一步解耦

最终结果

这篇文章有点长,所以我先给大家看一下最终结果。最终代码将分为

  • 一个全局api文件夹,其中包含共享的 Axios 实例和用于通用配置的 react-query 客户端,以及发送请求的 fetch 函数
  • 使用 react-query 但与底层 API 细节隔离的自定义钩子
  • 使用这些自定义钩子但与任何数据获取逻辑分离的组件。

具有单独 API 层的最终结果

代码的初始状态

原始(混乱的)代码

作为我们重构之旅的基础,我们将使用下面的组件来渲染“问题”表。它是我为React Job Simulator构建的类似 Sentry 的错误跟踪应用程序的一部分。

应用程序截图

该组件具有一些高级功能,例如

  • 分页
  • 预取下一页的数据
  • 通过每行右侧的按钮解决问题时进行乐观更新。

它的用户界面看起来还不错,但代码不太美观。你可以自己看看。(如果你不太明白,不用担心。这就是重点。)

注意:虽然原始代码是用 TypeScript 编写的,但为了方便阅读,此处的示例代码均使用 JavaScript 编写。屏幕截图也保留了 TypeScript 版本。

// features/issues/components/issue-list.tsx

import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";

export function IssueList() {
  const [page, setPage] = useState(1);

  // Fetch issues data from REST API
  const issuePage = useQuery(
    ["issues", page],
    async ({ signal }) => {
      const { data } = await axios.get(
        "https://prolog-api.profy.dev/v2/issue",
        {
          params: { page, status: "open" },
          signal,
          headers: { Authorization: "my-access-token" },
        }
      );
      return data;
    },
    { staleTime: 60000, keepPreviousData: true }
  );

  // Prefetch the next page of issues before the user can see it
  const queryClient = useQueryClient();
  useEffect(() => {
    if (issuePage.data?.meta.hasNextPage) {
      queryClient.prefetchQuery(
        ["issues", page + 1],
        async ({ signal }) => {
          const { data } = await axios.get(
            "https://prolog-api.profy.dev/v2/issue",
            {
              params: { page, status: "open" },
              signal,
              headers: { Authorization: "my-access-token" },
            }
          );
          return data;
        },
        { staleTime: 60000 }
      );
    }
  }, [issuePage.data, page, queryClient]);

  const { items, meta } = issuePage.data || {};

  // Resolve an issue with optimistic update
  const ongoingMutationCount = useRef(0);
  const resolveIssueMutation = useMutation(
    (issueId) =>
      axios.patch(
        `https://prolog-api.profy.dev/v2/issue/${issueId}`,
        { status: "resolved" },
        { headers: { Authorization: "my-access-token" } }
      ),
    {
      onMutate: async (issueId) => {
        ongoingMutationCount.current += 1;

        await queryClient.cancelQueries(["issues"]);

        // start optimistic update
        const currentPage = queryClient.getQueryData([
          "issues",
          page,
        ]);
        const nextPage = queryClient.getQueryData([
          "issues",
          page + 1,
        ]);

        if (!currentPage) {
          return;
        }

        const newItems = currentPage.items.filter(({ id }) => id !== issueId);

        if (nextPage?.items.length) {
          const lastIssueOnPage =
            currentPage.items[currentPage.items.length - 1];
          const indexOnNextPage = nextPage.items.findIndex(
            (issue) => issue.id === lastIssueOnPage.id
          );
          const nextIssue = nextPage.items[indexOnNextPage + 1];
          if (nextIssue) {
            newItems.push(nextIssue);
          }
        }

        queryClient.setQueryData(["issues", page], {
          ...currentPage,
          items: newItems,
        });

        return { currentIssuesPage: currentPage };
      },
      onError: (err, issueId, context) => {
        // restore previos state in case of an error
        if (context?.currentIssuesPage) {
          queryClient.setQueryData(["issues", page], context.currentIssuesPage);
        }
      },
      onSettled: () => {
        // refetch data once the last mutation is finished
        ongoingMutationCount.current -= 1;
        if (ongoingMutationCount.current === 0) {
          queryClient.invalidateQueries(["issues"]);
        }
      },
    }
  );

  return (
    <Container>
      <Table>
        <thead>
          <HeaderRow>
            <HeaderCell>Issue</HeaderCell>
            <HeaderCell>Level</HeaderCell>
            <HeaderCell>Events</HeaderCell>
            <HeaderCell>Users</HeaderCell>
          </HeaderRow>
        </thead>
        <tbody>
          {(items || []).map((issue) => (
            <IssueRow
              key={issue.id}
              issue={issue}
              resolveIssue={() => resolveIssueMutation.mutate(issue.id)}
            />
          ))}
        </tbody>
      </Table>
      <PaginationContainer>
        <div>
          <PaginationButton
            onClick={() => setPage(page - 1)}
            disabled={page === 1}
          >
            Previous
          </PaginationButton>
          <PaginationButton
            onClick={() => setPage(page + 1)}
            disabled={page === meta?.totalPages}
          >
            Next
          </PaginationButton>
        </div>
        <PageInfo>
          Page <PageNumber>{meta?.currentPage}</PageNumber> of{" "}
          <PageNumber>{meta?.totalPages}</PageNumber>
        </PageInfo>
      </PaginationContainer>
    </Container>
  );
}
Enter fullscreen mode Exit fullscreen mode

如前所述,我们在前两篇关于使用 REST API获取修改数据的文章中构建了这个组件,您可以在那里找到详细的解释。这里我们来快速探讨一下将所有代码放入组件中可能遇到的一些问题。

问题

首先,我们将组件与 API 相关的各种东西高度耦合。例如

  • 状态管理库(react-query)
  • 数据获取库(Axios)
  • 端点的完整 URL,包括基本 URL
  • 通过标头中的访问令牌进行授权

当前实施中的问题

UI 组件不需要了解所有这些东西。

除此之外,即使在这个组件内部,部分代码也是重复的。每次使用 Axios(在查询、预取查询和变更中)时,我们都会重新输入完​​整的 URL,并进行细微的修改,设置授权标头,并使用类似的 react-query 配置。

当然,这不是我们的应用程序中唯一必须与 REST API 交互的组件。

举个例子:假设我们的 REST API 有了新版本,我们需要将基础 URL 调整https://prolog-api.profy.dev/v2v3。这应该是一个简单的更改,但我们必须修改每个获取数据的组件。

你可能明白了。这样的代码很难维护,而且容易出错。

我们现在的目标是将 UI 组件与 REST API 相关的逻辑隔离开来。理想情况下,我们希望能够在不触及任何 UI 代码的情况下更改 API 请求。

获取源代码

重构为单独的 API 层

步骤 1:提取查询钩子

目前,我们组件的大部分代码都与数据获取有关。具体来说:

  1. 获取问题数据。
  2. 将问题更新为“已解决”状态。

首先,让我们将这两个代码块拆分成各自的自定义钩子(这是推荐的最佳实践)。我们创建一个钩子,用于获取名为 的文件中的数据use-get-issues.ts

// features/issues/api/use-get-issues.ts

import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";

export function useGetIssues(page) {
  const query = useQuery(
    ["issues", page],
    async ({ signal }) => {
      const { data } = await axios.get(
        "https://prolog-api.profy.dev/v2/issue",
        {
          params: { page, status: "open" },
          signal,
          headers: { Authorization: "my-access-token" },
        }
      );
      return data;
    },
    { staleTime: 60000, keepPreviousData: true }
  );

  // Prefetch the next page!
  const queryClient = useQueryClient();
  useEffect(() => {
    if (query.data?.meta.hasNextPage) {
      queryClient.prefetchQuery(
        ["issues", page + 1],
        async ({ signal }) => {
          const { data } = await axios.get(
            "https://prolog-api.profy.dev/v2/issue",
            {
              params: { page: page + 1, status: "open" },
              signal,
              headers: { Authorization: "my-access-token" },
            }
          );
          return data;
        },
        { staleTime: 60000 }
      );
    }
  }, [query.data, page, queryClient]);
  return query;
}
Enter fullscreen mode Exit fullscreen mode

文件名和函数名useGetIssues已经告诉我们代码的作用。单凭这一点,就极大地提升了代码的可读性。

让我们看一下下一个用于解决问题的自定义钩子。我不会详细介绍它onMutate和其他回调函数。

// features/issues/api/use-resolve-issues.ts

import { useRef } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios from "axios";

export function useResolveIssue(page) {
  const queryClient = useQueryClient();
  const ongoingMutationCount = useRef(0);
  return useMutation(
    (issueId) =>
      axios.patch(
        `https://prolog-api.profy.dev/v2/issue/${issueId}`,
        { status: "resolved" },
        { headers: { Authorization: "my-access-token" } }
      ),
    {
      onMutate: ...,
      onError: ...,
      onSettled: ...,
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

目前,我们只是将两个代码块提取到自定义钩子中。但即使是这样简单的改变,也让组件更加易于阅读。

// features/issues/components/issue-list.tsx

import { useState } from "react";
import { useGetIssues, useResolveIssue } from "../../api";

export function IssueList() {
  const [page, setPage] = useState(1);

  const issuePage = useGetIssues(page);
  const resolveIssue = useResolveIssue(page);

  const { items, meta } = issuePage.data || {};

  return (
    <Container>
      <Table>
        <head>...</thead>
        <tbody>
          {(items || []).map((issue) => (
            <IssueRow
              key={issue.id}
              issue={issue}
              resolveIssue={() => resolveIssue.mutate(issue.id)}
            />
          ))}
        </tbody>
      </Table>
      <PaginationContainer>...</PaginationContainer>
    </Container>
  );
}
Enter fullscreen mode Exit fullscreen mode

但可读性并不是唯一改进的地方。我们刚刚引入了第一层。

现在,组件与 API 获取相关的逻辑已经隔离。组件不再知道我们使用了 Axios、API 端点被调用了什么,也不再关心请求配置。它不再需要关心数据预取或乐观更新之类的细节。

第 2 步:重用通用逻辑

我们通过创建自定义钩子将组件与 API 细节隔离开来。但这意味着我们只是把很多问题转移到了这些钩子上。其中一个问题就是重复代码。现在,我们先解决两个部分:

  • 在获取和预取逻辑中使用的查询键。
  • 调用的 fetch 函数axios.get

fetch 函数相当明显。它有很多重复的代码,只是pageGET 请求中的参数不同而已。

另一方面,查询键在这里可能看起来无关紧要。这几乎只是代码重复。问题是这些键也用于useResolveIssue乐观更新的钩子中。

因此,每当我们在钩子中更改查询键时,useGetIssues都必须记住在useResolveIssue钩子中也更新它们。否则,我们很可能会忘记,从而引入难以检测的错误。

因此我们在这里介绍两个变化:

  1. 让我们使用生成器函数来代替硬编码的查询键。
  2. 提取获取函数以便我们可以重复使用它。
// features/issues/api/use-get-issues.ts

import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";

const QUERY_KEY = "issues";

// this is also used to generate the query keys in the useResolveIssue hook
export function getQueryKey(page) {
  if (page === undefined) {
    return [QUERY_KEY];
  }
  return [QUERY_KEY, page];
}

// shared between useQuery and queryClient.prefetchQuery
async function getIssues(page, options) {
  const { data } = await axios.get("https://prolog-api.profy.dev/v2/issue", {
    params: { page, status: "open" },
    signal: options?.signal,
    headers: { Authorization: "my-access-token" },
  });
  return data;
}

export function useGetIssues(page) {
  const query = useQuery(
    getQueryKey(page),
    ({ signal }) => getIssues(page, { signal }),
    { staleTime: 60000, keepPreviousData: true }
  );

  // Prefetch the next page!
  const queryClient = useQueryClient();
  useEffect(() => {
    if (query.data?.meta.hasNextPage) {
      queryClient.prefetchQuery(
        getQueryKey(page + 1),
        ({ signal }) => getIssues(page + 1, { signal }),
        { staleTime: 60000 },
      );
    }
  }, [query.data, page, queryClient]);
  return query;
Enter fullscreen mode Exit fullscreen mode

useResolveIssues钩子现在也可以使用查询键生成器了。这消除了未来可能出现的 bug 来源。

此外,我们还提取了 fetch 函数。

// features/issues/api/use-resolve-issues.ts

import { useRef } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import * as GetIssues from "./use-get-issues";

async function resolveIssue(issueId) {
  const { data } = await axios.patch(
    `https://prolog-api.profy.dev/v2/issue/${issueId}`,
    { status: "resolved" },
    { headers: { Authorization: "my-access-token" } }
  );
  return data;
}

export function useResolveIssue(page) {
  const queryClient = useQueryClient();
  const ongoingMutationCount = useRef(0);
  return useMutation((issueId) => resolveIssue(issueId), {
    onMutate: async (issued ) => {
      ongoingMutationCount.current += 1;

      // use the query key generator from useGetIssues
      await queryClient.cancelQueries(GetIssues.getQueryKey());

      const currentPage = queryClient.getQueryData(
        GetIssues.getQueryKey(page)
      );
      const nextPage = queryClient.getQueryData(
        GetIssues.getQueryKey(page + 1)
      );

      // let me spare you the rest
      ...
    },
    onError: ...,
    onSettled: ...,
  });
}
Enter fullscreen mode Exit fullscreen mode

结果是更多的DRY 代码。但不仅如此。

你知道我们刚刚添加了另一个隔离层吗?通过提取获取函数,我们刚刚将自定义react-query钩子与数据获取逻辑解耦了。

例如,之前我们与useGetIssues紧密耦合axios

export function useGetIssues(page) {
  const query = useQuery(
    ["issues", page],
    ({ signal }) => axios.get(...),
    ...
  );
Enter fullscreen mode Exit fullscreen mode

使用新代码,我们可以从 切换axiosfetch甚至 Firebase,而根本不需要触碰useGetIssues钩子。

export function useGetIssues(page) {
  const query = useQuery(
    ["issues", page],
    ({ signal }) => getIssues(page, { signal }),
    ...
  );
Enter fullscreen mode Exit fullscreen mode

面向有志于成为初级开发者的 React 电子邮件课程

步骤3:使用全局Axios实例

我们要解决的下一个问题是每个查询钩子中的 API 基本 URL 重复。

对于小型应用来说,这似乎无关紧要。但想象一下,你有十几个甚至更多的钩子,并且必须更改基本 URL。更改基本 URL 的原因有很多:

  • API 已移动至另一个子域(不太可能)
  • 有一个新的 API 版本(可能)
  • 我们需要在开发和生产中使用不同的基本 URL(很有可能)

在任何一种情况下,我们都必须修改每个自定义钩子,并确保不会忘记任何一个。事实上,在撰写之前的博客文章时,我已经忘记更改另一个钩子的版本了。

哎呀,事情败露了。

好的,那么如何在所有 fetch 函数中重用相同的基本 URL?最简单的方法是使用一个共享实例,axios并在其中设置基本 URL。

在本例中,我们创建一个新的全局文件夹api,并在其中创建一个axios.ts文件。我们创建一个实例axios并设置baseURL选项。该实例已导出,因此我们可以在任何获取函数中使用它。

// api/axios.ts

import Axios from "axios";

export const axios = Axios.create({
  baseURL: "https://prolog-api.profy.dev/v2",
});
Enter fullscreen mode Exit fullscreen mode

这仍然不允许我们为不同的环境(例如开发或生产)设置不同的基础 URL。因此,我们使用环境变量来代替硬编码 URL。

注意:该assert函数充当安全网。如果NEXT_PUBLIC_API_BASE_URL缺少环境变量,它会阻止应用程序构建。因此,我们在代码部署之前就能立即知道出了问题。

// api/axios.ts

import assert from "assert";
import Axios from "axios";

assert(
  process.env.NEXT_PUBLIC_API_BASE_URL,
  "env variable not set: NEXT_PUBLIC_API_BASE_URL"
);

export const axios = Axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
});
Enter fullscreen mode Exit fullscreen mode

设置环境变量的一个简单选项(至少在开发中)是使用.env文件。

注意:我们的应用是 Next.js 应用,支持.env开箱即用的文件。如果您不使用 Next.js,则可能需要自行设置dotenv

// .env

NEXT_PUBLIC_API_BASE_URL=https://prolog-api.profy.dev/v2
Enter fullscreen mode Exit fullscreen mode

getIssues现在我们可以从 fetch 函数、resolveIssue和 中删除基本 URL getProjects。看到其中一个可能就足够了。

// features/issues/api/use-get-issues.ts

import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { axios } from "@api/axios";
import type { Page } from "@typings/page.types";
import type { Issue } from "@features/issues";

...

async function getIssues(page, options) {
  // no need to add the base URL anymore
  const { data } = await axios.get("/issue", {
    params: { page, status: "open" },
    signal: options?.signal,
    headers: { Authorization: "my-access-token" },
  });
  return data;
}

export function useGetIssues(page) {
  ...
}
Enter fullscreen mode Exit fullscreen mode

我们刚刚把获取函数从基础 URL 中分离出来了!我们只需调整一个环境变量,无需修改任何代码,就能切换 URL。

步骤 4:在全局 Axios 实例中设置通用标头

下一个问题是我们的获取功能与授权机制紧密耦合。

在我们的例子中,我们只是设置了一个简单的访问令牌(目前是硬编码的,并在存储库中检查过……哎哟)。但大多数应用程序使用稍微复杂一点的方法。

无论如何,将我们的获取函数与授权机制分离并删除一些重复的代码都是有意义的。Axios在这里非常有用,因为它支持请求和响应拦截器。我们可以使用这些拦截器将授权标头添加到任何发出的请求中。

// api/axios.ts

import assert from "assert";
import Axios, { AxiosRequestConfig } from "axios";

assert(
  process.env.NEXT_PUBLIC_API_BASE_URL,
  "env variable not set: NEXT_PUBLIC_API_BASE_URL"
);

assert(
  process.env.NEXT_PUBLIC_API_TOKEN,
  "env variable not set: NEXT_PUBLIC_API_TOKEN"
);

function authRequestInterceptor(config: AxiosRequestConfig) {
  config.headers.authorization = process.env.NEXT_PUBLIC_API_TOKEN;
  return config;
}

export const axios = Axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
});

axios.interceptors.request.use(authRequestInterceptor);
Enter fullscreen mode Exit fullscreen mode

我们再次使用环境变量来存储访问令牌。

// .env

NEXT_PUBLIC_API_BASE_URL=https://prolog-api.profy.dev
NEXT_PUBLIC_API_TOKEN=my-access-token
Enter fullscreen mode Exit fullscreen mode

奇迹般地,我们可以headers从获取函数中删除该选项。

// features/issues/api/use-get-issues.ts

...

async function getIssues(page, options) {
  const { data } = await axios.get("/issue", {
    params: { page, status: "open" },
    signal: options?.signal,
  });
  return data;
}

export function useGetIssues(page) { ... }
Enter fullscreen mode Exit fullscreen mode
// features/issues/api/use-resolve-issues.ts

...

async function resolveIssue(issueId: string) {
  const { data } = await axios.patch(
    `/issue/${issueId}`,
    { status: "resolved" },
  );
  return data;
}

export function useResolveIssue(page) { ... }
Enter fullscreen mode Exit fullscreen mode

所以现在,我们将获取函数与基本 URL 以及授权机制隔离开来。

步骤 5:使用全局配置的查询客户端

这又是一个看似很小的问题:用于所有 GET 请求的重复查询配置。

虽然这看起来微不足道,但实际上,在我编写这段代码时,它导致了一个 bug。我忘记了上面截图中的第二个配置,然后奇怪的事情发生了。追踪这个 bug 并不容易。

为了重用这些通用配置,我们可以创建一个全局查询客户端,类似于我们对 Axios 所做的操作。我们创建一个文件api/query-client.ts,并导出一个包含通用选项的查询客户端。

// api/query-client.ts

import { QueryClient } from "@tanstack/react-query";

const defaultQueryConfig = { staleTime: 60000 };

export const queryClient = new QueryClient({
  defaultOptions: { queries: defaultQueryConfig },
});
Enter fullscreen mode Exit fullscreen mode

现在我们可以staleTime从获取函数中删除配置。

// features/issues/api/use-get-issues.ts

export function useGetIssues(page) {
  const query = useQuery(
    getQueryKey(page),
    ({ signal }) => getIssues(page, { signal }),
    { keepPreviousData: true }
  );

  // Prefetch the next page!
  const queryClient = useQueryClient();
  useEffect(() => {
    if (query.data?.meta.hasNextPage) {
      queryClient.prefetchQuery(getQueryKey(page + 1), ({ signal }) =>
        getIssues(page + 1, { signal })
      );
    }
  }, [query.data, page, queryClient]);
  return query;
}
Enter fullscreen mode Exit fullscreen mode

通过将查询客户端的设置移动到api全局 Axios 实例旁边的文件夹中,我们现在可以将与 API 请求相关的所有内容集中存放在一个地方。

至少几乎所有事情都是如此。

步骤6:提取API函数

我们已经很好地将 UI 组件与底层数据获取代码隔离开来。不过,我想更进一步,将获取函数提取到一个中心位置。这一步的灵感来自 Redux Toolkit Query,其中 API 被定义在一个地方。

此步骤违背了该项目所使用的功能驱动文件夹结构。所以我不确定这样做会有什么好处。不过,让我们看看最终结果如何。

我认为当前代码的缺点是:

  • fetch 函数和react-queryhooks 位于同一个文件中。如果我们想从 Axios 切换到其他工具,fetch比如 Firebase,就必须修改所有这些 hooks 文件。
  • 不同文件中的多个钩子使用相同的端点。如果我们要更改共享端点,就必须小心,不要忘记其中一个获取函数。有几种方法可以解决这个问题,例如将端点提取到共享常量中,创建一个返回端点的函数,或者(就像我们将要做的那样)将两个获取函数合并到一个文件中。

因此,让我们将获取函数提取到全局api文件夹中的单独文件中。

这里是与“问题”相关的所有端点,它们都合并在一个文件中。

// api/issues.ts

import { axios } from "./axios";

const ENDPOINT = "/issue";

export async function getIssues(page, filters, options) {
  const { data } = await axios.get(ENDPOINT, {
    params: { page, ...filters },
    signal: options?.signal,
  });
  return data;
}

export async function resolveIssue(issueId) {
  const { data } = await axios.patch(`${ENDPOINT}/${issueId}`, {
    status: "resolved",
  });
  return data;
}
Enter fullscreen mode Exit fullscreen mode

以下是“项目”端点。

// api/projects.ts

import { axios } from "./axios";

const ENDPOINT = "/project";

export async function getProjects() {
  const { data } = await axios.get(ENDPOINT);
  return data;
}
Enter fullscreen mode Exit fullscreen mode

举个例子,我们的自定义钩子文件use-get-issues.ts看起来几乎一样。只有getIssues函数被 import from 替换了@api/issues

// features/issues/api/use-get-issues.ts

import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getIssues } from "@api/issues";

const QUERY_KEY = "issues";

export function getQueryKey(page) {
  if (page === undefined) {
    return [QUERY_KEY];
  }
  return [QUERY_KEY, page];
}

export function useGetIssues(page) {
  const query = useQuery(
    getQueryKey(page),
    ({ signal }) => getIssues(page, { status: "open" }, { signal }),
    { keepPreviousData: true }
  );

  // Prefetch the next page!
  const queryClient = useQueryClient();
  useEffect(() => {
    if (query.data?.meta.hasNextPage) {
      queryClient.prefetchQuery(getQueryKey(page + 1), ({ signal }) =>
        getIssues(page + 1, { status: "open" }, { signal })
      );
    }
  }, [query.data, page, queryClient]);
  return query;
}
Enter fullscreen mode Exit fullscreen mode

获取源代码

最终的分离状态

从我的角度来看,我们现在已经达到了令人满意的分离程度。让我们回顾一下:

  1. 靠近 REST API 的代码都位于全局api文件夹中,彼此相邻。这包括 Axios 和查询客户端,以及发送请求的 fetch 函数。如果 API 有任何更改(例如版本、基础 URL、标头或端点不同),我们可以轻松找到需要调整的文件。
  2. 请求或查询的共享配置现在位于一个位置(api/axios.tsapi/query-client.ts)。我们无需将其添加到每个请求或钩子中。因此,错误配置的风险降低了,并且更容易为整个应用程序进行更改。
  3. 查询钩子不了解用于数据获取的底层库,也不需要关心 API 端点。所有这些都封装在api文件夹中的获取函数中。实际上,我们可以将 Axios 替换成其他库,只需更改文件api夹中的代码即可。至少理想情况下是这样。
  4. UI 组件与任何数据获取逻辑解耦。仅从组件代码来看,你根本不知道分页表数据是预获取的,或者“Resolve Issue”突变会触发乐观更新。

面向有志于成为初级开发者的 React 电子邮件课程

额外奖励:通过包装库进一步解耦

尽管我们已经处于良好的状态,但我们可以更进一步。

注:我不太喜欢接下来的内容,因为它开销太大,而潜在的未来价值却太小。但为了完整起见,我还是想提一下。

我们已经很好地分离了 fetch 函数和查询钩子。如上所述,查询钩子对所使用的 Axios 或诸如端点之类的请求细节一无所知。

但是 fetch 函数本身还没有与 Axios 解耦。我们直接导出 Axios 客户端并在 fetch 函数中使用它。

// api/axios.ts

export const axios = Axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
});
Enter fullscreen mode Exit fullscreen mode
// api/issues.ts

import { axios } from "./axios";

export async function getIssues(...) {
  const { data } = await axios.get(ENDPOINT, {
    params: { page, ...filters },
    signal: options?.signal,
  });
  return data;
}
Enter fullscreen mode Exit fullscreen mode

如果我们想用其他东西替换 Axios,我们也必须调整所有的获取功能。

为了创建进一步的隔离层,我们可以简单地包装axios客户端并仅间接公开其方法。

// api/api-client.ts

const axios = Axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
});

export const apiClient = {
  get: (route, config) =>
    axios.get(route, { signal: config?.signal, params: config?.params }),
  post: (route, data, config) =>
    axios.post(route, data, { signal: config?.signal }),
  put: (route, data, config) =>
    axios.put(route, data, { signal: config?.signal }),
  patch: (route, data, config) =>
    axios.patch(route, data, { signal: config?.signal }),
};
Enter fullscreen mode Exit fullscreen mode

请注意,我们不会将config对象直接传递给axios。否则,fetch 函数可能会使用 Axios 支持的任何配置选项。这又会将它们与 Axios 耦合。

获取函数基本上保持不变。

// api/issues.ts

import { apiClient } from "./api-client";

export async function getIssues(...) {
  const { data } = await apiClient.get(ENDPOINT, {
    params: { page, ...filters },
    signal: options?.signal,
  });
  return data;
}
Enter fullscreen mode Exit fullscreen mode

现在,API 客户端和 fetch 函数之间已经完全隔离。我们可以替换 Axios,而无需修改 fetch 函数中的任何一行代码。

类似地,我们的 UI 组件仍然与 耦合,react-query因为查询钩子直接返回 的返回值useQuery

// features/issues/api/use-get-issues.ts

...

export function useGetIssues(page: number) {
  const query = useQuery(
    getQueryKey(page),
    ({ signal }) => getIssues(page, { status: "open" }, { signal }),
    { keepPreviousData: true }
  );

  ...

  return query;
}
Enter fullscreen mode Exit fullscreen mode

使用此钩子的组件可以使用 返回值中的所有内容useQuery。因此,如果我们想迁移,react-query就必须调整查询钩子以及使用这些钩子的组件。

为了在这里引入另一个隔离层,我们可以再次包装钩子的返回值。

// features/issues/api/use-get-issues.ts

...

export function useGetIssues(page: number) {
  const query = useQuery(
    getQueryKey(page),
    ({ signal }) => getIssues(page, { status: "open" }, { signal }),
    { keepPreviousData: true }
  );

  // Prefetch the next page!
  const queryClient = useQueryClient();
  useEffect(() => {
    if (query.data?.meta.hasNextPage) {
      queryClient.prefetchQuery(getQueryKey(page + 1), ({ signal }) =>
        getIssues(page + 1, { status: "open" }, { signal })
      );
    }
  }, [query.data, page, queryClient]);

  return {
    data: query.data,
    isLoading: query.isLoading,
    isError: query.isError,
    error: query.error,
    refetch: async () => {
      await query.refetch();
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

请注意,我们既不query.refetch直接公开也不公开其返回值,因为那样又会为耦合打开大门。

现在我们可以(理想情况下)换掉它react-query,只修改查询钩子。组件甚至不会察觉到。

这些额外隔离层的缺点是创建和维护这些包装器的开销。您已经看到,如果我们想要实现真正的隔离,我们需要非常小心地暴露内容(例如,configAPI 客户端中的参数,或者query.refetch查询钩子中的函数或其返回值)。这在 Vanilla JS 中已经很难实现,而且需要大量额外的代码。但使用 TypeScript 时,情况会更糟,因为你必须复制许多类型。

其优势显然在于能够替换单个库。不过,目前尚不清楚这种可能性有多大,以及它能带来多大的好处。新的替代库仍然有可能不支持相同的功能,最终你还需要重写部分其他代码。

从我的角度来看,如果某个库可能需要在某个时候被替换,那么引入这样的包装器是有意义的。例如,一个 UI 库在初期可以大大加快开发速度,但随着设计变得更加具体并偏离库的默认设置,可能会带来麻烦。

但就我们的情况而言,我认为成本是不合理的。

面向有志于成为初级开发者的 React 电子邮件课程

文章来源:https://dev.to/jkettmann/separate-api-layers-in-react-apps-6-steps-towards-maintainable-code-4n2
PREV
所有 JavaScript 应用都需要事件节流!事件节流结论
NEXT
React 和 REST API:基于 OpenAPI 文档的端到端 TypeScript