GraphQL 工程篇:分页、数据优化与 React Hooks 实战

发布于:2025-06-13 ⋅ 阅读:(19) ⋅ 点赞:(0)

GraphQL 工程篇:分页、数据优化与 React Hooks 实战

之前的笔记:

完整的代码依旧在:

https://github.com/GoldenaArcher/graphql-by-example

React 集成

这一篇集成了一些 React 的内容,主要包括 custom hook 的部分

之前的实现基于封装实现,在 React 组建内调用,就需要实现 loading、error,不过 apollo-client 本身也提供了基于不同实现的集成:

下面的部分就会使用 apollo-client 对 React 的集成进行重构

添加 provider

首先需要导入 ApolloProvider,目前项目就放在 App.jsx 中实现了,放在最顶层,这样下面所有的组件都可以共享组件的数据、状态和缓存

import { ApolloProvider } from "@apollo/client";

function App() {
  return (
    <ApolloProvider client={apolloClient}>
      <NavBar user={user} onLogout={handleLogout} />
      <main className="section"></main>
    </ApolloProvider>
  );
}

export default App;

useQuery

随后是将之前的 gql 全都导出成一个单独的 query:

export const companyByIdQuery = gql`
  query CompanyById($id: ID!) {
    company(id: $id) {
      id
      name
      description
      jobs {
        id
        date
        title
      }
    }
  }
`;

export async function getCompany(id) {
  const query = companyByIdQuery;

  const { data } = await apolloClient.query({ query, variables: { id } });
  return data.company;
}

这里 getCompany 其实可以删了,不过这里留着作为一个 referred

接下来就是在 React 组建中使用合适的 query:

import { useParams } from "react-router";
import { companyByIdQuery } from "../lib/graphql/queries";
import JobList from "../components/JobList";
import { useQuery } from "@apollo/client";

function CompanyPage() {
  const { companyId } = useParams();
  const { data, loading, error } = useQuery(companyByIdQuery, {
    variables: { id: companyId },
  });

  // const [state, setState] = useState({
  //   company: null,
  //   loading: true,
  //   hasErrors: false,
  // });

  // useEffect(() => {
  //   (async () => {
  //     try {
  //       const company = await getCompany(companyId);
  //       setState({ company, loading: false, hasErrors: false });
  //     } catch (e) {
  //       setState({
  //         company: null,
  //         loading: false,
  //         hasErrors: true,
  //       });
  //     }
  //   })();
  // }, [companyId]);

  // const { company, loading, hasErrors } = state;

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

  if (error) {
    return <div>Something went wrong...</div>;
  }

  const company = data.company;

  return (
    <div>
      <h1 className="title">{company.name}</h1>
      <div className="box">{company.description}</div>
      <h2 className="title is-5">Jobs At {company.name}</h2>
      <JobList jobs={company.jobs} />
    </div>
  );
}

export default CompanyPage;

可以看到, useQuery 本身接管了状态管理的部分,因此相比较原本的实现,使用 apollo-client 提供的 hooks 的代码更加的简洁明了

换句话说,如果前端的实现比较简单,不需要在非 React 组件中调用,那么使用 apollo-client 提供的 hooks 即可;如果有额外的业务需求,需要在 React 组件外调用 GQL,那么可以 GQL 保存为对应的方法,再在不同的地方进行调用

不过总体来说,90%以上的业务场景是可以通过直接使用 apollo-client 提供的 hooks 解决的

custom hook

