React 应用架构实战 0x5:集成 API 到应用中

2023-05-17 21:01:11 浏览数 (1)

在之前,了解了如何设置模拟 API,而在本节中,将学习如何通过应用程序消费 API。当我们提到 API 时,指的是 API 后端服务。我们将学习如何在客户端和服务器上获取数据,对于 HTTP 客户端,我们将使用 Axios,并使用 React Query 库来处理获取到的数据,它允许我们在 React 应用程序中处理 API 请求和响应。

# 配置 API 客户端

我们将使用 Axios 作为我们的应用程序的 API 客户端,它是一个非常流行的用于处理 HTTP 请求的库。它支持在浏览器和服务器端使用,并且具有创建实例、拦截请求和响应、取消请求等功能的 API。

我们首先要创建一个 Axios 实例,其中包含一些我们希望在每个请求上执行的通用操作。

代码语言:javascript复制
// src/lib/api-client.ts
import Axios from "axios";

import { API_URL } from "@/config/constants";

export const apiClient = Axios.create({
  baseURL: API_URL,
  headers: {
    "Content-Type": "application/json",
  },
});

apiClient.interceptors.response.use(
  (resp) => {
    return resp.data;
  },
  (err) => {
    const message = err?.response?.data?.message || err.message;
    console.error(message);
    return Promise.reject(message);
  }
);

# 使用 React Query

React Query 是一个很好的处理异步数据的库,可以将数据在 React 组件中使用。

# 为什么使用 React Query

React Query 是一个很好的处理异步远程状态的选择的主要原因是它可以为我们处理许多事情。

假设有以下组件,它从 API 加载一些数据并将其显示出来:

代码语言:javascript复制
const loadData = () => Promise.resolve({ data: "Hello World" });

const DataComponent = () => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    loadData()
      .then((data) => {
        setData(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err);
        setLoading(false);
      });
  }, []);

  if (loading) {
    return <div>Loading...</div>;
  }

  if (error) {
    return <div>Error: {error}</div>;
  }

  return <div>{data}</div>;
};

如果我们只从 API 获取数据一次,那么这样做是可以的,但在大多数情况下,我们需要从许多不同的地方获取数据。我们可以看到这里有一定量的重复代码:

  • 需要定义相同的dataerrorloading 状态
  • 必须相应地更新不同的状态
  • 数据在我们离开组件时立即被丢弃

如果使用 React Query,我们可以使用 useQuery 钩子来处理这些事情:

代码语言:javascript复制
import { useQuery } from "@tanstack/react-query";

const loadData = () => Promise.resolve({ data: "Hello World" });

const DataComponent = () => {
  const { data, error, isLoading } = useQuery({
    queryKey: ["data"],
    queryFn: loadData,
  });

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

  if (error) {
    return <div>Error: {error}</div>;
  }

  return <div>{data}</div>;
};

状态处理被抽象化了,消费者不需要担心存储数据或处理加载和错误状态;一切都由 React Query 处理。React Query 的另一个好处是它的缓存机制。对于每个查询,我们需要提供相应的查询键,用于将数据存储在缓存中。

这也有助于请求的去重。如果我们从多个地方调用相同的查询,它将确保 API 请求仅发生一次。

# 配置 React Query

我们将使用 React Query 的默认配置,但是我们需要在应用程序中提供一个 QueryClient 实例,它将用于管理缓存和请求。

代码语言:javascript复制
// src/lib/react-query.ts
import { QueryClient } from "@tanstack/react-query";

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      refetchOnWindowFocus: false,
      retry: false,
      useErrorBoundary: true,
    },
  },
});

现在我们已经创建了查询客户端,我们必须将其包含在提供程序中。让我们前往 src/providers/app.tsx 并将内容替换为以下内容:

代码语言:javascript复制
// src/providers/app.tsx
import { ReactNode } from "react";
import { ChakraProvider, GlobalStyle } from "@chakra-ui/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ErrorBoundary } from "react-error-boundary";

import { theme } from "@/config/theme";
import { queryClient } from "@/lib/react-query";

