在NestJS中用GraphQL实现分页功能
分页是一个常见的用户体验问题,在许多数据驱动的应用程序中出现。我们需要限制屏幕上显示的内容;如果我的内容需要过多的滚动,在我的网站上导航就会成为用户的痛苦经历。
在这篇文章中,我们将看一下解决这个问题的常见方法,即用GraphQL构建NestJS服务器,然后在React前端上消费它。通过以下章节的逐步深入,我们将建立一个简单的分页系统,可以应用于各种应用程序。
本指南将分为三个主要部分。
这是一个在NestJS和GraphQL中实现简单分页系统的实用指南。你可以在本指南中建立的应用程序的基础上进行改进,以创造出更适合生产的东西。
我建议用编码来巩固这些概念。本指南中的所有代码都可以在我的GitHub上找到。
我们要建立什么?
我们要构建的应用程序是一个简单的React前端,允许用户浏览用户列表。它足够简单,可以很容易地理解我们要讲的不同概念,同时也足够实用,可以为现有的应用程序进行修改。

这应该随时会得到AWWWARDS的提名。
分页算法
在建立这个项目之前,我们应该先了解一下我们将要实现的分页算法,这是值得的。当我们开始创建文件和编写代码时,这将帮助你理解项目的每个部分。
让我们跳过前面,看看最后的GraphQL查询,我们将调用它来获取和分页用户列表。
{
count
users(take: 20, skip: 0) {
firstName
lastName
}
}
该查询由两个资源组成:count 和users 。
第一个,count ,你可以从名字中看出,它只是返回数据库中所有用户的数量。另一个资源,users ,让我们指定我们要检索多少个用户(take ),以及从一个偏移量开始检索(skip )。
我们如何用这个简单的查询实现分页?
考虑一下我们有五个资源的情况。
[one, two, three, four, five]
如果我们运行上述参数为take = 2, skip = 0 的查询,我们会得到以下资源。
[one, two]
而如果我们再次运行同样的查询,但有以下参数。
take = 2, skip = 2
我们会得到以下资源。
[three, four]

