基于光标的分页
我们建议您阅读核心分页 API在学习基于指针的分页具体考虑因素之前。
使用列表元素 ID 作为指针
由于分页列表中的数值偏移可能是不可靠的,常见的改进是使用一些唯一标识符来识别页面开始的元素。
如果列表代表一组无重复的元素,这个标识符可以是每个对象的唯一 ID,从而使用列表中最后一个对象的 ID 和某些limit
(限制参数)来请求额外的页面。
由于列表的元素可能是归一化的Reference
对象,您可能需要在merge
和/或read
函数中使用options.readField
辅助函数来获取id
字段:
const cache = new InMemoryCache({typePolicies: {Query: {fields: {feed: {keyArgs: ["type"],merge(existing, incoming, {args: { cursor },readField,}) {const merged = existing ? existing.slice(0) : [];let offset = offsetFromCursor(merged, cursor, readField);// If we couldn't find the cursor, default to appending to// the end of the list, so we don't lose any data.if (offset < 0) offset = merged.length;// Now that we have a reliable offset, the rest of this logic// is the same as in offsetLimitPagination.for (let i = 0; i < incoming.length; ++i) {merged[offset + i] = incoming[i];}return merged;},// If you always want to return the whole list, you can omit// this read function.read(existing, {args: { cursor, limit = existing.length },readField,}) {if (existing) {let offset = offsetFromCursor(existing, cursor, readField);// If we couldn't find the cursor, default to reading the// entire list.if (offset < 0) offset = 0;return existing.slice(offset, offset + limit);}},},},},},});function offsetFromCursor(items, cursor, readField) {// Search from the back of the list because the cursor we're// looking for is typically the ID of the last item.for (let i = items.length - 1; i >= 0; --i) {const item = items[i];// Using readField works for both non-normalized objects// (returning item.id) and normalized references (returning// the id field from the referenced entity object), so it's// a good idea to use readField when you're not sure what// kind of elements you're dealing with.if (readField("id", item) === cursor) {// Add one because the cursor identifies the item just// before the first item in the page we care about.return i + 1;}}// Report that the cursor could not be found.return -1;}
由于项目可以从列表中移除、添加或在列表内部移动,而无需更改其id
字段,这种分页策略比上面我们看到的基于偏移量的策略对列表突变具有更高的弹性。
然而,当您的merge
函数始终将新页面附加到现有数据时,该策略效果最好,因为它没有采取任何预防措施来防止cursor
位于现有数据中间时覆盖元素。
使用映射来存储唯一项
如果您的分页字段逻辑上表示一个集合的唯一项,您可以使用比数组更方便的数据结构在内部存储它。
实际上,您的merge
函数可以以任何您喜欢的格式返回内部数据,只要您的read
函数通过将其内部表示转换为列表来协作:
const cache = new InMemoryCache({typePolicies: {Query: {fields: {feed: {keyArgs: ["type"],// While args.cursor may still be important for requesting// a given page, it no longer has any role to play in the// merge function.merge(existing, incoming, { readField }) {const merged = { ...existing };incoming.forEach(item => {merged[readField("id", item)] = item;});return merged;},// Return all items stored so far, to avoid ambiguities// about the order of the items.read(existing) {return existing && Object.values(existing);},},},},},});
有了这种内部表示,您就不再需要担心传入的项目覆盖无关的现有项目,因为映射的赋值只会替换具有相同id
字段的项目。
然而,这种做法留下了一个重要的问题没有回答:在请求下一页时应该使用什么cursor
。多亏了JavaScript对象键通过插入顺序的可预测排序,您可以使用read
函数返回的最后元素的id
字段作为下一请求的cursor
——尽管如果您依赖这种行为而感到不安,您并不孤单。在下一个问题中,我们将看到一种略微不同的方法,将下一cursor
做得更明确。
将光标与项目分开
分页光标通常是从列表项的ID字段派生的,但并不总是这样。当列表可能包含重复项,或根据某些标准进行排序或过滤时,光标可能需要编码列表内的一个位置,还需要编码生成列表的排序/过滤逻辑。在这种情况下,由于光标在逻辑上不属于列表元素,因此光标可能会与列表分开返回:
const MORE_COMMENTS_QUERY = gql`query MoreComments($cursor: String, $limit: Int!) {moreComments(cursor: $cursor, limit: $limit) {cursorcomments {idauthortext}}}`;function CommentsWithData() {const {data,loading,fetchMore,} = useQuery(MORE_COMMENTS_QUERY, {variables: { limit: 10 },});if (loading) return <Loading/>;return (<Commentsentries={data.moreComments.comments || []}onLoadMore={() => fetchMore({variables: {cursor: data.moreComments.cursor,},})}/>);}
为了展示字段策略系统的灵活性,这里提供了一个实现Query.moreComments字段的例子,该字段内部使用映射,但返回一个唯一的comments数组:
const cache = new InMemoryCache({typePolicies: {Query: {fields: {moreComments: {keyArgs: false,merge(existing, incoming, { readField }) {const comments = existing ? { ...existing.comments } : {};incoming.comments.forEach(comment => {comments[readField("id", comment)] = comment;});return {cursor: incoming.cursor,comments,};},read(existing) {if (existing) {return {cursor: existing.cursor,comments: Object.values(existing.comments),};}},},},},},});
由于光标明确地作为查询的一部分存储和返回,因此现在更清楚地了解光标来自何方。
Relay风格的光标分页
尽管一些更简单的方法有已知的缺点,但InMemoryCache字段策略API允许使用任何可能的分页风格。
如果您正在设计一个没有提供灵活性的GraphQL客户端,您很可能会尝试标准化一种适合所有情况的分页风格,这种风格被认为足够复杂,可以支持大多数用例。这就是Relay,另一个流行的GraphQL客户端,选择了他们的Cursor Connections Specification。因此,许多公共GraphQLAPI已经采用Relay连接规范,以最大限度地与Relay客户端兼容。
使用Relay风格连接类似于基于光标的分页,但在查询响应的格式上有所不同,这影响了光标的处理方式。除了connection.edges(一个包含{ cursor, node }对象的列表)以外,每个节点都是列表项,Relay还提供了一个connection.pageInfo对象,它给出connection.edges中第一个和最后一个项目的光标作为connection.pageInfo.startCursor和connection.pageInfo.endCursor。该pageInfo
对象还包含布尔型属性hasPreviousPage和hasNextPage,可以用来确定是否有更多结果可用(向前和向后):
const COMMENTS_QUERY = gql`query Comments($cursor: String) {comments(first: 10, after: $cursor) {edges {node {authortext}}pageInfo {endCursorhasNextPage}}}`;function CommentsWithData() {const { data, loading, fetchMore } = useQuery(COMMENTS_QUERY);if (loading) return <Loading />;const nodes = data.comments.edges.map((edge) => edge.node);const pageInfo = data.comments.pageInfo;return (<Commentsentries={nodes}onLoadMore={() => {if (pageInfo.hasNextPage) {fetchMore({variables: {cursor: pageInfo.endCursor,},});}}}/>);}
幸运的是,可以使用 Apollo Client 实现类似 Relay 的分页,通过 Apollo 客户端 的 merge
和 read
函数来,这意味着所有有关连接 edges
和 pageInfo
的棘手细节都可以抽象成一个单次且可复用的辅助函数:
import { relayStylePagination } from "@apollo/client/utilities";const cache = new InMemoryCache({typePolicies: {Query: {fields: {comments: relayStylePagination(),},},},});
无论是需要使用 Apollo Client 消费 relay分页API时, relayStylePagination 都是一个非常不错的尝试工具,即使最终你可能需要复制粘贴其代码并根据你的特定需求进行修改。
请注意,relayStylePagination
函数生成一个具有 字段read
函数的策略,该函数简单返回所有可用数据,忽略 args
,这使得 relayStylePagination
更易于与 fetchMore
结合使用。这是一个 非分页 read
函数。没有任何阻碍你将这个 read
函数调整为接受 args
以返回单独的页面,只要你记得在调用 fetchMore
之后再更新你原始 variables 查询的数据。