美文网首页
web 开发之 —— 列表的分页

web 开发之 —— 列表的分页

作者: 冰淤 | 来源:发表于2018-06-05 12:26 被阅读640次

列表的分页是 web 开发中非常常见的一个需求,目前我接触过的分页有一下几类:

  • 静态列表
  • 动态无序列表
  • 动态有序列表

静态列表

常见于企业的时间轴列表中,它的列表内容几乎是不变的,因此使用 page + count 进行分页就可以了

动态无序列表

这里的 无序 指的是这个列表除了 动态的分数 来作为排序规则之外,没有其它的额外规则,比如说不能根据创建时间排序。常见于一些 UGC 产品的首页信息流中,它的列表顺序是根据热度时刻(每 n 秒排序一次,或者通过操作缓存的 score 实时变更)变化的,这种列表的分页在每次获取数据的时候传递一个参数:seenIds 它是一个 你看过的文章的 id 的数组,这样就可以去重了

理论上动态无序列表如果想要根据热度来严格排序,就不能分页了,只能一次取出所有的数据,就想知乎的热榜一样,而知乎的问题页面的答案列表,其实并不需要严格的按照加权票数来排序的,因此可以使用 seenIds 来获取分页数据

使用seenIds获取分页数据有性能瓶颈,我们后面再讲

动态有序列表

这里的 有序 指这个列表可以依据数据库中的某个字段来排序,通常会是 创建时间(或者自增 id ),这种列表涉及的场景最多,我们主要讲这个列表。动态有序列表有两种:

使用 minId 获取动态降序列表分页

根据某字段从大到小排列,如QQ空间的好友动态,是根据创建时间由大到小排列,由于向这个列表中插入的新数据 创建时间 只会更大,因此在分页获取时,只需要传一个 minId 的参数就可以了,告诉后端 你看过的最小的数据的 id,这样就不会出现重复数据

使用 maxId 获取动态升序列表分页

根据某字段从小到大排列,如简书评论列表,根据创建时间由小到大排列,同理认为这种情况下传递一个 maxId 给后端就可以了,但是它有一个问题就是:向列表中插入的新数据会在列表的底部,这个时候假设这样一个场景:

  1. 每页获取10条评论
  2. 评论共有11条
  3. 在只获取了第一条的情况下,创建一条新评论
  4. 创建新评论后,不刷新页面,将新的评论 push 到评论列表底部
  5. 加载下一页的评论

那么这个时候,你拿不到任何数据,因为你的 maxId 是你创建的那条评论的 id,除非你有办法在计算 maxId 的时候,将你自己新加的评论的 id 排除掉,而即使你把自己新加的评论的 id 在计算 maxId 的时候排除掉,你在获取下一页数据的时候,又会把自己新加的评论获取到,这样就 重复

因此使用 maxId 获取动态升序列表需要解决两个问题:

  1. 不要让自己创建的数据的 id 参与到 maxId 的计算中
  2. 在获取到列表数据的时候,要去重
使用 page + count 获取动态升序列表分页

既然使用 maxId 获取动态列表需要去重,那么为什么不直接使用 page + count 来获取呢?使用 page + count 来获取,也会出现数据重复,但只需要处理去重就行了,不需要计算 maxId 了,但其实使用这种方式还有另外一个问题,那就是假设如下场景:

  1. 用户打开页面时,评论共有 11 条
  2. 用户每页获取 10 条数据,且用户没有点击获取更多
  3. 过了很久之后,后台数据里的评论已经有 21 条了
  4. 用户点击下一页
  5. 这个时候对获取到的数据去重,会发现什么也没获取到
  6. 假设后台数据变成了 201 条而不是 21 条,那么顺序就乱了

因此使用 page + count 获取动态升序列表的时候有一个问题和一个弊端:

  1. 需要去重
  2. 当数据增长极快的时候,使用 page + count 无法保证顺序,并且无法获取到足量的有效数据

综上: 获取动态升序列表我们还是得使用 maxId 来分页


用代码解决问题(仅动态有序列表的前端部分)

  1. 假设我们把所有的评论存储在 comments 数组里
data: {
  comments: [],
  maxId: 0
}

// 我们在计算 maxId 的时机,就不能是在获取下一页时,因为这个时候计算的 maxId,肯定是新添加的评论的 id
// 所以这个 maxId 要在获取到分页数据时来计算

// 创建一条新评论
api.post('comment/create', { data }).then((comment) => {
  this.comments.push(comment)
})
// 获取分页数据
api.get('comment/list', { maxId: this.maxId }).then((resComments) => {
  // 将 response 的 id 存到一个 Array 里
  const resIds = resComments.map(_ => _.id)
  // 计算 maxId
  this.maxId = resComments[resComments.length - 1].id
  // 删除重复的数据
  this.comments = this.comments.filter(_ => resIds.indexOf(_.id) === -1)
  // 将新数据 merge 到旧数据里
  this.comments = this.comments.concat(resComments)
  // 这里是将老的数据删除,而不是不使用新的数据,因为新的数据更有价值
})
  1. 假设我们把分页获取到的评论存储在 comments 数组里,把自己发表的评论放在 newComments 里