type AppProviderProps = {
  children: ReactNode;
};

export const AppProvider = ({ children }: AppProviderProps) => {
  return (
    <ChakraProvider theme={theme}>
      <ErrorBoundary fallback={<div>Something went wrong</div>} onError={console.error}>
        <GlobalStyle />
        <QueryClientProvider client={queryClient}>
          <ReactQueryDevtools initialIsOpen={false} />
          {children}
        </QueryClientProvider>
      </ErrorBoundary>
    </ChakraProvider>
  );
};

在这里,我们正在导入并添加 QueryClientProvider,它将配置 queryClient 用于查询和更新。请注意,我们正在将 queryClient 实例作为 client 属性传递。

我们还添加了 ReactQueryDevtools,它是一个小部件,允许我们检查所有查询。它仅在开发中工作,对于调试非常有用。

# 给功能逻辑添加 API 层

每个功能的 API 层将在 api 文件夹中定义。API 请求可以是查询或更新。

对于每个 API 请求,我们都将有一个文件,其中包含并导出 API 请求定义函数和用于在 React 中使用请求的 hook。对于请求定义函数,我们将使用我们刚刚创建的 axios client,对于 hooks,我们将使用 React Query 的 hooks。

# 职位相关 Jobs

  • 获取职位列表
代码语言:javascript复制
// src/features/jobs/api/get-jobs.ts
import { useQuery } from "@tanstack/react-query";

import { apiClient } from "@/lib/api-client";
import type { Job } from "../types";

type GetJobsOptions = {
  params: {
    organizationId: string | undefined;
  };
};

export const getJobs = ({ params }: GetJobsOptions): Promise<Job[]> => {
  return apiClient.get("/jobs", { params });
};

export const useJobs = ({ params }: GetJobsOptions) => {
  const { data, isFetching, isFetched } = useQuery({
    queryKey: ["jobs", params],
    queryFn: () => getJobs({ params }),
    enabled: !!params.organizationId, // 只有当 organizationId 存在时才会执行
    initialData: [],
  });

  return {
    data,
    isLoading: isFetching && !isFetched,
  };
};

  • 获取职位详情
代码语言:javascript复制
// src/features/jobs/api/get-job.ts
import { useQuery } from "@tanstack/react-query";

import { apiClient } from "@/lib/api-client";
import type { Job } from "../types";

type GetJobOptions = {
  jobId: string;
};

export const getJob = ({ jobId }: GetJobOptions): Promise<Job> => {
  return apiClient.get(`/jobs/${jobId}`);
};

export const useJob = ({ jobId }: GetJobOptions) => {
  const { data, isLoading } = useQuery({
    queryKey: ["job", jobId],
    queryFn: () => getJob({ jobId }),
  });

  return {
    data,
    isLoading,
  };
};

  • 创建职位
代码语言:javascript复制
// src/features/jobs/api/create-job.ts
import { useMutation } from "@tanstack/react-query";

import { apiClient } from "@/lib/api-client";
import { queryClient } from "@/lib/react-query";
import type { Job, CreateJobData } from "../types";

type CreateJobOptions = {
  data: CreateJobData;
};

export const createJob = ({ data }: CreateJobOptions): Promise<Job> => {
  return apiClient.post("/jobs", data);
};

type UseCreateJobOptions = {
  onSuccess?: (job: Job) => void;
};

export const useCreateJob = ({ onSuccess }: UseCreateJobOptions = {}) => {
  const { mutate: submit, isLoading } = useMutation({
    mutationFn: createJob,
    onSuccess: (job) => {
      queryClient.invalidateQueries(["jobs"]);
      onSuccess?.(job);
    },
  });

  return {
    submit,
    isLoading,
  };
};

# 组织 Organizations

  • 获取组织详情
代码语言:javascript复制
// src/features/organizations/api/get-organization.ts
import { useQuery } from "@tanstack/react-query";

import { apiClient } from "@/lib/api-client";

import type { Organization } from "../types";

type GetOrganizationOptions = {
  organizationId: string;
};