这个的实现比较简单,就是将对应的业务逻辑保存到对应的 custom hooks 里,起到更加方便管理、提升代码复用率的作用:

  • custom hook

    import { useQuery } from "@apollo/client";
    import { companyByIdQuery } from "../lib/graphql/queries";
    
    export const useFetchCompany = (companyId) => {
      const { data, loading, error } = useQuery(companyByIdQuery, {
        variables: { id: companyId },
      });
    
      return { company: data?.company, loading, error };
    };
    
  • 组件内调用

    import { useParams } from "react-router";
    import JobList from "../components/JobList";
    import { useFetchCompany } from "../hooks/useCompany";
    
    function CompanyPage() {
      const { companyId } = useParams();
      const { company, loading, error } = useFetchCompany(companyId);
    
      if (loading) {
        return <div>Loading...</div>;
      }
    
      if (error) {
        return <div>Something went wrong...</div>;
      }
    
      return (
        <div>
          <h1 className="title">{company.name}</h1>
          <div className="box">{company.description}</div>
          <h2 className="title is-5">Jobs At {company.name}</h2>
          <JobList jobs={company.jobs} />
        </div>
      );
    }
    
    export default CompanyPage;
    

同样的,有需要讲 GQL 保存为单独的方法在不同地方调用的需求,也是可以通过封装 custom hook 减少 React 组件内的重复代码

useMutate

useQuery 对立,不过这里是 mutation,即 CUD 操作的实现

  • custom hook

    export const useCreateJob = () => {
      const [mutate, options] = useMutation(createJobMutation);
    
      const createJob = async ({ title, description }) => {
        const {
          data: { createJob: job },
        } = await mutate({
          variables: {
            input: {
              title,
              description,
            },
          },
          update: (cache, { data: { createJob: job } }) => {
            cache.writeQuery({
              query: jobByIdQuery,
              data: {
                job,
              },
            });
          },
        });
    
        return job;
      };
    
      return [createJob, options];
    };
    
  • 调用

    import { useState } from "react";
    import { useNavigate } from "react-router-dom";
    import { useCreateJob } from "../hooks/useJob";
    
    function CreateJobPage() {
      // omitting other state management
      // loading can be used to block re-submit with disable attributes
      const [createJob, { loading }] = useCreateJob();
    
      const handleSubmit = async (event) => {
        event.preventDefault();
        const job = await createJob({ title, description });
    
        navigate(`/jobs/${job.id}`);
      };
    
      // omitting return
    }
    
    export default CreateJobPage;
    

这种封装的方法,根据不同情况可以修改不同的返回值

Data Loader

Data Loader,这里提到的,是一个 npm 上的包:**dataloader,在这里主要可以提升和优化,解决一下 n+1 问题**

n+1 问题

n+1 问题是一个比较经典的数据库 query 问题了,以当前这个项目为例,它可能会出现这样的情况:

GraphQL: jobs + company
getJobs
Job 1
Job 2
Job 3
getCompany by companyId1
getCompany by companyId2
getCompany by companyId3

具体的流程为:

  1. 出现调用 getJobs() 方法的情况

  2. 每一个 job 都会有对应的 company

    根据现在的实现,获取 company 的方法为:

    company: (job) => {
          return getCompany(job.companyId);
        },
    
  3. 数据库会通过 select * from db where id={job.companyId} 的方法去返回所有的公司

    如果有 N 个 job,就会调用 N 次该查询,去寻找对应的公司

    再算上原本调用 Jobs 的 query,就形成了 n+1 次调用

解决方案 & 使用方式

通过 **dataloader,调用方式就变成了这样:**

GraphQL: jobs + company
getJobs
Job 1
Job 2
Job 3
DataLoader load by companyId
batch getCompaniesByIds

具体的流程为:

  1. 出现调用 getJobs() 方法的情况

  2. 获取所有 jobs 的 companyId

  3. 通过 select * from db where id in (a,b,c,d) 的方式去调用

    这样可以有效地把 n 次调用减少为 1 次 → 假设没有 pagination,这样可以减少后端和数据库之间沟通的压力

具体使用方式为:

  • 新增一个 loader
    export const companyLoader = new DataLoader(async (ids) => {
      const companies = await getCompanyTable().select().whereIn("id", ids);
      return ids.map((id) => companies.find((company) => company.id === id));
    });
    
  • 在 resolver 中调用这个 loader
      Job: {
        date: (parent) => {
          return toIsoDate(parent.createdAt);
        },
        company: (job) => {
          return companyLoader.load(job.companyId);
        },
      },
    