data: {
  comments: [],
  newComments: []
}

// 这种情况下我们不需要维护一个 maxId 变量,因为在获取数据的时候就算就可以了

// 创建一条新评论
api.post('comment/create', { data }).then((comment) => {
  this.newComments.push(comment)
})
// 获取分页数据
api.get('comment/list', {
  maxId: comments[comments.length - 1].id
}).then((resComments) => {
  // 将 response 的 id 存到一个 Array 里
  const resIds = resComments.map(_ => _.id)
  // 数据去重
  this.newComments = this.newComments.filter(_ => resIds.indexOf(_.id) !== -1)
  // merge
  this.comments = this.comments.concat(resComments)
})

// 然后我们需要一个 computed 来合并 comments 和 newComments,并且使用 lodash 进行排序
showComments () {
  return _.orderBy(this.comments.concat(this.newComments), 'id', 'ASC')
}

通过上面的代码我们已经可以处理之前提出的两个问题,但实际业务并不是这么简单,它会更复杂,比如评论中会有子评论,而子评论也是一个 动态升序列表,接下来我们就来处理这种复杂情况

  1. 每个主评论都有一个子评论列表

这是一个常见的场景,基于这种场景,我们需要在上面两种解决方法中选一个,我认为我们应该选第一种,因为第二种是需要依赖重排序和计算属性的,而维护一个 newComments 和维护一个 maxId 的代价基本相同

// store.js
const state = {
  comments: [],
  maxId: 0
}

const mutations = {
  SET_MAIN_COMMENTS (state, comments) {
    const formatComments = comments.map(item => {
      // 假设子评论的 key 是 children_comments
      const childrenComment = item.children_comments
      // 在每个 comment 的数据里维护一个 __maxId
      return Object.assign(item, {
          __maxId: childrenComment[childrenComment.length - 1].id
      })
    })
    // 和之前相同的操作
    const resIds = formatComments.map(_ => _.id)
    state.maxId = formatComments[formatComments.length - 1].id
    state.comments = state.comments.filter(_ => resIds.indexOf(_.id) === -1)
    state.comments = state.comments.concat(data)
  },
  SET_SUB_COMMENTS (state, { comments, parentId }) {
    let parentComment = null
    let parentIndex = 0
    states.comments.forEach((item, index) => {
      if (item.id === parentid) {
        parentComment = item
        parentIndex = index
      }
    })
    if (!parentComment) {
      return
    }
    const resIds = comments.map(_ => _.id)
    // 操作一下 __maxId 即可
    states.comments[index].__maxId = comments[comments.length - 1].id
    states.comments[index].children_comments = parentComment.children_comments.filter(_ => resIds.indexOf(_.id) === -1)
    states.comments[index].children_comments = states.comments[index].children_comments.concat(comments)
  },
  CREATE_MAIN_COMMENT (state, comment) {
    state.comments.push(comment)
  },
  CREATE_SUB_COMMENT (state, { parentId, comment }) {
    state.comments.forEach((item, index) => {
      if (item.id === parentId) {
        state.comments[index].children_comments.push(comment)
      }
    })
  }
}

const action = {
  async getMainComments ({ state, commit }, { noteId }) {
    const data = await api.getComments({
      noteId,
      maxId: state.maxId
    })
    commit('SET_MAIN_COMMENTS', data)
  },
  async getSubComments ({ state, commit }, { parentId }) {
    const comments = await api.getChildrenComments({
      parentId,
      maxId: state.comments.filter(_ => _.id === parentId)[0].__maxId
    })
    commit('SET_SUB_COMMENTS', { comments, parentId })
  },
  async createMainComment ({ commit }, data) {
     const comment = await api.createMainComment(data)
     commit('CREATE_MAIN_COMMENT', comment)
  },
  async createSubComment ({ commit }, { parentId, data }) {
     const comment = await api.createMainComment({ parentId, data })
     commit('CREATE_SUB_COMMENT', { parentId, comment })
  }
}
  1. 复杂业务下的多态

通过上面的代码,我们已经使用 vuex 讲评论列表的数据层抽象出来了,我们可以再思考一下在一个大型 web 应用中评论层的数据该如何聚合,我们可以使用多态来处理所有的 动态有序列表

假设一个文章页面,在通过接口获取数据时,会返回文章的同时返回评论列表的第一页数据,一般情况下,我们都是这样处理一个页面的数据的:

// store.js
const state = {
  note: {
    comments: []
  }
}

const mutations = {
  SET_NOTE (state, data) {
    state.note = data
  }
}

