当构建为用户提供长长的信息列表的应用程序时,如新闻提要、论坛中的帖子或聊天应用程序中的消息,我们的目标是向用户展示合理数量的信息。
在用户开始滚动浏览最初的列表后,网络客户端应立即加载更多的内容,以继续向用户提供这些信息。这个过程被称为 "无限滚动"。
想象一下,你正在浏览一个包含地球上所有人名字的列表。虽然这个列表并不是无限的,但它确实有这样的感觉。你的浏览器很可能难以处理从GraphQL服务器上扔过来的超过80亿个项目的列表。
这就产生了分页的需要。GraphQL API中的分页允许客户或我们的前台从API中查询部分项目的列表。在我们的前端,我们现在可以建立无限的滚动功能。
本文将讨论GraphQL中分页的概念以及它是如何工作的。我们将深入探讨无限滚动的概念及其应用、优点和缺点。我们还将看到如何将我们的Vue前端连接到一个提供数据列表的GraphQL API演示。有了这些,我们将演示如何在我们的前端实现无限滚动来获取数据。
- 分页和无限滚动
- 数字化分页
- 加载更多的分页
- 无限滚动
- GraphQL中的分页
- 基于偏移量的分页
- 基于ID的分页
- 无限滚动如何工作
- 滚动事件处理程序
- 交叉观察者API
- 构建我们的应用程序
- 设置我们的演示API
- 创建我们的Vue应用程序
- 建立无限滚动的功能
- 用滚动事件处理程序进行无限滚动
- 使用交叉观察者API的无限滚动功能
分页和无限滚动
分页是将内容分离或划分为独立部分的过程,称为页面。
虽然我们在本文中要完成的工作并不涉及我们创建页面来显示内容,但它仍然依赖于将内容分成若干部分的概念,以便在用户滚动时加载内容。
无限滚动是我们今天在应用程序中看到的三种主要分页形式之一。让我们快速浏览一下所有三种常见的分页形式:编号、加载更多和无限滚动。
编号式分页
当我们谈论分页时,编号式分页通常是指分页。在这种形式下,内容被分割并显示在不同的页面上。
这种形式的分页的一个很好的例子是谷歌的搜索结果。
加载更多的分页
加载更多的方法是另一种常见的分页形式。这是一个 "加载更多 "的按钮在列表的末尾,当点击时加载更多项目。它也可以以 "下一个 "按钮的形式出现,加载更多的项目,但不一定在同一页面。
负载更多分页方法的一个很好的例子是在一个博客上。通常在列表的底部有一个按钮,当它被点击时加载更多的博客文章。这种方法很适合那些希望用户在页面末尾看到页脚的网站。
还有另一种形式的加载更多的分页方法,用于未编号的页面。例如,你在Reddit的旧版本中得到这些按钮来加载更多的内容。
无限滚动
无限滚动在feeds中特别常见,用户可以继续滚动浏览一长串的项目。在这种方法中没有页面的概念,但用户滚动浏览的列表是较短列表的一部分的组合。这些主要列表的子集在用户滚动时被获取和添加。
无限滚动是新版Reddit以及大多数社交媒体平台所使用的方式。
GraphQL中的分页
我们已经看到了在应用程序中实现分页的常见方法,所以现在让我们来看看它在GraphQL中是如何工作的。虽然没有一种特定的方式来进行分页,但GraphQL官方网站给我们提供了三种主要的方式,我们可以去做:
- 基于偏移量的分页
- 基于游标的分页
- 基于ID的分页
基于偏移的分页
这种方法通常被认为是GraphQL中最简单的分页形式。对于基于偏移量的分页,查询通常包括两个参数。 first (或limit)和offset (或skip)。first 参数定义了列表返回的项目数量,offset 参数定义了我们应该在列表中跳过多少个项目。
通过这种分页设置,你的查询可能看起来像这样:
query {
people(first: 3, offset: 0) {
name
}
}
这个查询将从0,也就是第一个人开始,获得列表中的三个人。我们最后得到名单上的前三个人。
你也可以决定不从名单上的第一个人开始。也许你希望第二页有另外三个人,从名单上的第四个人开始。这不是问题。你的查询参数只需稍作改变即可。
query {
people(first: 3, offset: 3) {
name
}
}
现在的结果将偏移三个项目,并从第四个项目开始,而不是第一个项目。
我们也可以自由地改变first 和offset 的值,以满足我们的需要。offset 下面的查询从列表中得到四个项目,1 。
query {
people(first: 4, offset: 1) {
name
}
}
这意味着列表中的四个项目将从第二个项目开始。
这就是基于偏移量的分页的基本原理。虽然它很简单,但也有其缺点--即容易出现重复或遗漏数据。这个问题主要发生在分页过程中,新项目被添加到列表中。
当一个新的项目被添加时,项目在数组中的位置可能会改变。由于offset ,如果一个项目被添加到了列表的顶部,那么前一页的最后一个列表项目可能会成为下一页的第一个项目,因为它的位置已经降低了。
基于游标的分页
基于游标的分页是GraphQL中最广泛接受的分页标准。它对列表中在分页时发生的变化很有弹性,因为它不依赖于项目的位置,而是依赖于cursor 。
基于游标的分页可以通过几种方式实现。最常见的和被广泛接受的是遵循Relay GraphQL连接规范。
实现基于游标的分页的Relay连接规范可能很复杂,但给了我们更多的灵活性和信息。
这个规范给了我们一些参数,我们可以用来分页。它们包括first 和after (或afterCursor ),用于向前分页,last 和before 用于向后分页。
我们还可以访问几个字段。
让我们评估一下这个查询:
query ($first: Int, $after: String) {
allPeople(first: $first, after: $after){
pageInfo {
hasNextPage
endCursor
}
edges {
cursor
node {
id
name
}
}
}
}
你会注意到,用参数first 和after 作为字段,我们有:
pageInfo:包含在页面上的信息,包括。hasNextPage:在当前页面(或部分,子集)之后是否有其他项目endCursor: 当前页面上最后一个列表项的光标
edges:包含列表中每个项目的以下数据。cursor: 通常是一个不透明的值,可以从项目数据中生成node:实际的项目数据
现在,回过头来看上面的查询,有以下的变量。
{
"first": 2,
"after": "YXJyYXljb25uZWN0aW9uOjI="
}
下面是我们得到的响应:
{
"allPeople": {
"pageInfo": {
"hasNextPage": true,
"endCursor": "YXJyYXljb25uZWN0aW9uOjQ="
},
"edges": [
{
"cursor": "YXJyYXljb25uZWN0aW9uOjM=",
"node": {
"id": "cGVvcGxlOjQ=",
"name": "Darth Vader"
}
},
{
"cursor": "YXJyYXljb25uZWN0aW9uOjQ=",
"node": {
"id": "cGVvcGxlOjU=",
"name": "Leia Organa"
}
}
]
}
}
基于ID的分页
尽管基于ID的分页不像其他两种方法那样常用,但它仍然值得讨论。
这种方法与基于偏移量的分页相当相似。不同的是,它不是使用offset 来确定返回的列表应该从哪里开始,而是使用afterID (或者干脆是after ),接受列表中某个项目的id 。
看一下这个查询:
query {
people(first: 3, after: "C") {
name
}
}
这将从列表中获得前三个项目,这些项目的编号为id ,即C 。
这有助于解决基于偏移量的分页问题,因为返回的项目不再依赖于项目的位置。现在,它们依赖于使用id 作为唯一标识符的项目本身。
好了,现在我们已经熟悉了GraphQL中分页的工作原理,让我们深入了解一下无限滚动吧
无限滚动如何工作
我想假设我们以前都至少使用过一次社交媒体或新闻传送应用程序。因此,我们应该熟悉无限滚动是怎么回事。在你到达页面底部之前或同时,新内容会被添加到feed中。
对于JavaScript应用程序,我们可以通过两种主要方式实现无限滚动:要么使用滚动事件处理程序,要么使用Intersection Observer API。
滚动事件处理程序
对于滚动事件处理程序,我们运行一个函数,它将在两个条件下获取下面的页面:
- 滚动位置在页面的底部
- 有一个下一个页面需要获取
这个方法的通用JavaScript代码会是这样的。
window.addEventListener('scroll', () => {
let {
scrollTop,
scrollHeight,
clientHeight
} = document.documentElement;
if (scrollTop + clientHeight >= scrollHeight && hasNextPage) {
fetchMore()
}
});
在这里,我们正在监听window 中的一个scroll 事件。在回调函数中,我们得到文档的scrollTop 、scrollHeight 和clientHeight 。
然后,我们有一个if 语句,检查滚动的量(scrollTop )加上视口的高度(clientHeight )是否大于或等于页面的高度(scrollHeight ),以及hasNextPage 是否为真。
如果该语句为真,它将运行函数fetchMore() ,获得更多的项目并将它们添加到列表中。
交叉观察者API
与滚动事件处理方法不同,这个方法不依赖于滚动事件。相反,它观察一个元素在视口上是否可见并触发一个事件。
这里有一个基本的例子:
const options = {
root: document.querySelector("#list"),
threshold: 0.1,
};
let observer = new IntersectionObserver((entries) => {
const entry = entries[0];
if (entry.isIntersecting) {
fetchMore()
}
}, options);
observer.observe(document.querySelector("#target"));
我们用观察者的设置来定义选项。通过它,我们要观察根中的target 元素的可见性变化。这里的root 是一个具有id 的元素,list 。
我们还有一个threshold ,它决定了target 元素与根元素的相交程度。
我们将IntersectionObserver 函数分配给observer.value 。然后我们将一个回调函数与定义的options 一起传递。
该回调接受参数entries ,这是一个由回调接收的条目列表,每个报告其相交状态变化的目标都有一个entry 。每个条目都包含几个属性,比如isIntersecting ,它告诉我们目标元素现在是否与根相交,并且在大多数情况下,是可见的。
一旦entry.isIntersecting 为真,fetchMore() 函数就会被触发,并向列表中添加更多的项目。
建立我们的应用程序
我们将用Apollo客户端构建一个简单的Vue应用程序,与一个演示的GraphQL API对接。你可以在Netlify上找到我们将要建立的最终项目。
要开始的话,你需要
设置我们的演示API
在本教程中,我们将使用SWAPI GraphQL Wrapper,这是一个使用GraphQL构建的SWAPI封装器。
首先,从GitHub的存储库中删除。
git clone https://github.com/graphql/swapi-graphql.git
然后用下面的方法安装依赖项。
npm install
用以下方法启动服务器。
这将在一个随机的localhost端口启动GraphQL API服务器。
如果你在Windows上,在安装时遇到类似 本问题中提到的 任何问题 ,你可以按照说明来解决它。在 ,你也可以编辑第40行( ),在 - 之前添加 。然后再运行 。
package.json``build:lambdaNODE_ENVSET NODE_ENVSETnpm install
另外,你可以简单地使用这个部署好的版本进行查询。
创建我们的Vue应用程序
要创建一个新的应用程序,导航到你选择的目录并运行:
npm init vue@latest
现在,通过提示来配置你的安装:
√ Project name: ... vue-infinite-scroll
√ Add TypeScript? ... No / Yes
√ Add JSX Support? ... No / Yes
√ Add Vue Router for Single Page Application development? ... No / Yes
√ Add Pinia for state management? ... No / Yes
√ Add Vitest for Unit Testing? ... No / Yes
√ Add Cypress for both Unit and End-to-End testing? ... No / Yes
√ Add ESLint for code quality? ... No / Yes
Scaffolding project in C:\Users\user\Documents\otherprojs\writing\logrocket\vue-infinite-scroll...
Done. Now run:
cd vue-infinite-scroll
npm install
npm run dev
导航到你新创建的vue-infinite-scroll 目录,安装上面输出中列出的包,并启动应用程序。
cd vue-infinite-scroll
npm install
npm run dev
接下来,我们将安装以下软件包:
npm install --save graphql graphql-tag @apollo/client @vue/apollo-composable
我们要安装额外的@vue/apollo-composable 包,以便用Vue Composition API支持Apollo。
接下来,让我们做一些配置。
在./src/main.js 文件中,添加以下内容来创建一个ApolloClient 实例。
// ./src/main.js
import { createApp, provide, h } from 'vue'
import { createPinia } from 'pinia'
import { ApolloClient, InMemoryCache } from '@apollo/client/core'
import { DefaultApolloClient } from '@vue/apollo-composable'
import App from './App.vue'
import './assets/base.css'
// Cache implementation
const cache = new InMemoryCache()
// Create the apollo client
const apolloClient = new ApolloClient({
cache,
uri: 'http://localhost:64432'
// or
// uri: 'https://swapi-gql.netlify.app/.netlify/functions/index`
})
const app = createApp({
setup() {
provide(DefaultApolloClient, apolloClient)
},
render: () => h(App)
})
app.use(createPinia())
app.mount('#app')
在这里,我们创建了一个apolloClient 实例,其中InMemoryCache 和uri 是我们先前设置的SWAPI GraphQL服务器。
现在,我们将从GraphQL中获取数据。在./src/App.vue 文件中,让我们来设置我们的列表。
<!-- ./src/App.vue -->
<script setup>
import { computed, onMounted, ref } from "vue";
import gql from "graphql-tag";
import { useQuery } from "@vue/apollo-composable";
// GraphQL query
const ALLSTARSHIPS_QUERY = gql`
query AllStarships($first: Int, $after: String) {
allStarships(first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
edges {
cursor
node {
id
name
starshipClass
}
}
}
}
`;
// destructure
const {
// result of the query
result,
// loading state of the query
loading,
// query errors, if any
error,
// method to fetch more
fetchMore,
// access to query variables
variables
} = useQuery(ALLSTARSHIPS_QUERY, { first: 5 });
// computed value to know if there are more pages after the last result
const hasNextPage = computed(() => result.value.allStarships.pageInfo.hasNextPage);
</script>
<template>
<main>
<ul class="starship-list">
<p v-if="error">oops</p>
<!-- "infinite" list -->
<li v-else v-for="starship in result?.allStarships.edges" :key="starship.node.id" class="starship-item">
<p>{{ starship.node.name }}</p>
</li>
</ul>
<!-- target button, load more manually when clicked -->
<button ref="target" @click="loadMore" class="cta">
<span v-if="loading">Loading...</span>
<span v-else-if="!hasNextPage">That's a wrap!</span>
<span v-else>More</span>
</button>
</main>
</template>
<style scoped>
button {
cursor: pointer;
}
main {
width: 100%;
max-width: 30rem;
margin: auto;
padding: 2rem;
}
.starship-list {
list-style: none;
padding: 4rem 0 4rem 0;
}
.starship-item {
font-size: xx-large;
padding: 1rem 0;
}
.cta {
padding: 0.5rem 1rem;
background: var(--vt-c-white-soft);
color: var(--color-background-soft);
border: none;
border-radius: 0.5rem;
}
</style>
我们首先从graphql-tag 包中导入gql ,从@vue/apollo-composable 中导入useQuery 。useQuery 允许我们进行GraphQL查询。
接下来,我们用first 和after 变量来设置我们的查询ALLSTARSHIPS_QUERY ,这些变量将在我们进行查询时定义。
为了进行查询,我们使用useQuery() 。useQuery() 提供了几个属性,如result 、loading 、error 、fetchMore 和variables 。
当调用useQuery() ,我们传入实际的查询ALLSTARSHIPS_QUERY 和一个包含我们的变量的对象{ first: 5 } ,以获取前五个项目。
另外,在我们的<template> ,我们有一个<button> 与ref="target" 。这就是我们的 "加载更多 "按钮。
随着我们的进展,我们将只用它来观察我们何时到达列表的末尾,并使用交叉观察者API自动加载更多内容。
下面是我们现在应该有的东西:
构建出无限滚动的功能
让我们一步步来看看我们如何使用目标按钮来在点击时加载更多的项目。这在Apollo中是非常容易的。Apollo提供了fetchMore() 方法,顾名思义,我们可以用它来获取更多的内容并与原来的结果合并。
为了实现这个目的,我们要把fetchMore() 包在./src/App.vue 的loadMore() 函数中。
<!-- ./src/App.vue -->
<script setup>
// ...
// function to load more content and update query result
const loadMore = () => {
// fetchMore function from `useQuery` to fetch more content with `updateQuery`
fetchMore({
// update `after` variable with `endCursor` from previous result
variables: {
after: result.value?.allStarships.pageInfo.endCursor,
},
// pass previous query result and the new results to `updateQuery`
updateQuery: (previousQueryResult, { fetchMoreResult }) => {
// define edges and pageInfo from new results
const newEdges = fetchMoreResult.allStarships.edges;
const pageInfo = fetchMoreResult.allStarships.pageInfo;
// if newEdges actually have items,
return newEdges.length
? // return a reconstruction of the query result with updated values
{
// spread the value of the previous result
...previousQueryResult,
allStarships: {
// spread the value of the previous `allStarhips` data into this object
...previousQueryResult.allStarships,
// concatenate edges
edges: [...previousQueryResult.allStarships.edges, ...newEdges],
// override with new pageInfo
pageInfo,
},
}
: // else, return the previous result
previousQueryResult;
},
});
};
在这里,我们有调用fetchMore() 方法的loadMore() 函数。fetchMore() 接受一个variables 和updateQuery() 的对象。
我们将在variables 属性中定义更新的变量。在这里,我们更新了after 变量,以对应于第一个(或之前)结果的最后一个游标。
在updateQuery() ,然而,我们从新的查询结果中获得并定义edges 和pageInfo ,并重建查询结果,如果有的话。我们通过使用spread 语法连接对象属性来保留之前的result 对象的值,或者完全用新的对象属性来替代它们(比如说像pageInfo ,)。
至于边缘,我们将新的结果加入到之前的edges 数组中。
还记得 "目标 "按钮吗?我们有一个@click 处理程序,调用loadMore() 函数。
<button ref="target" @click="loadMore" class="cta">
现在,我们应该让我们的应用程序在按下按钮时加载更多的星舰。
太棒了!让我们把推进器调高一点,看看我们如何摆脱手动操作,获得真正的无限滚动感觉。首先,我们来看看如何用滚动事件处理程序来实现这一点。
用滚动事件处理程序进行无限滚动
这与我们前面所解释的工作类似。在./src/App.vue ,我们有一个onMounted() 钩子,一旦应用程序安装完毕,我们就开始监听滚动事件。
<!-- ./src/App.vue -->
<script setup>
// ...
onMounted(() => {
// listen to the scroll event in the window object (the page)
window.addEventListener(
"scroll",
() => {
// define
let {
// the amount useer has scrolled
scrollTop,
// the height of the page
scrollHeight,
// the height of viewport
clientHeight
} = document.documentElement;
// if user has scrolled to the bottom of the page
if (scrollTop + clientHeight >= scrollHeight && hasNextPage.value) {
// exccute the loadMore function to fetch more items
loadMore();
}
},
{
// indicate that the listener will not cancel the scroll
passive: true,
}
);
});
</script>
你可以看到,在滚动事件监听器的回调中,当用户滚动到页面底部时,我们会执行loadMore() 函数。
滚动事件处理方法的一个缺点是,用户必须要滚动才能发挥作用。确保页面上的内容不会太小,以便用户滚动浏览。
让我们看看它的运行情况:
很好!接下来,让我们看看如何用交叉观察者API实现同样的事情。
使用交叉观察者API的无限滚动
对于这个方法,我们需要观察一个元素,它将告诉我们什么时候已经到达了列表的末端。没有比我们的按钮更好的元素来做这件事了!
为了在我们的<script> ,我们将创建一个target (与<template> 中按钮的ref 属性使用的名称相同)变量,并将其分配给一个ref(null) 。
我们还将为observer 创建一个ref ,这将是我们在onMounted() 钩子中的IntersectionObserver() 。
<!-- ./src/App.vue -->
<script setup>
// ...
// create ref for observer
const observer = ref(null);
// create ref for target element for observer to observe
const target = ref(null);
onMounted(() => {
// ...
// options for observer
const options = {
threshold: 1.0,
};
// define observer
observer.value = new IntersectionObserver(([entry]) => {
// if the target is visible
if (entry && entry.isIntersecting) {
// load more content
loadMore();
}
}, options);
// define the target to observe
observer.value.observe(target.value);
});
</script>
在这里,在我们的onMounted() 钩子中,我们首先定义我们将传递给我们的observer 的选项。
我们通过初始化一个新的IntersectionObserver() ,并与我们的options 一起传递一个回调函数来定义观察者。
该函数接收了一个去结构化的[entry] 作为参数。然后,if 语句确定该条目是否与根相交。这个相交意味着目标在视口上是可见的,并执行loadMore() 函数。
entry 是由我们传递给observer.observe() 的参数决定的。
这样一来,我们就有了一个相当整洁的无限滚动列表了
总结
这就是我们的成果!我们创建了一个简单的Vue应用程序,从GraphQL API中获取数据,并具有无限滚动功能。
我们介绍了分页的基础知识,它的不同形式,以及分页在GraphQL中是如何工作的。我们讨论了无限滚动以及我们如何在JavaScript中实现这种效果。我们还深入探讨了实现它的两个主要方法:滚动事件处理程序和交叉观察者API。
我们用Vue和Apollo Client构建了我们的应用程序,将其连接到GraphQL API,并使用滚动事件处理程序和Intersection Observer API构建了无限滚动功能,从中获得了很多乐趣。
无限滚动是我们向用户展示大量信息的众多方式之一。它有它的优点和缺点,但有了我们在这里所涉及的一切,我相信你能够弄清楚它是否是你下一个大项目的最佳方法!