在NestJS中用GraphQL实现分页功能的方法

661 阅读13分钟

在NestJS中用GraphQL实现分页功能

分页是一个常见的用户体验问题,在许多数据驱动的应用程序中出现。我们需要限制屏幕上显示的内容;如果我的内容需要过多的滚动,在我的网站上导航就会成为用户的痛苦经历。

在这篇文章中,我们将看一下解决这个问题的常见方法,即用GraphQL构建NestJS服务器,然后在React前端上消费它。通过以下章节的逐步深入,我们将建立一个简单的分页系统,可以应用于各种应用程序。

本指南将分为三个主要部分。

  1. 分页算法
  2. 使用NestJS、GraphQL和Mongoose设置API
  3. 构建一个React前端来消费API

这是一个在NestJS和GraphQL中实现简单分页系统的实用指南。你可以在本指南中建立的应用程序的基础上进行改进,以创造出更适合生产的东西。

我建议用编码来巩固这些概念。本指南中的所有代码都可以在我的GitHub上找到。

我们要建立什么?

我们要构建的应用程序是一个简单的React前端,允许用户浏览用户列表。它足够简单,可以很容易地理解我们要讲的不同概念,同时也足够实用,可以为现有的应用程序进行修改。

Page through a list of users

这应该随时会得到AWWWARDS的提名。

分页算法

在建立这个项目之前,我们应该先了解一下我们将要实现的分页算法,这是值得的。当我们开始创建文件和编写代码时,这将帮助你理解项目的每个部分。

让我们跳过前面,看看最后的GraphQL查询,我们将调用它来获取和分页用户列表。

{
  count
  users(take: 20, skip: 0) {
    firstName
    lastName
  }
}

该查询由两个资源组成:countusers

第一个,count ,你可以从名字中看出,它只是返回数据库中所有用户的数量。另一个资源,users ,让我们指定我们要检索多少个用户(take ),以及从一个偏移量开始检索(skip )。

我们如何用这个简单的查询实现分页?

考虑一下我们有五个资源的情况。

[one, two, three, four, five]

如果我们运行上述参数为take = 2, skip = 0 的查询,我们会得到以下资源。

[one, two]

而如果我们再次运行同样的查询,但有以下参数。

take = 2, skip = 2

我们会得到以下资源。

[three, four]

Using skip and take to keep track of users

takeskip 是如何工作的,但从视觉上来看

通过跟踪我们在前台检索到的用户数量,我们可以向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 类。这允许我们使用getCountfindAll 方法来查询数据库。

... 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 类是countusers 查询的 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 用于设置GraphQL
  • MongooseModule 用于数据库
  • UsersModule 用于用户资源
  • ConfigModule 用于设置环境变量

现在,在添加你的数据库连接URI之前,确保设置一个MongoDB数据库,并使用.env.example 作为指导创建一个.env 文件。

在这一点上,你现在可以通过做以下事情来测试API。

  1. 确保你在后端目录中 -cd src/workspaces/backend

  2. 运行yarn seed ,在一些假的用户数据中播种。

  3. 运行yarn start:dev ,在3000端口启动服务器。

  4. 导航至 [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**组件

List of names

只是一个名字的列表

该组件将使用@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 库中导入gqluseQuery

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 组件,接受两个道具:skipamountToFetch

它立即启动了对API的查询,即GET_USERS 查询,以及将props 作为variables

然后,我们对用户数组进行映射(使用三元操作符,以防数据还没有准备好),并返回一个包含每个用户名字的div

在最后,返回语句完成了这个组件。

我们的 **pagination.tsx**组件

Pagination component

光荣的分页组件

希望你对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。

  1. count - 数据库中的用户总数。用于计算要在UI中呈现的步骤数
  2. render - 一个React组件,将从 组件接收额外的propPagination

它也有两个状态变量。

  1. step - 当前正在渲染的步骤
  2. 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) ...

接下来,定义三个变量。

  1. steps - 这做了一些简单的算术,以获得要渲染的步骤数
    > 如果计数=10个用户,并且 amountToFetch = 5
    > 步骤 = 2 // < 1 2 >
    > 如果计数=10个用户,并且 amountToFetch = 2
    > 步骤 = 5 // < 1 2 3 4 5 >

  2. renderedSteps - 利用 ,渲染一个来自 的按钮数组。每个按钮都有一个 处理程序,更新 的状态。steps 1..steps onClick step

  3. renderWithProps - 克隆在 道具中传递的组件,并向其添加两个新的道具。render

    1. skip - 在查询用户时要跳过多少?
    2. 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渲染五个元素。

  1. renderWithProps:克隆的render 组件,并添加了道具
  2. select:控制amountToFetch 状态变量,并允许用户改变每页获取的用户数量。我们目前已经硬编码了20、50和100三个步骤。onChange 处理程序更新了amountToFetch 状态,并重置了step
  3. button:允许用户向后移动一步
  4. renderedSteps:一个允许切换到相应步骤的按钮列表
  5. 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 和 ,用于我们将在下面定义的一个查询。useQuery
  • UsersPagination 组件
  • 项目自带的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

End result is paginated list of users

Tada!

总结

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