GraphQL 工程篇:分页、数据优化与 React Hooks 实战
之前的笔记:
- GraphQL 入门篇:基础查询语法
- GraphQL 实战篇:Apollo Client 配置与缓存
完整的代码依旧在:
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) {idnamedescriptionjobs {iddatetitle}}}
`;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 attributesconst [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 问题了,以当前这个项目为例,它可能会出现这样的情况:
具体的流程为:
-
出现调用
getJobs()
方法的情况 -
每一个 job 都会有对应的 company
根据现在的实现,获取 company 的方法为:
company: (job) => {return getCompany(job.companyId);},
-
数据库会通过
select * from db where id={job.companyId}
的方法去返回所有的公司如果有 N 个 job,就会调用 N 次该查询,去寻找对应的公司
再算上原本调用 Jobs 的 query,就形成了 n+1 次调用
解决方案 & 使用方式
通过 **dataloader,调用方式就变成了这样:**
具体的流程为:
-
出现调用
getJobs()
方法的情况 -
获取所有 jobs 的
companyId
-
通过
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
增加定义,否则无法 query 到数据type Query {job(id: ID!): Jobjobs(limit: Int, offset: Int): [Job]company(id: ID!): Company }
- 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><buttononClick={() => 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!): Jobjobs(limit: Int, offset: Int): PaginatedListcompany(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><PaginationBarcurrentPage={currPage}totalPages={totalPages}onPageChange={setCurrPage}/><JobList jobs={jobs?.items} /></div>); }export default HomePage;
pagination 按钮的实现省略了,不过就是按照 totalPages
进行的计算,大体逻辑就是
- 高亮当前页面
- 当
currPage === 1
,disable 第一页 - 当
currPage === totalPages
,disable 最后一页