缓存问题

默认情况下 **dataloader 是会 cache 数据的,也就是说,如果使用 singleton,在永远都有 1 个实例的情况下,就只能获取实例初始化时获取的资料。如果这是一个只读数据库,那么不会有很大的问题,不过在大多数的业务场景下,还是需要考虑 CRUD 四个操作的**

目前的解决方式是在 context 中,在每次调用时,都创建一个新的 instance

  • loader 更新

    const companyLoader = new DataLoader(async (ids) => {
      const companies = await getCompanyTable().select().whereIn("id", ids);
      return ids.map((id) => companies.find((company) => company.id === id));
    });
    
    export const createCompanyLoader = () => companyLoader;
    
  • server 中更新 context

    const getContext = async ({ req }) => {
      const companyLoader = createCompanyLoader();
      const context = {
        companyLoader,
      };
    
      if (!req.auth) {
        return context;
      }
    
      const user = await getUser(req.auth.sub);
      context.user = user;
    
      return context;
    };
    
  • 调用

      Job: {
        date: (parent) => {
          return toIsoDate(parent.createdAt);
        },
        company: (job, _args, { companyLoader }) => {
          return companyLoader.load(job.companyId);
        },
      },
    

这样总体就能够解决数据缓存从而 stale 的问题

分页 pagination

分页根据具体的实现大体分成两种:

  • offset pagination
    这种情况假设数据的变更不是非常的频繁,单纯的将数据按页分。比如说如果有 100 个数据,分成 10 页,那么数据的现实就是 1-10,11-20,等
    使用 offset 会遇到的问题就是,如果数据的变更比较频繁,依旧以上面的案例来说,在用户查询 1-10 条数据后,向数据库的头部新增 10 条新的数据,用户再查询第二页时,出现的还是 1-10 条数据
  • cursor pagination
    cursor 的实现比较复杂,它的分页不是取决于数据库的 index,而是用一个更加动态的 hash id,这样可以解决 offset 的问题,不过在实现的时候也更加的负责。一些实现会用数据库 id——用的是 uuid 这种——去进行 cursor pagination,不过具体我也没有做过这方面的后端实现,不是特别的了解
    cursor pagination 用的比较多的地方就是流媒体/短视频/社媒这种平台,数据推送/新增更加的无序,更加依赖算法实现,所以用 cursor pagination 能够很好的减少重复数据推送的问题——也就是当数据操作比较频繁时,offset pagination 中比较常见的问题
    之前写 YT 复刻的时候,因为用的就是 YT 的 API,所以复刻的也是实际的功能: **Redux Toolkit + React + TS + Tailwind CSS 复刻 YouTube 学习心得,**yt 用的就是 cursor pagination

下面的案例基于 offset 实现

分页 - server

同样,后端代码需要修改 3 个地方

  • schema
    type Query {
      job(id: ID!): Job
      jobs(limit: Int, offset: Int): [Job]
      company(id: ID!): Company
    }
    
    增加定义,否则无法 query 到数据
  • resolvers
      Query: {
        jobs: async (_root, { limit , offset}) => getJobs(limit, offset),
      },
    
    接和传参
  • db
    export async function getJobs(limit, offset) {
      const query = getJobTable().select().orderBy("createdAt", "desc");
      if (limit) {
        query.limit(limit);
      }
      if (offset) {
        query.offset(offset);
      }
      return await query;
    }
    
    具体的数据库实现

分页 - client