export const getOrganization = ({
  organizationId,
}: GetOrganizationOptions): Promise<Organization> => {
  return apiClient.get(`/organizations/${organizationId}`);
};

export const useOrganization = ({ organizationId }: GetOrganizationOptions) => {
  const { data, isLoading } = useQuery({
    queryKey: ["organization", organizationId],
    queryFn: () => getOrganization({ organizationId }),
  });

  return {
    data,
    isLoading,
  };
};

# 在应用程序中使用 API 层

之前为了在没有 API 功能的情况下构建 UI,我们在页面上使用了测试数据。现在,我们想用我们刚刚创建的真实查询和更新操作来替换它们,以便与 API 进行通信。

# 公开组织

代码语言:javascript复制
// src/pages/organizations/[organizationId]/index.tsx
import { ReactElement } from "react";
import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next";
import { Heading, Stack } from "@chakra-ui/react";

import { NotFound } from "@/components/not-found";
import { Seo } from "@/components/seo/seo";
import { JobsList, getJobs } from "@/features/jobs";
import type { Job } from "@/features/jobs";
import { OrganizationInfo, getOrganization } from "@/features/organizations";
import { PublicLayout } from "@/layouts/public-layout";

type PublicOrganizationPageProps = InferGetServerSidePropsType<typeof getServerSideProps>;

const PublicOrganizationPage = ({ organization, jobs }: PublicOrganizationPageProps) => {
  if (!organization) {
    return <NotFound />;
  }

  return (
    <>
      <Seo title={organization.name} />
      <Stack spacing="4" w="full" maxW="container.lg" mx="auto" mt="12" p="4">
        <OrganizationInfo organization={organization} />
        <Heading size="md" my="6">
          Open Jobs
        </Heading>
        <JobsList jobs={jobs} organizationId={organization.id} type="public" />
      </Stack>
    </>
  );
};

PublicOrganizationPage.getLayout = function getLayout(page: ReactElement) {
  return <PublicLayout>{page}</PublicLayout>;
};

export default PublicOrganizationPage;

export const getServerSideProps = async ({ params }: GetServerSidePropsContext) => {
  const organizationId = params?.organizationId as string;

  const [organization, jobs] = await Promise.all([
    getOrganization({ organizationId }).catch(() => null),
    getJobs({
      params: {
        organizationId: organizationId,
      },
    }).catch(() => [] as Job[]),
  ]);

  return {
    props: {
      organization,
      jobs,
    },
  };
};

# 公开职位

代码语言:javascript复制
// src/pages/organizations/[organizationId]/jobs/[jobId].tsx

import { ReactElement } from "react";
import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next";
import { Stack, Button } from "@chakra-ui/react";

import { NotFound } from "@/components/not-found";
import { Seo } from "@/components/seo";
import { PublicJobInfo, getJob } from "@/features/jobs";
import { getOrganization } from "@/features/organizations";
import { PublicLayout } from "@/layouts/public-layout";

type PublicJobPageProps = InferGetServerSidePropsType<typeof getServerSideProps>;

export const PublicJobPage = ({ organization, job }: PublicJobPageProps) => {
  const isInvalid = !job || !organization || job.organizationId !== organization.id;

  if (isInvalid) {
    return <NotFound />;
  }

  return (
    <>
      <Seo title={`${job.position} | ${job.location}`} />
      <Stack w="full">
        <PublicJobInfo job={job} />
        <Button
          bg="primary"
          color="primaryAccent"
          _hover={{
            opacity: "0.9",
          }}
          as="a"
          href={`mailto:${organization.email}?subject=Application for ${job.position} position`}
          target="_blank"
        >
          Apply
        </Button>
      </Stack>
    </>
  );
};

PublicJobPage.getLayout = (page: ReactElement) => <PublicLayout>{page}</PublicLayout>;

export default PublicJobPage;

export const getServerSideProps = async ({ params }: GetServerSidePropsContext) => {
  const organizationId = params?.organizationId as string;
  const jobId = params?.jobId as string;

  const [organization, job] = await Promise.all([
    getOrganization({ organizationId }).catch(() => null),
    getJob({ jobId }).catch(() => null),
  ]);

  return {
    props: {
      organization,
      job,
    },
  };
};

