分页

记录开发duozhuavue💚主页的分页功能时的实践。

关于分页

分页的样子

  1. 有编号的分页

  2. 无编号,点击加载

  3. 无编号,滚动加载

跳转到下一页时要做什么

  1. 获取数据

    1
    2
    //根据传入的参数获取新数据
    const incoming = getNextPage(...);
  2. 更新缓存

    1
    2
    3
    4
    5
    6
    7
    // 每个字段都可以有自己的 merge() 函数用于配置缓存合并策略
    function merge(existing, incoming) {
    ...
    existing = [...existing, ...incoming];
    ...
    return existing;
    }

想要的功能

在已经有缓存的情况下,分页读取、显示(paginated read)缓存数据。

尝试

基本配置

graphql 查询设置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// first: 获取几个分类
// after: 从哪个分类开始获取分类
// itemsFirst: 每个分类下获取几本图书
// itemsAfter: 从哪个地方开始获取图书
export const GET_CATEGORY_FEED = gql`
query getCategoryFeed(
$first: Int
$after: String
$itemsFirst: Int
$itemsAfter: String
) {
categoryFeed(first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
id
name
items(first: $itemsFirst, after: $itemsAfter) {
pageInfo {
endCursor
hasNextPage
}
edges {
node {
id
title
rawAuthor
doubanRating
originalPrice
image
}
}
}
}
}
}
}
`

关于 categoryFeed 中的 pageInfoedges,可以参考 GraphQL Cursor Connections Specification

设置缓存合并策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Query: {
fields: {
categoryFeed: {
...relayStylePagination(),
keyArgs: false,
// 自定义read函数,实现分页读取缓存
read(existing, {args: {first, after}}) {
const res = {};
// 根据 after 参数选择缓存中的数据返回
res = {...}
return res;
}
},
},
},

缓存的合并策略,可以参考 Customizing the behavior of cached fields

relayStylePagination 可以参考 Relay-style cursor pagination

使用 fetchMore()

查询设置

1
2
3
4
5
6
7
8
9
10
11
12
13
const after = ref('') // 从哪里开始获取数据
const first = ref(1) // 获取几条数据
const {
result: categoryFeedResult,
loading: categoryFeedLoading,
error: categoryFeedError,
fetchMore
} = useQuery(GET_CATEGORY_FEED, () => ({
after: after.value,
first: first.value,
itemsAfter: '',
itemsFirst: 3
}))

首次使用

  1. 页面加载

    请求第一条数据,缓存为空,于是请求服务器,获得第一条数据,写入缓存,返回给页面

  2. 点击加载更多分类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    const loadMoreCategories = function () {
    fetchMore({
    variables: {
    after: cursor.value // 这里的cursor是第一页数据的 pageInfo 中的信息
    }
    }).then(({ data: { categoryFeed } }) => {
    after.value = categoryFeed.pageInfo.endCursor
    console.log(
    '$after is updated, there will be a new categoryFeed query...'
    )
    })
    }

    调用 loadMoreCategories() ,执行带有新参数的 fetchMore()

    新数据会依照合并策略写入缓存

    👀 为了读取更新后的缓存,需要在 then() 中更新 after 的值(更新会触发查询执行,该 after 参数可以用于请求缓存中特定部分的数据

  3. 再次点击加载更多分类

刷新页面后

  1. 页面加载
  2. 点击加载更多分类
  3. 再次点击加载更多分类

效果及问题

看起来没什么问题!但是问题发生在有缓存时的第一次点击之后

缓存中的分类如今有三条数据

刷新页面,页面加载,ok!

点击加载更多分类,ops!缓存中的分类变成两条了!!

问题分析

在有缓存的情况下,我的期望是从缓存读取数据。但是实际情况是,每次调用 fetchMore() 都会请求服务器数据。

看了一下 fetchMore()源码,它是这么定义的

解决方法

  • 不用fetchMore()
  • 重新定义缓存合并策略,如果新结果已经被缓存,就不执行merge()函数(...relayStylePagination()中有默认的 merge()函数

使用 useQuery

为了不使用 fetchMore() ,刚开始我使用了 useLazyQuery(), 它会返回一个需要主动调用获取数据的 load() 函数。我在页面挂载后,调用 load() 获取第一页数据,在点击发生时再次调用 load() 并传入相应的参数,获取新数据。这种实现满足了一些需求,但是也有它的问题。于是我又去考虑别的策略,很快就意识到,相同的逻辑其实利用 useQuery() 也可以实现,尽管它们存在相同的问题。

给查询传入响应式变量,想要获取下一页数据时,只需要更改变量的值(查询会自动更新

查询设置

1
2
3
4
5
6
7
8
9
10
11
12
const after = ref('') // 从哪里开始获取数据
const first = ref(1) // 获取几条数据
const {
result: categoryFeedResult,
loading: categoryFeedLoading,
error: categoryFeedError
} = useQuery(GET_CATEGORY_FEED, () => ({
after: after.value,
first: first.value,
itemsAfter: '',
itemsFirst: 3
}))

首次使用

  1. 页面加载

    请求第一条数据,缓存为空,于是请求服务器,获取第一条数据,写入缓存,返回到页面

  2. 点击加载更多分类

    1
    2
    3
    const loadMoreCategories = function () {
    after.value = cursor.value // 只需要更新 after 的值
    }

    调用 loadMoreCategories()

    查询再次执行,缓存未命中,请求服务器,得到第二条数据,写入缓存,返回到页面

刷新页面

  1. 页面加载

    after 参数为 ""

    请求第一条数据,缓存命中,返回到页面

  2. 点击加载更多分类

    调用 loadMoreCategories()

    查询再次执行,命中缓存,得到两条数据,返回到页面

    1
    2
    3
    4
    // 自定义缓存的 read() 函数
    res.edges = [
    ...existing.edges.slice(0, startIndex + first); // 总是从头开始读取数据
    ];

问题

🤨 在没有缓存的情况下,请求新数据时,该组件会整体刷新

因为 categoryFeed 查询确实是重新执行了一次,所以整体刷新是正常现象。这种正常现象不是我要的效果。

重新定义缓存合并策略

  • TODO

trade-off

relayStylePagination() 的默认设置中,对缓存的读取是全部读取。也就是说,当你从别的页面回到主页,你可以看到之前得到的所有数据,并没有分页地读取缓存数据。

我也在想,在主页这样滚动浏览的情境下,分页读取到底有没有必要,想了半天,好像是没必要啊!

回到最初的起点

最终我还是决定采用使用 fetchMore() 的方法。虽然绕了一圈,但是这段时间为了解决分页问题不停探索,还是学到了很多东西。

🎏 写完duozhuavue就去面duozhuayu

参考

Doc - apollo 分页

Doc - apollo 使用 fetchMore 增量加载

Doc - Vue Apollo 分页

Blog - Understanding pagination