const action = {
  async getNote ({ commit }, { id }) {
    const note = await api.getNote(id)
    commit('SET_NOTE', note)
  }
}
// page-component.vue
async asyncData ({ store, route }) {
  await store.dispatch('note/getNote', { id: route.params.id })
}

如果我们使用多态的方式存储评论列表,那我们就可以这么做:

// ... state & mutations
const action = {
  async getNote ({ commit }, { id }) {
    return await api.getNote(id)
  }
}
// page-component.vue
async asyncData ({ store, route }) {
  const note = await store.dispatch('note/getNote', { id: route.params.id })
  store.commit('note/SET_NOTE', note)
  store.commit('comment/SET_MAIN_COMMENT', note.comments)
}

然后后端也将评论功能做成一个 service,增删改查使用同一套接口,传递一个 type 来控制不同的数据表,就可以实现一个简单而强大的评论体系了

  1. 与使用多态后的问题:精彩评论和评论重排序

前端的评论列表的数据层使用了多态后,当有特异性需求的时候就需要有特殊的操作,比如说:
* 文章页面要有评论列表,还要有精彩评论列表,如简书
* 答案列表不仅支持按照分数排序,还要支持按照创建时间重排序,如知乎

因为还没有没有接触过这样的需求,所以暂时不对这种情况进行讨论,以后再说吧╮(╯▽╰)╭

seenIds 的性能瓶颈

当我们提到使用 seenIds 做参数来去重的时候,你就应该想到了当 seenIds 无限增长时,对整个系统意味着什么,他会遇到两个问题:

  1. 如果 API 是一个 GET 请求,那么 seenIds 会被拼在 request url 后面,而 request url 是有长度限制的,因此当数据量太大,并且用户一直翻页的时候,就会报 http error 了
  2. 就算没有报 http error,当传递给后端的 seenIds 过多时,对后端来说这就是一个问题,具体的逻辑可能是这样的:
    1. DB 层前面肯定需要一个 Cache 层,所以我们获取评论列表时其实优先会去从缓存里获取
    2. 每条评论又有子评论,每条评论的点赞量、回复量又是动态的,并且评论在可编辑的情况下,导致每个主评论都是单独存储的,并不会将一篇文章下的所有评论存储在同一个 key 里
    3. 评论列表是动态的,因此也不能每次都去从数据库拿,所以可以将文章下的评论列表的 id 存成一个缓存,由于列表是动态无序的,而是通过某个综合的评分来排序,因此使用 Redis 的有序集合(sorted set)来存储 id 的数组
    4. 每次获取下一页数据时,都把这个有序集合的里的所有数据拿出来,filter 一下,再取出几条(一段 PHP 的 filter 代码:array_slice(array_diff($ids, $seen), 0, $take)),那在 seenIds 的 length 非常的大,并且数据量也非常大的时候,就会有性能瓶颈(未实践)

为了解决性能问题,使用其它方式来获取分页数据(未实践)

  1. 因为 seenIds 的性能瓶颈问题,使用了 minId 来获取下一页数据
  2. 这里的 minId 其实不是一个 id,而是一个 score,后端的 Cache 层依然还是有序集合
  3. 同一个有序集合的 score 是可以重复的,因此这里使用 score 会有两种获取分页数据的方式:
    1. 传给后端 minScore,然后获取小于这个 score 的数据,按照 score 从高到低的 N 条,那么当你看过的某条数据的 score 降低的时候,你就会重复看到这条数据。它不仅会导致重复,还会导致相同 score 的数据不会被你看到
    2. 传给后端 minScore (看过的最小的 score)和 minIds(看过的最小 score 的 id 列表), 然后通过 Redis 的 ZRANGEBYSCORE 操作有序集合,拿到列表里score >= minScore 的 X 条数据(X = minIds.length + N)resIds,然后 filter 这个 resIds,将看过的 minIds 过滤掉,就只剩下 N 条数据了,就算这样也会重复,但它至少更多的情况下能够获取到相同 score 的数据

使用 sinceId 优化动态降序列表

我们在获取动态降序列表时使用了 minId 来处理,其实我们还可以再传一个 sinceId 来优化业务层,它表示你获取到的第一条数据的 id(其实在这种情况下就是 maxId)

  1. 将 sinceId 传递给后端,后端可以判断在这个 id 之后又有几条新的数据被创建了,这样在翻页的时候,就可以提示用户:有几条新动态,QQ空间就是这样做的
  2. 当用户刷新列表(refresh)的时候,可以传递一个 sinceId 给后端,这样后端就不需要发送多余的数据给前端,前端也可以实现一个“上次看到这里”的功能,就像知乎的推荐信息流页面

相关文章

网友评论

      本文标题:web 开发之 —— 列表的分页

      本文链接:https://www.haomeiwen.com/subject/zeiesftx.html