# 看板职位列表

代码语言:javascript复制
// src/pages/dashboard/jobs/index.tsx
import { ReactElement } from "react";
import { Heading, HStack } from "@chakra-ui/react";
import { PlusSquareIcon } from "@chakra-ui/icons";

import { Link } from "@/components/link";
import { Loading } from "@/components/loading";
import { Seo } from "@/components/seo";
import { JobsList, useJobs } from "@/features/jobs";
import { DashboardLayout } from "@/layouts/dashboard-layout";

import { useUser } from "@/testing/test-data";

const DashboardJobsPage = () => {
  const user = useUser();
  const jobs = useJobs({
    params: {
      organizationId: user.data?.organizationId ?? "",
    },
  });

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

  if (!user.data) return null;

  return (
    <>
      <Seo title="Jobs" />
      <HStack mb="8" justify="space-between" align="center">
        <Heading>Jobs</Heading>
        <Link href={`/dashboard/jobs/create`} icon={<PlusSquareIcon />} variant="solid">
          Create Job
        </Link>
      </HStack>
      <JobsList
        jobs={jobs.data || []}
        isLoading={jobs.isLoading}
        organizationId={user.data.organizationId}
        type="dashboard"
      />
    </>
  );
};

DashboardJobsPage.getLayout = (page: ReactElement) => <DashboardLayout>{page}</DashboardLayout>;

export default DashboardJobsPage;

# 看板职位详情

代码语言:javascript复制
// src/pages/dashboard/jobs/[jobId].tsx
import { ReactElement } from "react";
import { useRouter } from "next/router";

import { Loading } from "@/components/loading";
import { NotFound } from "@/components/not-found";
import { Seo } from "@/components/seo";
import { DashboardJobInfo, useJob } from "@/features/jobs";
import { DashboardLayout } from "@/layouts/dashboard-layout";

const DashboardJobPage = () => {
  const router = useRouter();
  const jobId = router.query.jobId as string;

  const job = useJob({ jobId });

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

  if (!job.data) {
    return <NotFound />;
  }

  return (
    <>
      <Seo title={`${job.data.position} | ${job.data.location}`} />
      <DashboardJobInfo job={job.data} />
    </>
  );
};

DashboardJobPage.getLayout = (page: ReactElement) => <DashboardLayout>{page}</DashboardLayout>;

export default DashboardJobPage;

# 职位创建

代码语言:javascript复制
// src/features/jobs/components/create-job-form/create-job-form.tsx
import { Box, Stack } from "@chakra-ui/react";
import { useForm } from "react-hook-form";

import { Button } from "@/components/button";
import { InputField } from "@/components/form";

import { useCreateJob } from "../../api/create-job";
import type { CreateJobData } from "../../types";

export type CreateJobFormProps = {
  onSuccess: () => void;
};

export const CreateJobForm = ({ onSuccess }: CreateJobFormProps) => {
  const createJob = useCreateJob({
    onSuccess,
  });

  const { register, handleSubmit, formState } = useForm<CreateJobData>();

  const onSubmit = (data: CreateJobData) => {
    createJob.submit({ data });
  };

  return (
    <Box w="full">
      <Stack as="form" w="full" onSubmit={handleSubmit(onSubmit)} spacing="8">
        <InputField
          label="Position"
          {...register("position", {
            required: "Position is required",
          })}
          error={formState.errors["position"]}
        />
        <InputField
          label="Department"
          {...register("department", {
            required: "Department is required",
          })}
          error={formState.errors["department"]}
        />
        <InputField
          label="Location"
          {...register("location", {
            required: "Location is required",
          })}
          error={formState.errors["location"]}
        />
        <InputField
          label="Info"
          type="textarea"
          {...register("info", {
            required: "Info is required",
          })}
          error={formState.errors["info"]}
        />
        <Button isDisabled={createJob.isLoading} isLoading={createJob.isLoading} type="submit">
          Create
        </Button>
      </Stack>
    </Box>
  );
};

0 人点赞