使用 React 和 GraphQL 创建无限加载列表
照片由 Lysander Yuen 拍摄,来自 Unsplash
这周我需要在为现有客户开发的 React 应用中实现一个无限加载列表来显示用户。我之前也做过类似的功能,但从未使用过 GraphQL 作为服务器端。是时候学习如何实现GraphQL 规范中描述的分页机制了。
无限加载列表非常实用,它可以将大量数据拆分成小块,并在用户向下滚动列表时逐步加载。dev.to 的首页就是一个很好的例子。它避免了页面完全加载,也为移动应用提供了更好的用户体验。
本文涵盖的内容
本教程将介绍如何使用Apollo Server在 GraphQL 端实现分页。我们不会介绍服务器的配置,但您可以在我之前写的文章中找到如何使用 Koa 进行配置的方法。
其次,我们将使用最近添加到Apollo Client中的hooks在 React 端消费分页查询。
最后,我们将使用Brian Vaughn的react-window库来实现无限加载列表。
分页
我们想要显示一个人员列表。我们将使用一个简单的数据模型,每个人有一个姓名firstname和一个电子邮件地址lastname。这足以说明我们正在实现的功能。
type Person {
id: ID,
lastname: String,
firstname: String,
}
简单的部分已经完成。现在轮到难点了:分页。分页有很多种。你可能知道基于页面的分页,它使用页面索引和元素偏移量。但这种分页模型在常见场景中存在一些局限性。你可以阅读Caleb Meredith的这篇文章了解更多相关信息。
我们将采用基于游标的分页方式。
其原理在于,游标是列表中每个元素的唯一标识符。在我们的例子中,它将是人的 ID(但也可以是任何其他内容)。
我认为举例子是最好的理解方式。让我们来执行一系列查询,加载前两组人员数据。
persons(first: 10)
它将返回前十个人。每个人都有一个游标。我们将使用最后获取到的那个人的游标再次查询 GraphQL 服务器,以获取新的一批人员信息。
persons(first: 10, cursor: "ZmY3OTI0YWMtYTY0Ny00NTIyLWE2ZjEtNzJmMTNhN2E3NjAx")
它会返回我们获取到的最后一个人之后的十个人。
在 GraphQL 中,分页查询返回一个connection。
type Query {
persons(first: Int!, cursor: ID): PersonConnection
}
Aconnection允许您提供比查询元素更多的信息,例如元素总数或与当前页面相关的信息。
type PersonConnection {
edges: [PersonEdge]
pageInfo: PageInfo
}
type PageInfo {
endCursor: ID!,
hasNextPage: Boolean!,
}
元素集合存储在一个edges属性中。该edge属性由我们之前讨论过的游标和一个node包含列表中元素所有相关信息的集合组成。由于游标并非Person直接位于数据中,因此我们可以自由地更改服务器实现而不会影响数据模型。此外,它还允许我们增强边缘节点携带的信息,例如添加一个relations属性来列出与该元素有联系的人员。
type PersonEdge {
cursor: ID!,
node: Person!,
}
现在是时候实现查询的解析器了persons。我们使用 faker 生成随机数据,并提供一个种子,以确保每次请求中人员信息不会改变。
const range = (size, callback) => {
return Array.from({length: size}, callback);
};
const resolvers = {
Query: {
persons: (query, {cursor, first}) => {
faker.seed(123);
const persons = range(200, () => ({
id: random.uuid(),
firstname: name.firstName(),
lastname: name.lastName(),
}));
const cursorIndex = !cursor
? 0
: persons.findIndex(person => person.id === cursor) + 1;
const sliceOfPersons = persons.slice(cursorIndex, cursorIndex + first);
return {
edges: sliceOfPersons.map(person => ({
cursor: person.id,
node: {...person},
})),
pageInfo: {
endCursor: sliceOfPersons[sliceOfPersons.length - 1].id,
hasNextPage: cursorIndex + first < persons.length,
},
};
},
},
};
如果未提供游标参数,则first在数组开头创建一个包含指定数量元素的切片,切片数量由该参数决定。如果cursor提供了游标参数,则找到该人员在数组中的索引,并在该索引处创建一个切片。
我们不忘提供与当前页面相关的信息,方法是将切片中最后一个人的endCursor姓名设置为该属性的值。此外,我们还会添加一个属性,告知用户是否可以查询更多人员信息。indexhastNextPage
服务器端部分已完成。您可以使用 GraphQL Playground 测试查询。Apollo Server 实现默认在您的 GraphQL 端点上提供此功能。http://localhost:4000/graphl在浏览器中打开以下 URL 并输入此查询(URL 取决于您在服务器中配置的端点):
{
persons(first: 10) {
edges {
node {
lastname
firstname
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
我们应该会在右侧面板看到人员列表。
使用 Apollo Client 中的钩子来执行查询
我将其用于create-react-appReact 应用程序。我将使用以下文件夹结构:
.
├── package.json
└── src
├── App.css
├── App.js
├── App.test.js
├── InfiniteList.css
├── InfiniteList.hooks.js
├── InfiniteList.js
├── index.css
└── index.js
首先,我们需要安装 Apollo Client 依赖项。
yarn add apollo-boost @apollo/react-hooks graphql
在文件中,App.js我们使用实例化一个 Apollo 客户端apollo-boost,并将其传递给Providerfrom @apollo/react-hooks。
import React from 'react';
import ApolloClient from 'apollo-boost';
import {ApolloProvider} from '@apollo/react-hooks';
import InfiniteList from './InfiniteList';
import './App.css';
const client = new ApolloClient({
uri: 'http://localhost:4000/graphql',
});
function App() {
return (
<ApolloProvider client={client}>
<div className="App">
<InfiniteList />
</div>
</ApolloProvider>
);
}
export default App;
我喜欢将数据获取逻辑与渲染逻辑分开。我们将在文件中创建一个[自定义钩子] InfiniteList.hooks.js。
我们在 GraphQL 查询中创建了一个常量。
import {gql} from 'apollo-boost';
const GET_PERSONS = gql`
query getPersons($cursor: ID) {
persons(first: 20, cursor: $cursor) {
edges {
node {
lastname
firstname
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
`;
我们定义了一个名为 `finally` 的自定义钩子usePersons,它会返回实现无限加载列表所需的所有变量。要调用我们的查询,我们使用 ` useQueryfinally` 中的钩子@apollo/react-hooks。它接受查询和选项作为参数。我们将选项设置notifyOnNetworkStatusChange为 `true`,这样loading每次调用查询时,变量都会更新。
import {useQuery} from '@apollo/react-hooks';
// ...
function usePersons() {
const {data, loading, fetchMore} = useQuery(GET_PERSONS, {
notifyOnNetworkStatusChange: true,
});
if (loading && !data.persons) return {loading, persons: []};
const loadMore = () => {
return fetchMore({
query: GET_PERSONS,
notifyOnNetworkStatusChange: true,
variables: {
cursor: data.persons.pageInfo.endCursor,
},
updateQuery: (previousResult, {fetchMoreResult}) => {
const newEdges = fetchMoreResult.persons.edges;
const pageInfo = fetchMoreResult.persons.pageInfo;
return newEdges.length
? {
persons: {
__typename: previousResult.persons.__typename,
edges: [...previousResult.persons.edges, ...newEdges],
pageInfo,
},
}
: previousResult;
},
});
};
return {
persons: data.persons.edges.map(({node}) => node),
hasNextPage: data.persons.pageInfo.hasNextPage,
loading,
loadMore,
};
}
export default usePersons;
useQuery返回一个fetchMore函数,我们可以在用户每次向下滚动列表时使用它。我们准备一个loadMore函数,该函数会使用fetchMore之前解释过的最后一个已获取人员的光标来调用它。该updateQuery选项允许我们描述如何处理新获取的人员数据块。我们将新获取的边与之前的边合并。
实现无限加载列表
我们将使用react-window这个组件来实现显示无限加载列表的功能。在实现之前,我们需要先安装依赖项。
yarn add react-window react-window-infinite-loader react-virtualized-auto-sizer
有必要对这些依赖关系做一些解释。react-window该函数旨在高效地显示大型列表。它仅为可见元素创建组件并重用节点。
react-window-infinite-loader是一个 HOC,它会在用户向下滚动列表时即时加载元素,并且react-virtualized-auto-sizer是一个小小的附加组件,可以帮助您显示列表,使其适应父容器中的可用空间。
这些工具都是布莱恩·沃恩设计的,它们彼此之间能够完美配合。
首先,我们调用自定义钩子来获取第一批人员数据。
import React from 'react';
import usePersons from './InfiniteList.hooks';
import './InfiniteList.css';
function InfiniteList() {
const {persons, loading, loadMore, hasNextPage} = usePersons();
}
export default InfiniteList;
现在我们添加AutoSizer组件以获取一个width属性,height该属性表示组件容器中的可用空间。
import AutoSizer from 'react-virtualized-auto-sizer';
// ...
return (
<div className="InfiniteList-list">
<AutoSizer>{({height, width}) => <div />}</AutoSizer>
</div>
);
我们添加了一个InfiniteLoader需要三个属性的组件:
isItemLoaded判断行是否已加载itemCount这是列表中将显示的元素总数。在我们的例子中,由于我们可能不知道这个数字(想想推特信息流),所以我们会使用一个小技巧。如果还有下一页需要加载,我们就将总数加 1。loadMoreItems是一个用于获取新一批人员数据的函数。
它还接受一个函数作为参数children。传递的对象中有两个变量,可以InfiniteLoader用来控制List下一步要添加的组件。
const personsCount = hasNextPage ? persons.length + 1 : persons.length;
const loadMorePersons = loading ? () => {} : loadMore;
const isPersonLoaded = index => !hasNextPage || index < persons.length;
// ...
return (
<div className="InfiniteList-list">
<AutoSizer>
{({height, width}) => (
<InfiniteLoader
isItemLoaded={isPersonLoaded}
itemCount={personsCount}
loadMoreItems={loadMorePersons}>
{({onItemsRendered, ref}) => <div />}
</InfiniteLoader>
)}
</AutoSizer>
</div>
);
List最后,我们添加了显示实际人员列表的组件。
要使其正常工作,需要一个 ` heightand`width属性。我们应该传递组件提供的值AutoSizer。`or` 属性还需要一个 `row` 值itemSize。我们还需要将变量InfiniteLoader作为 props 传递。
该List组件也接受children一个函数作为参数。它会返回index当前人员的姓名和一个style属性。您必须将该属性传递style给行的父元素,以便列表正确显示。
如果尚未加载该人物,我们会显示占位符“正在加载……”。
// ...
return (
<div className="InfiniteList-list">
<AutoSizer>
{({height, width}) => (
<InfiniteLoader
isItemLoaded={isPersonLoaded}
itemCount={personsCount}
loadMoreItems={loadMorePersons}>
{({onItemsRendered, ref}) => (
<List
height={height}
itemCount={personsCount}
itemSize={40}
onItemsRendered={onItemsRendered}
ref={ref}
width={width}>
{({index, style}) => {
let content;
if (!isPersonLoaded(index)) {
content = 'Loading...';
} else {
const {firstname, lastname} = persons[index];
content = `${firstname} ${lastname}`;
}
return (
<div className="InfiniteList-item" style={style}>
{content}
</div>
);
}}
</List>
)}
</InfiniteLoader>
)}
</AutoSizer>
</div>
);
一切就绪🙌!
我创建了一个存储库,其中包含了本文中提到的所有资源。
您可以通过在仓库根目录下执行以下命令来运行它:
yarn && yarn start
非常感谢您的反馈🙏 如果您有任何问题,请在推特上联系我@YvonnickFrin!
编辑:我为 React 实现部分创建了一个名为 `React.Abstract` 的抽象层。您可以在这篇文章react-simple-infinite-loading中了解更多关于该项目及其工作原理的信息。