take 和skip 是如何工作的,但从视觉上来看
通过跟踪我们在前台检索到的用户数量,我们可以向skip 参数传递一个数字,以检索到正确的下一个用户数量。这一点在我们实现前台时将会变得更加清晰。
现在,让我们设置API来实现到目前为止讨论的功能。
使用NestJS、GraphQL和Mongoose设置一个API
通常情况下,我们会从建立一个新的NestJS项目开始,并安装一些依赖项来让我们开始。
然而,为了跳过所有设置项目的痛苦部分来跟随教程,我已经提前设置了一个包含所有必要库和设置文件的仓库。
仓库是一个包含后端和前端组件的单库。这使得我们可以在一个单一的版本库中构建API和前端,在开发时间上释放额外的速度。
它依赖于Yarn工作空间,所以你需要同时安装npm和Yarn。
克隆版本库并运行以下命令来开始。
git clone https://github.com/ovieokeh/graphql-nestjs-pagination-guide.git
npm install
cd ../workspaces/frontend
npm install
cd workspaces/backend
npm install
mkdir src && cd src
如果你运行package.json 文件中的任何命令,它们很可能会出错。如果你的编辑器配置了,你也可能看到eslint 错误。这很好。我们会在学习指南的过程中解决这些问题。
现在你已经安装了所有需要的软件包,我们可以开始构建我们的API的不同组件。
Mongoose模式设置
首先,我们需要建立一个能够查询GraphQL的数据库。我决定在本指南中使用Mongoose,因为它是目前最流行的数据库ORM之一,但你应该能够在其他ORM中应用同样的概念。
我们将首先创建一个src/mongoose 文件夹和一个src/mongoose/schema.ts 文件来保存我们的数据库类型、模型和模式。
mkdir mongoose
touch mongoose/schema.ts
现在,让我们来配置我们的schema.ts 文件。
// src/mongoose/schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document } from 'mongoose'
export type UserDocument = UserModel & Document
@Schema()
export class UserModel {
@Prop()
firstName: string
@Prop()
lastName: string
@Prop()
email: string
@Prop()
dateOfBirth: Date
}
export const UserSchema = SchemaFactory.createForClass(UserModel)
UserDocument是一个TypeScript类型,代表一个用户模型和Mongoose文件UserModel代表一个将被存储在数据库中的单一用户UserSchema是一个由Mongoose模式衍生出来的模式。UserModel
当我们完成API的设置时,我们将利用这些。
NestJS和GraphQL
接下来,我们需要创建一些文件和文件夹,这将在我们填充内容时解释。
mkdir users && cd users
mkdir dto entities
touch dto/fetch-users.input.ts entities/user.entity.ts
dto/fetch-users.input.ts
// dto/fetch-users.input.ts
import { Field, Int, ArgsType } from '@nestjs/graphql'
import { Max, Min } from 'class-validator'
@ArgsType()
export class FetchUsersArgs {
@Field(() => Int)
@Min(0)
skip = 0
@Field(() => Int)
@Min(1)
@Max(50)
take = 25
}
FetchUsersArgs 是一个数据传输对象(DTO),这意味着它描述了一块正在通过网络发送的数据。在这种情况下,它描述的是参数, 和 ,我们在查询用户时将把这些参数传递给API。skip take
我们要创建的下一组文件是用户服务、解析器和模块。
创建 **users.service.ts**文件
touch users.service.ts users.resolver.ts users.module.ts
import { Model } from 'mongoose'
import { Injectable } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { UserDocument, UserModel } from '../../mongoose/schema'
import { FetchUsersArgs } from './dto/fetch-users.input'
import { User } from './entities/user.entity'
@Injectable()
export class UsersService {
constructor(
@InjectModel(UserModel.name) private userModel: Model<UserDocument>,
) {}
... continues below (1) ...
NestJS使用@InjectModel 装饰将我们之前创建的Mongoose数据库注入到UsersService 类。这允许我们使用getCount 和findAll 方法来查询数据库。
... continues from (1) ...
async getCount(): Promise<number> {
const count = await this.userModel.countDocuments()
return count
}
... continues below (2) ...
UsersService.getCount() 是一个方法,允许我们获取数据库中的用户总数。这个数字对于实现前端的编号分页组件很有用。
... continues from (2) ...
async findAll(args: FetchUsersArgs = { skip: 0, take: 5 }): Promise<User[]> {
const users: User[] = (await this.userModel.find(null, null, {
limit: args.take,
skip: args.skip,
})) as User[]
return users
}
}
UsersService.findAll({ skip, take }) 是一个方法,可以获取指定数量的用户(用 参数)以及一个偏移量( )。take``skip
这两个方法构成了我们将要建立的分页系统的基础。
创建users.resolver.ts 文件
import { Resolver, Query, Args } from '@nestjs/graphql'
import { User } from './entities/user.entity'
import { UsersService } from './users.service'
import { FetchUsersArgs } from './dto/fetch-users.input'
@Resolver(() => User)
export class UsersResolver {
constructor(private readonly usersService: UsersService) {}
@Query(() => Number, { name: 'count' })
async getCount(): Promise<number> {
return this.usersService.getCount()
}
@Query(() => [User], { name: 'users' })
async findAll(@Args() args: FetchUsersArgs): Promise<User[]> {
return this.usersService.findAll(args)
}
}
UsersResolver 类是count 和users 查询的 GraphQL 解析器。这些方法只是调用相应的UsersService 方法。
创建 users.module.ts 文件
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { UserModel, UserSchema } from '../../mongoose/schema'
import { UsersService } from './users.service'
import { UsersResolver } from './users.resolver'
@Module({
imports: [
MongooseModule.forFeature([{ name: UserModel.name, schema: UserSchema }]),
],
providers: [UsersResolver, UsersService],
})
export class UsersModule {}
UsersModule 类导入了Mongoose模式并配置了解析器和服务类,如上文所定义。这个模块被传递给主应用程序模块,并允许进行前面定义的查询。
创建app.module.ts 文件
最后,为了将一切联系在一起,让我们创建一个app.module.ts 文件,以消费我们到目前为止定义的所有模块。
import { Module } from '@nestjs/common'
import { GraphQLModule } from '@nestjs/graphql'
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'
import { MongooseModule } from '@nestjs/mongoose'
import { UsersModule } from './users/users.module'
import { ConfigModule, ConfigService } from '@nestjs/config'
import configuration from '../nest.config'
@Module({
imports: [
UsersModule,
ConfigModule.forRoot({
load: [configuration],
}),
MongooseModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
uri: configService.get('databaseUrl'),
}),
inject: [ConfigService],
}),
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: 'schema.gql',
include: [UsersModule],
}),
],
})
export class AppModule {}
如果你已经有GraphQL和NestJS的经验,这一切都应该是熟悉的。我们正在导入。
GraphQLModule用于设置GraphQLMongooseModule用于数据库UsersModule用于用户资源ConfigModule用于设置环境变量
现在,在添加你的数据库连接URI之前,确保设置一个MongoDB数据库,并使用.env.example 作为指导创建一个.env 文件。
在这一点上,你现在可以通过做以下事情来测试API。
-
确保你在后端目录中 -
cd src/workspaces/backend -
运行
yarn seed,在一些假的用户数据中播种。 -
运行
yarn start:dev,在3000端口启动服务器。 -
导航至
[http://localhost:3000/graphql](http://localhost:3000/graphql)在你的浏览器上打开GraphQL平台,在那里你可以尝试 "分页算法"部分的查询,像这样:{ count users(take: 20, skip: 0) { firstName lastName } }
如果你已经走到了这一步,你就是一个摇滚明星
。
这是一个休息的好时机,可以再次浏览一下后端代码。花点时间去理解它,也许可以喝杯果汁(或者茶,如果你很花哨的话),然后继续前台的工作。
构建一个React前台来消费API
有了我们的后端,我们现在可以创建一个闪亮的React前端来实现一个基本的分页系统。
构建组件
你可以利用workspaces/frontend 文件夹,而不是建立一个全新的前端项目,因为它已经有一个React应用,并安装了所有必要的依赖。
cd ../frontend/src
让我们先用自下而上的方法来构建组件,最后再将其全部整合。
我们将需要以下组件。
Users- 查询API并显示用户列表Pagination- 提供分页逻辑并显示控件App- 显示用户和分页Index- 在Apollo提供器中封装应用程序并渲染到DOM中
编写我们的 **users.tsx**组件

只是一个名字的列表
该组件将使用@apollo/client 库查询GraphQL API,并在查询结果出来后渲染一个用户列表。
// ensure you're in /workspaces/frontend/src
touch Users.tsx
打开新创建的文件。
// Users.tsx
import { gql, useQuery } from '@apollo/client'
const GET_USERS = gql`
query GetUsers($skip: Int!, $amountToFetch: Int!) {
users(skip: $skip, take: $amountToFetch) {
id
firstName
lastName
}
}
`
type User = {
id: string
firstName: string
lastName: string
}
... continues below (3) ...
在文件的顶部,我们从前面提到的@apollo/client 库中导入gql 和useQuery 。
gql 允许我们建立一个具有动态变量替换等功能的GraphQL查询。 变量是一个查询,要求从一个偏移量 的长度为 的列表 。GET_USERS $skip users $amountToFetch
我们正在查询每个用户的id,firstName, 和lastName 属性。User 变量是一个TypeScript类型,它指定了一个用户的结构。
... continues from (3) ...
const Users = (props: { skip?: number; amountToFetch?: number }) => {
const { data } = useQuery<{ count: number; users: User[] }>(GET_USERS, {
variables: props,
})
const renderedUsers = data?.users?.map(({ id, firstName, lastName }) => {
const name = `${firstName} ${lastName}`
return (
<div key={id}>
<p>{name}</p>
</div>
)
})
return <div className="Users">{renderedUsers}</div>
}
export default Users
最后,我们有一个Users 组件,接受两个道具:skip 和amountToFetch 。
它立即启动了对API的查询,即GET_USERS 查询,以及将props 作为variables 。
然后,我们对用户数组进行映射(使用三元操作符,以防数据还没有准备好),并返回一个包含每个用户名字的div 。
在最后,返回语句完成了这个组件。
我们的 **pagination.tsx**组件

光荣的分页组件
希望你对React中的renderProps 技术很熟悉。这个组件利用renderProps 来渲染一个带有道具的组件,以及渲染一个选择输入和一些按钮。
创建一个新的Pagination.tsx 文件并打开它。
// ensure you're in /workspaces/frontend/src
touch Pagination.tsx
我们将首先从React中导入一些类型和工具,并设置一些状态变量来跟踪分页组件的当前状态。
import { ChangeEvent, cloneElement, FunctionComponentElement, useState } from 'react'
const Pagination = ({ count, render }: {
count: number
render: FunctionComponentElement<{ skip: number; amountToFetch: number }>
}) => {
const [step, setStep] = useState(0)
const [amountToFetch, setAmountToFetch] = useState(10)
... continues below (4) ...
Pagination 组件接受两个prop。
count- 数据库中的用户总数。用于计算要在UI中呈现的步骤数render- 一个React组件,将从 组件接收额外的propPagination
它也有两个状态变量。
step- 当前正在渲染的步骤amountToFetch- 在任何时候要获取的用户数量
... continues from (4) ...
const steps = count ? Math.ceil(count / amountToFetch) : 0
const renderedSteps = new Array(steps).fill(0).map((num, index) => (
<button
data-is-active={index === step}
key={index}
type="button"
onClick={() => setStep(index)}
>
{index + 1}
</button>
))
const renderWithProps = cloneElement(render, {
skip: step * amountToFetch,
amountToFetch,
})
... continues below (5) ...
接下来,定义三个变量。
-
steps- 这做了一些简单的算术,以获得要渲染的步骤数
> 如果计数=10个用户,并且 amountToFetch = 5
> 步骤 = 2 // < 1 2 >
> 如果计数=10个用户,并且 amountToFetch = 2
> 步骤 = 5 // < 1 2 3 4 5 > -
renderedSteps- 利用 ,渲染一个来自 的按钮数组。每个按钮都有一个 处理程序,更新 的状态。steps1..stepsonClickstep -
renderWithProps- 克隆在 道具中传递的组件,并向其添加两个新的道具。renderskip- 在查询用户时要跳过多少?amountToFetch- 要检索的用户数量
... continues from (5) ... return ( <> {renderWithProps} <select name="amount to fetch" id="amountToFetch" value={amountToFetch} onChange={(e: ChangeEvent<HTMLSelectElement>) => { const newAmount = +e.target.value setAmountToFetch(newAmount) setStep(0) }} > <option value={10}>10</option> <option value={20}>20</option> <option value={50}>50</option> </select> <button type="button" disabled={step === 0} onClick={() => setStep((prevstep) => prevstep - 1)} > {'<'} </button> {renderedSteps} <button type="button" disabled={(step + 1) * amountToFetch > count} onClick={() => setStep((prevstep) => prevstep + 1)} > {'>'} </button> </> ) } export default Pagination
最后,我们向DOM渲染五个元素。
renderWithProps:克隆的render组件,并添加了道具select:控制amountToFetch状态变量,并允许用户改变每页获取的用户数量。我们目前已经硬编码了20、50和100三个步骤。onChange处理程序更新了amountToFetch状态,并重置了stepbutton:允许用户向后移动一步renderedSteps:一个允许切换到相应步骤的按钮列表button:允许用户向前移动一步
再次,花点时间呼吸,放松,并理解到目前为止所涉及的概念。散步可能不是一个坏主意
React和Apollo
我们现在离终点线已经很近了!剩下的就是将Users 组件与Pagination 组件连接起来,然后进行渲染。
创建一个App.tsx 文件并打开它。
// ensure you're in /workspaces/frontend/src
touch App.tsx
这里是我们的文件内容。
import { gql, useQuery } from '@apollo/client'
import Users from './Users'
import Pagination from './Pagination'
import './App.css'
const GET_USERS_COUNT = gql`
query GetUsersCount {
count
}
`
function App() {
const { data } = useQuery<{ count: number }>(GET_USERS_COUNT)
return (
<div className="App">
<Pagination count={data?.count || 0} render={(<Users />) as any} />
</div>
)
}
export default App
这是一个相对简单的组件。我们导入。
gql和 ,用于我们将在下面定义的一个查询。useQueryUsers和Pagination组件- 项目自带的CSS样式表
然后我们定义GET_USERS_COUNT 查询,它只是请求数据库中的用户总数。
App 函数请求GET_USERS_COUNT 查询,并将结果存储在data 变量中。
在return 语句中,我们将Pagination 组件渲染成 div 和 -。
- 将
data.count变量作为count道具传递给对方。 - 将
Users组件作为render的道具。
只剩下最后一个部分,你就可以在浏览器中测试你的结果了。呜呼!
现在,创建一个index.tsx 文件并打开它。
// ensure you're in /workspaces/frontend/src
touch index.tsx
这里又是我们的文件内容。
import React from 'react'
import ReactDOM from 'react-dom/client'
import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client'
import App from './App'
import './index.css'
const client = new ApolloClient({
uri: process.env.REACT_APP_API_GRAPHQL_URL,
cache: new InMemoryCache(),
})
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
root.render(
<React.StrictMode>
<ApolloProvider client={client}>
<App />
</ApolloProvider>
</React.StrictMode>
)
这个文件中的大部分内容现在应该感到很熟悉了。有趣的是,我们正在创建一个新的Apollo客户端来连接到我们的API,并在root.render 语句中把它传递给Apollo提供者。
注意:确保使用.env.example 作为指导创建一个.env 文件,并添加你的API URL(很可能是http:localhost:3000/graphql )。
在这一点上,你现在可以在浏览器中启动前台,并对你的创造感到惊叹。
- 确保后端正在运行 (
yarn start:dev) - 确保你在
workspaces/frontend,并运行yarn start - 导航到http://localhost:3001

Tada!
总结
继续与分页控件进行互动。也许你可以找到截断中间部分的方法,甚至添加一些漂亮的样式;这是一个基本的分页系统,你可以根据任何数据类型或场景进行定制。