client 也是修改三个部分,query,hook 和 component

  • queries

    export const jobsQuery = gql`
      query Jobs($limit: Int, $offset: Int) {
        jobs(limit: $limit, offset: $offset) {
          ...JobDetail
        }
      }
      ${jobDetailFragment}
    `;
    

    同上,可以从 GQL 拿到数据

  • hook

    export const useFetchJobs = (limit, offset) => {
      const { data, loading, error } = useQuery(jobsQuery, {
        variables: { limit, offset },
      });
      return {
        jobs: data?.jobs,
        loading,
        error,
      };
    };
    

    同理,接和传参

  • component

    import { useState } from "react";
    import JobList from "../components/JobList";
    import { useFetchJobs } from "../hooks/useJob";
    
    const JOBS_PER_PAGE = 5;
    
    function HomePage() {
      const [currPage, setCurrPage] = useState(1);
      const { jobs, error, loading } = useFetchJobs(
        JOBS_PER_PAGE,
        (currPage - 1) * JOBS_PER_PAGE
      );
    
      return (
        <div>
          <h1 className="title">Job Board</h1>
          <div>
            <button
              onClick={() => setCurrPage((prev) => (prev === 1 ? 1 : prev - 1))}
            >
              Prev
            </button>
            <span>{currPage}</span>
            <button onClick={() => setCurrPage((prev) => prev + 1)}>Next</button>
          </div>
          <JobList jobs={jobs} />
        </div>
      );
    }
    
    export default HomePage;
    

    具体的渲染实现

最终效果如下:

这时候还有一个问题,那就是查到数据库没有的数据——也就是本来数据库里只有 50 条数据,但是 query 到了 60,70,甚至更多的情况。GQL 现在是不会报错了 ,不过依旧会给用户一种错误感觉,就是数据和服务不完整的感觉

总数计算 - server

这里同样修改 3 个地方:

  • schema

    type Query {
      job(id: ID!): Job
      jobs(limit: Int, offset: Int): PaginatedList
      company(id: ID!): Company
    }
    
    type PaginatedList {
      totalCount: Int!
      items: [Job]
    }
    
  • db

    export const countJobs = async () => {
      const { count } = await getJobTable().first().count("* as count");
      return count;
    };
    

    新增的一个功能,主要查询总数

  • resolvers

    export const resolvers = {
      Query: {
        jobs: async (_root, { limit, offset }) => {
          const [items, totalCount] = await Promise.all([
            getJobs(limit, offset),
            countJobs(),
          ]);
          return { items, totalCount };
        },
      },
    };
    

    这里就是调用 db 新增加的功能,然后返回

至此,后端的数据已经准备好,可以让前端调用了

总数计算 - client

这里更新两个地方,queries 拿数据——数据结构没有变,所以 hooks 不变,component 渲染

  • queries

    export const jobsQuery = gql`
      query Jobs($limit: Int, $offset: Int) {
        jobs(limit: $limit, offset: $offset) {
          items {
            ...JobDetail
          }
          totalCount
        }
      }
      ${jobDetailFragment}
    `;
    
  • component

    import { useState } from "react";
    import JobList from "../components/JobList";
    import PaginationBar from "../components/PaginationBar";
    import { useFetchJobs } from "../hooks/useJob";
    
    const JOBS_PER_PAGE = 5;
    
    function HomePage() {
      const [currPage, setCurrPage] = useState(1);
      const { jobs, error, loading } = useFetchJobs(
        JOBS_PER_PAGE,
        (currPage - 1) * JOBS_PER_PAGE
      );
    
      const totalPages = Math.ceil((jobs?.totalCount || 0) / JOBS_PER_PAGE);
    
      return (
        <div>
          <h1 className="title">Job Board</h1>
          <PaginationBar
            currentPage={currPage}
            totalPages={totalPages}
            onPageChange={setCurrPage}
          />
          <JobList jobs={jobs?.items} />
        </div>
      );
    }
    
    export default HomePage;
    

pagination 按钮的实现省略了,不过就是按照 totalPages 进行的计算,大体逻辑就是

  • 高亮当前页面
  • currPage === 1,disable 第一页
  • currPage === totalPages,disable 最后一页